Documentation menu

Documentation / Foundation / maps

Maps

Package
@wabbit/tome-maps
Version
v0.1.1
npm install @wabbit/tome-maps
First install from our registry?
  1. Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
FoundationPreview

Tome maps layer — a neutral map seam: the provider and geocoder contract, a registry, MapLibre, Google and Mapbox providers (SDKs stay optional peers), deferred loading until a visitor asks for the map, a fallback for a failed or blocked map, and the pin, cluster and framing helpers a legend needs.

Overview

Tome maps layer — a neutral map seam: the provider and geocoder contract, a registry, MapLibre, Google and Mapbox providers (SDKs stay optional peers), deferred loading until a visitor asks for the map, a fallback for a failed or blocked map, and the pin, cluster and framing helpers a legend needs.

Layer: domain (per ARCHITECTURE.md § The Layer Model). It owns no collection and no Payload code; it sits in the domain layer so the directory layer can import its types. Tier: free, foundation family.

A map is an enhancement. A "Directions" link works with no map, no script and no third party, so a failed or blocked map falls back to links, and a site can hold the map until a visitor asks for it. That matters most on a health or clinic site, where a third-party request before consent is a decision the site owner wants to make.

Peer dependencies

Peer ranges, copied from package.json:

PeerRangeOptional?
@wabbit/tome-core>=1.17.0 <2.0.0no — the postal address type a geocoder takes
react>=19.0.0no
maplibre-gl>=4.0.0yes — only if you use the maplibre provider
mapbox-gl>=3.0.0yes — only if you use the mapbox provider
@googlemaps/js-api-loader>=1.16.0yes — only if you use the google provider

None of the three SDKs is a hard dependency. Each provider loads its SDK with a dynamic import() inside a client-only effect, so a site pays only for the one it uses, and nothing is requested until a map actually mounts.

60-second quickstart

'use client'

import '@wabbit/tome-maps/maplibre' // self-registers the provider; no key needed
import { getMapProvider } from '@wabbit/tome-maps'
import type { MapPin } from '@wabbit/tome-maps'

const pins: MapPin[] = [{ id: 'main', lat: 39.0997, lng: -94.5786, label: 'Main office' }]

export function OfficeMap() {
  const provider = getMapProvider('maplibre')!
  const { MapView } = provider
  return (
    <MapView
      pins={pins}
      theme="light"
      loadOn="interaction"
      fallback={<a href={provider.directionsUrl({ lat: 39.0997, lng: -94.5786, label: 'Main office' })}>Get directions</a>}
    />
  )
}

With loadOn="interaction" the page requests nothing from any map host until the visitor presses "Show map". Until then the fallback and the button render in place of the map.

Server and client posture

The package root (@wabbit/tome-maps) and ./types are browser-safe and server-safe: types, the registry, and the pin, cluster and framing helpers. They import no map SDK. The provider entries ./maplibre, ./google and ./mapbox are client modules ('use client'); import them from a frontend entry point, not from payload.config.ts or other server-only code. ./google/server and ./mapbox/server are server modules (server-only) that read a server key to geocode. ./shared is internal.

API surface

EntryExports
@wabbit/tome-mapsTypes: MapPin, MapViewProps, MapProvider, Geocoder, GeocodeResult, StaticMapUrlArgs, DirectionsUrlArgs, MapGuard, MapLoadOn, LatLng. Registry: registerMapProvider, getMapProvider, getDefaultMapProvider, registerGeocoder, getGeocoder, getDefaultGeocoder, getRegisteredMapProviderIds, getRegisteredGeocoderIds, createMapRegistry, DEFAULT_MAP_PROVIDER_ID, DEFAULT_GEOCODER_ID. Helpers: pinSvgMarkup, pinVisualState, pinAriaLabel, clusterPins, clusterSvgMarkup, clusterAriaLabel, clusterBadgeInput, boundsForPins, framingPinsFor, DEFAULT_PIN_GLYPH, PIN_CSS_VARS, CLUSTER_CSS_VARS, PIN_WIDTH, PIN_HEIGHT, CLUSTER_SIZE
@wabbit/tome-maps/typesThe types above, alone
@wabbit/tome-maps/maplibremaplibreMapProvider, MapLibreMapView, mountMaplibreMap, configureMaplibreProvider, DEFAULT_MAPLIBRE_STYLE, nominatimGeocoder, configureNominatimGeocoder. Registers maplibre and nominatim on import
@wabbit/tome-maps/googlegoogleMapProvider, GoogleMapView, mountGoogleMap, configureGoogleMapsProvider. Registers google on import
@wabbit/tome-maps/google/servergoogleGeocoder, configureGoogleMapsProvider. Registers the geocoder on import
@wabbit/tome-maps/mapboxmapboxMapProvider, MapboxMapView, mountMapboxMap, configureMapboxProvider, DEFAULT_MAPBOX_STYLE. Registers mapbox on import
@wabbit/tome-maps/mapbox/servermapboxGeocoder, configureMapboxProvider. Registers the geocoder on import
@wabbit/tome-maps/sharedInternal: the guard check, the load lifecycle and the DOM marker builders the providers share. Not a supported surface

