The theme can render a location map on either Google Maps or MapLibre, and a
module that draws markers, clusters, and popups shouldn't know or care which. So
Maps is built as a swappable subsystem: a single Contract describes the
interface, each engine is an adapter that satisfies it, and Maps.load() picks
one at runtime and dynamic-imports only that one.
This adapter-behind-a-Contract pattern is Maps-specific. It is warranted only by the genuine complexity of swapping map engines — not a template to reach for by default. Wrapping a library in a Vendor is the normal boundary; the full Contract treatment is flagged in code review when a new subsystem's complexity actually merits it, and nowhere else.
The Contract is documentation, not code
utilities/Maps/Contract.js runs nothing — it exports an empty object. Its whole
job is to declare, in one place, the shapes and interfaces every engine must
implement, so the two adapters can be read against a single spec:
/**
* The map engine contract. This file is documentation only — it declares the
* shapes and interfaces every engine (GoogleMaps, and later Leaflet/MapLibre)
* must satisfy. Nothing here runs.
*/
Four interfaces make up the surface — Engine mints the objects, and each
minted object (EngineMap, EngineMarker, EngineCluster, EnginePopup) wraps
one native thing:
/**
* @interface Engine
*
* @function map - async (HTMLElement, MapOptions) => EngineMap (await .ready() for first paint)
* @function marker - async (MarkerSpec) => EngineMarker
* @function cluster - async (EngineMap, ClusterOptions) => EngineCluster
* @function popup - async (PopupSpec) => EnginePopup
*/
The conventions every engine obeys
The Contract's header spells out the rules an adapter must hold to. These are the load-bearing ones:
- Async methods only, plus a sync
get controller(). Every wrapper method returns a promise; the one synchronous accessor iscontroller, which hands back the native object beneath the wrapper for the rare case a caller needs it. - Coordinates cross the boundary as plain
{lat, lng}. Each engine converts at its own edge — MapLibre toLngLat, Google to itsLatLng— so nothing upstream speaks an engine's native geometry. - Camera moves fold their own settle. A method like
map.spotlight(pos)resolves only once the movement has come to rest. There is no event surface on the Contract; youawaitthe move and it's done. - Boolean predicates come in affirmative + inverse pairs —
isAttached/isDetached,isOpened/isClosed.
/**
* @interface EngineMap
*
* @property {Object} controller - The native map (getter).
* @function ready - async () => void (resolves once tiles have first loaded)
* @function zoom - async (undefined|string|number|LatLngLiteral[]) => number|void
* @function pan - async (LatLngLiteral) => void
* @function spotlight - async (LatLngLiteral) => void (recenter + zoom in on a position in one move)
*/
Selecting an engine
You never name a concrete engine in module code. You call Maps.load(config) with an
engine key, and get back an object that satisfies the Engine contract:
// a module drawing a map — engine chosen by config, not hard-coded
const engine = await Maps.load({ engine: 'google', /* …map options */ })
const map = await engine.map(this.element, mapOptions)
await map.ready()
Maps.load reads config.engine (defaulting to google), dynamic-imports only
that engine's module, and hands back its ready Engine. The other engine's chunk —
and its whole vendor stack — never ships. Each engine caches a single instance and
pulls in whatever it needs (Google fetches its libraries through the JS API loader
and lazy-imports SnazzyInfoWindow; MapLibre takes a Protomaps key and wraps
maplibregl), but that's behind the boundary: from Maps.load onward you speak only
Contract methods.
Related
- The vendor boundary — the lighter, default pattern; the Contract sits one layer above it.
- The Globals registry — how the LocationMap module is registered and driven.