Pins

MapPin needs an id, the coordinates and a label. slug, href and an open meta bag are optional; providers never read meta, so a consumer can hang its own record on a pin and look it up by id in onPinSelect. A pin drawn with no extra fields is an ordinary pin: natural size, no status badge, labelled with its name alone, and clusters of such pins show a count with no open-share arc.

A consumer with richer pins (a directory's listings) extends MapPin and adds drawing fields. Providers read sponsored, openState ('open' | 'closing' | 'closed') and tier off a pin when they are present and draw the sponsored size, the status badge and the closed fade accordingly. Nothing else on a pin is read.

Loading and failure

loadOn controls when a map starts to load. 'visible' (the default) loads as soon as the map renders; it does not wait for the map to scroll into view. 'interaction' renders the fallback and a "Show map" button (relabel it with showMapLabel) and requests no script and no tiles until the button is pressed.

fallback renders when the SDK cannot load (a blocked script host, an uninstalled optional peer), when no key is configured for a keyed provider, and when the map style cannot be fetched before the first render. A single missing tile after the first render leaves the map in place. Without a fallback the frame is left as it is, which is how the providers behaved before fallback existed.

Guard

Every provider takes an optional guard prop: a function of the view props that must return exactly true for the map to render and load its SDK. Anything else, including a throw, blocks the map entirely: nothing renders, not even the fallback, and the SDK is never imported. mountMaplibreMap, mountGoogleMap and mountMapboxMap check it again before they touch the loader, so a map blocked by its guard cannot reach the dynamic import by any route.

This package applies no guard by default and has no notion of what a guard checks. A consumer that must fail closed injects its own on every map it renders. @wabbit/tome-blocks-directory-pack does exactly that for its age gate: it wraps each provider and always passes its guard, and keeps its own registry so a directory block can only ever look up a guarded provider.

Configuration

import { configureMaplibreProvider, configureNominatimGeocoder } from '@wabbit/tome-maps/maplibre'

configureMaplibreProvider({
  styleUrl: { light: 'https://tiles.example.com/styles/light', dark: 'https://tiles.example.com/styles/dark' },
  attributionText: 'Tiles by Example',
})

configureNominatimGeocoder({ userAgent: 'your-site/1.0 (+https://your-site.example)', email: 'ops@your-site.example' })

The default MapLibre style comes from OpenFreeMap: free, no key, commercial use allowed, attribution required (the provider renders it). It carries no uptime promise and may stop at any time, so the style URL is configuration, and pointing it at a self-hosted style is a one-line change. The Nominatim geocoder is throttled to one request per second and needs an identifying user agent. Google and Mapbox keys come from boot-time config or the public NEXT_PUBLIC_DIRECTORY_GOOGLE_MAPS_BROWSER_KEY and NEXT_PUBLIC_DIRECTORY_MAPBOX_ACCESS_TOKEN variables (names kept from the directory so existing sites need no change); server keys (DIRECTORY_GOOGLE_MAPS_SERVER_KEY, DIRECTORY_MAPBOX_ACCESS_TOKEN) are read only by the /server entries and never reach the browser bundle.

  • CHANGELOG

Exports

Import path
@wabbit/tome-maps
@wabbit/tome-maps/types
@wabbit/tome-maps/shared
@wabbit/tome-maps/maplibre
@wabbit/tome-maps/google
@wabbit/tome-maps/google/server
@wabbit/tome-maps/mapbox
@wabbit/tome-maps/mapbox/server

Changelog

0.1.xMinor

  • New @wabbit/tome-maps package: the map and geocoder seam the directory used, as a free neutral package, with MapLibre, Google and Mapbox providers.

    Providers load their SDK only when a map mounts, or, with loadOn: 'interaction', only after the visitor presses "Show map". A failed or blocked map renders your fallback (for example a directions link).

    MapPin is vertical-neutral with an open meta bag. Each provider accepts an optional injected guard; none applies by default.

    The map SDKs stay optional peers.

2 fixes (0.1.1)
  • 0.1.1
    The MapLibre and Mapbox providers now load their own stylesheet, so a map draws correctly without a manual CSS import.

    Without the provider's stylesheet the canvas was not sized, MapLibre's gesture hints printed as plain text under the map, and the pins were not positioned. Each provider now imports its package's dist stylesheet beside the SDK when the map mounts.

    A bundler serves it as a local file, so nothing is requested from a map host before the visitor presses "Show map", and a guarded map loads neither the SDK nor the sheet.

  • 0.1.1
    The header status strip now updates itself like the desk strip, and a map that cannot load says so.

    The multi-office strip in the chrome announcement slot (PracticeStatusAnnouncement) is still rendered on the server, but its status line is now a small client island that recomputes from the same hours on mount, every 30 seconds and when the tab comes back, so a cached page can no longer say "Open now" after closing time while the desk strip below it says closed. Its first render equals the server's, so nothing flickers.

    When the map's tiles or style are blocked, the maps providers print a plain sentence ("The map could not load. The directions links still work.", settable with failedMessage) beside the directions links instead of failing silently.

Full changelog across all packages