API
Consumer API reference for
createInstance({ map, document? }).
Note
API heading names and api-contract marker blocks are enforced by sync automation. Change them only alongside matching tests and sync scripts.
Quick start
The consumer creates and owns the MapLibre map, then injects it into Waymark:
import { Map } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { createInstance } from "waymark-js";
const map = new Map({
container: "map",
style: "https://tiles.openfreemap.org/styles/bright",
center: [-128.0097, 50.6539],
zoom: 13,
});
const instance = createInstance({
map,
document: {
config: { id: "map" },
data: {
layers: [
{
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { title: "The Scarlet Ibis" },
geometry: { type: "Point", coordinates: [-128.0097, 50.6539] },
},
],
},
},
],
},
},
});
instance.on("waymark:data.layer.mounted", (event) => {
console.log("Layer mounted", event.detail);
});
console.log(instance.toJSON());
MapLibre GL JS is a peer dependency: install and bundle maplibre-gl yourself, and import its stylesheet (maplibre-gl/dist/maplibre-gl.css). Waymark keeps maplibre-gl external at build time and ships no CSS or worker code, so the consumer's bundler resolves MapLibre — and its worker — from the consumer's own installation. When you are finished, call instance.destroy() to remove Waymark-owned layers and listeners; the map itself remains the consumer's to remove.
Factory signature
createInstance({ map, document? })
| Parameter | Type | Required | Behaviour |
|---|---|---|---|
map | MapLibre Map-compatible object | Yes | Consumer-owned map. Waymark validates the method contract and throws a TypeError when it fails. See Map injection. |
document | object | No | Canonical serialisable InstanceDocument with strict top-level keys: config, state, data. Unknown top-level keys are ignored. |
Waymark guarantees strict round-trip serialisation for canonical documents. Each map takes one runtime, so round-trip uses a second map:
const first = createInstance({ map: mapA, document: instanceDocument });
const second = createInstance({ map: mapB, document: first.toJSON() });
second.toJSON(); // strict match for serialisable fields
Canonical InstanceDocument shape:
{
config: {
id?: string,
paint?: {
circle?: {
"circle-color"?: string,
"circle-radius"?: number,
"circle-opacity"?: number
},
line?: {
"line-color"?: string,
"line-width"?: number,
"line-opacity"?: number
},
fill?: {
"fill-color"?: string,
"fill-opacity"?: number
}
},
types?: Record<string, {
title?: string,
icon?: string,
iconSize?: number,
paint: {
circle?: object,
line?: object,
fill?: object
}
}>,
debug?: boolean
},
state: {
types?: Record<string, { visible?: boolean }>,
debug?: boolean
},
data: {
layers: Array<{
type?: "geojson",
data: object,
paint?: {
circle?: object,
line?: object,
fill?: object
}
}>
}
}
data.layers[] input accepts { type?: "geojson", data }. type defaults to "geojson", and serialised output normalises each layer to { type: "geojson", data }.
data must use one of these RFC7946-aligned GeoJSON shapes:
FeatureCollectionwithfeaturesarrayFeaturewithgeometrykey (objectornull)- Geometry object with
typeinPoint,MultiPoint,LineString,MultiLineString,Polygon,MultiPolygon, orGeometryCollection
Waymark validates essential geometry semantics at the data-layer boundary:
- positions must be arrays with at least two finite numbers (
longitude,latitude) LineStringmust contain at least two positionsLinearRingmust contain at least four positions and be closed (first and last positions equivalent)Polygonmust contain at least one valid ringMultiPoint/MultiLineString/MultiPolygonmust contain only valid per-member geometriesGeometryCollectionvalidates recursively through nested geometries
Map injection
map is required. The consumer creates and owns the MapLibre Map — including its container, style, basemaps, camera and attribution — and injects it into Waymark. Waymark mounts its GeoJSON overlay onto that map and mounts nothing else.
Waymark duck-types the injected value against this method contract:
getContainer, on, off, addSource, addLayer, getStyle, fitBounds
Validation errors are thrown as TypeError and name what is missing:
- No map, or a non-object value:
createInstance({ map }) requires a MapLibre-compatible map instance; received undefined. - Missing methods:
createInstance({ map }) requires a MapLibre-compatible map instance. Missing method(s): getContainer, on, off, addSource, addLayer, getStyle, fitBounds.
Each map owns exactly one Waymark runtime:
- The runtime registry is a
WeakMapkeyed by the injected map. - A second
createInstance(...)call with the same map throwsThis map already has a Waymark instance. Call destroy() on the existing instance before creating another. - Call
destroy()before creating a replacement instance for the same map.
instanceDocument.config.id is an optional namespacing id, not a DOM container id. It names event payloads and Waymark source/layer IDs, and it does not need to exist in the DOM. When omitted, Waymark generates one (waymark-<token>).
createInstance(...) is a browser-runtime API: it requires DOM access because the map does. In SSR applications, construct the map on the client only, then inject it.
Waymark never reads map-related config. config.map, state.map, basemap configuration, map options and camera state are not part of the instance document — configure them on the map itself. Unknown config keys are dropped during normalisation.
Config defaults and merge behaviour
Waymark resolves runtime config from src/config/defaults.js (defaultConfig), overridden by instanceDocument.config:
- Base:
src/config/defaults.js(defaultConfig) - Override:
instanceDocument.config - Unknown config keys are dropped during normalisation
debug:falsepaint: omitted by default (no instance-wide paint overrides); passed through as authored when providedtypes: omitted by default (no type-based rendering); passed through as authored when provided
Debug logging
When config.debug is true, Waymark logs every emitted Waymark event to the browser console with a [waymark:debug] prefix:
[waymark:debug] map waymark:instance.created
[waymark:debug] map waymark:data.layer.mounted
Debug can be controlled at runtime via the public API:
instance.debug.setEnabled(true); // start console logging
instance.debug.setEnabled(false); // stop console logging
Debug logging is console-only; there is no debug UI panel or separate public debug payload contract.
config.debug is a baseline toggle. Runtime changes are serialised as state.debug delta when they diverge from the config baseline.
Returned instance shape
createInstance(...) returns:
{
id: string, // namespacing id, not a container id
toJSON: () => InstanceDocument,
data: {
addLayer: (
layer: { type?: "geojson", data: object, paint?: object },
options?: { fitBounds?: boolean }
) => void,
fitBounds: (options?: { padding?: number }) => void
},
types: {
setVisibility: (typeKey: string, visible: boolean) => { previous: boolean, next: boolean } | null,
getVisibility: (typeKey: string) => boolean,
getAll: () => Record<string, { title: string, visible: boolean }>,
resetVisibility: () => void
},
debug: {
setEnabled: (enabled: boolean) => void
},
destroy: () => void,
on: (type, handler, options?) => void,
off: (type, handler, options?) => void,
once: (type, handler, options?) => void
}
id is the instance's namespacing identifier (config.id when provided, otherwise a generated waymark-<token>). It names event payloads and Waymark source/layer IDs; it does not need to match a DOM container id. The public API has no ui namespace, and no basemap or camera API — the injected map owns those.
toJSON() is the only public serialisation API.
Instance reuse and destroy semantics
- Reuse is map-first, via a
WeakMap-backed registry keyed by the injected map. - Each map has one Waymark runtime. Calling
createInstance(...)again with the same map throwsThis map already has a Waymark instance. Call destroy() on the existing instance before creating another. - There is no ID-based recreate path;
waymark:instance.recreatedis no longer emitted. destroy()is idempotent and safe to call more than once.destroy()removes only Waymark-owned layers/sources, detaches Waymark's map listeners (load,style.load, feature interactionclick/mousemove/mouseout), removes its registry entry, and never callsmap.remove()or touches the map container.- After
destroy(), the same map can be passed to a freshcreateInstance(...)call.
Instance event API
Events are dispatched as CustomEvents from the injected map's container (map.getContainer()), so consumers can also listen on that element directly.
on(...), off(...), and once(...) follow browser EventTarget semantics because handlers are attached to the container element.
- Use
on(type, handler, options?) - Use
off(type, handler, options?) - Use
once(type, handler, options?)
Lifecycle events:
waymark:instance.createdwaymark:instance.destroyedLifecycle event payload shape:
{
id: string;
}
Waymark no longer forwards waymark:map.* events. Listen to the injected map directly for load, moveend, camera, style and error events.
Data-layer runtime events:
waymark:data.layer.addedwaymark:data.layer.mountedwaymark:data.layer.errorData-layer mounted payload shape (waymark:data.layer.mounted):
{
id: string,
layerIndex: number,
mountedFamilies: Array<"circle" | "line" | "fill" | "symbol">,
mountedLayerIds: string[],
mountedTypes: string[]
}
Data-layer added payload shape (waymark:data.layer.added):
{
id: string,
layer: { type: "geojson", data: object, paint?: object }
}
Data-layer error payload shape (waymark:data.layer.error):
{
id: string,
stage: "validation" | "runtime",
message: string
}
Canonical runtime state events:
waymark:state.changedwaymark:state.debug.changedwaymark:state.types.visibility.changed
No-op runtime dispatches emit no state events. Camera and basemap state events (waymark:state.map.*) are gone — the injected map owns camera and style.
Feature interaction events:
waymark:feature.clickwaymark:feature.hoverwaymark:feature.leaveSee Feature interaction events for detail shape, hover/leave semantics and click priority.
State event payload shape:
{
id: string,
command: "types.visibility.set" | "types.visibility.reset" | "debug.set",
scope: "types.visibility" | "debug",
previous: unknown,
next: unknown,
meta?: { typeKey?: string },
source: string,
snapshot: {
types: Record<string, { visible: boolean }>,
debug: boolean
}
}
Feature interaction events
Waymark observes pointer interaction with its own mounted layers and emits feature events. Presentation is consumer-owned: Waymark does not create popups, mutate the cursor, move the camera, or whitelist feature properties.
Events:
waymark:feature.click— a click resolved to a rendered Waymark feature.waymark:feature.hover— the pointer entered a rendered Waymark feature.waymark:feature.leave— the pointer left the hovered feature.
Detail shape:
{
id: string, // instance namespacing id
feature: GeoJSON Feature, // { type, id?, geometry, properties }
layerId: string, // MapLibre layer that produced the hit
lngLat: LngLat | null, // pointer event lngLat
originalEvent: Event | null
}
feature is a plain GeoJSON Feature — { type: "Feature", id?, geometry, properties } — with MapLibre presentation internals (layer, source, state, _vectorTileFeature, ...) stripped. id is included only when the rendered feature carried one.
Hover and leave semantics:
hoverfires on entry only. While the pointer stays over the same rendered feature, Waymark suppresses repeats. Feature identity is the MapLibrelayerIdplus the featureid; when the feature has noid, Waymark falls back to a geometry/properties signature.leavefires when the pointer moves onto empty map space, onto a different feature (the previous feature emitsleave, then the new feature emitshover), or off the map canvas.- The
leavedetail carries the previously hoveredfeatureandlayerId, paired with the current pointer event'slngLatandoriginalEvent. - Listeners detach on
destroy(); no feature events are emitted after destroy.
Overlapping hits resolve by per-layer priority: the highest-priority observed layer wins, and ties keep MapLibre render order. See Click priority for the layer priority table. The layerId on each event is the MapLibre layer that produced the hit — an invisible hit-circle, a regular circle, or a line/fill layer. Icon symbol layers are visual only and are not queried; an icon click resolves through its companion hit circle.
Hit-circle layers
Every point family gets a companion invisible hit-circle layer for reliable pointer targeting. When a feature renders as a MapLibre circle or icon symbol layer, an invisible circle (radius 14, circle-opacity: 0) carrying the same waymarkType filter is mounted as its own layer.
- Icon types: the hit circle is
{baseLayerId}-circle-hit-{typeKey}, coloured with the type'scircle-color - Regular circles: the untyped hit circle is
{baseLayerId}-{family}-hit; typed circle sublayers get{baseLayerId}-{family}-type-{typeKey}-hit
These hit-circles are invisible to the user but detectable by queryRenderedFeatures, ensuring clicks near point features are captured reliably even when the visual marker or icon is small. The feature interaction module queries hit-circles at a higher priority than visible circle layers.
Click priority
When multiple observed layers overlap at the cursor position, the layer with the highest numeric priority wins when resolving the clicked feature:
| Priority | Layer type |
|---|---|
| 2 | Invisible hit-circle layers |
| 1 | Regular circle layers |
| 0 | Line and fill layers |
Symbol icon layers are never queried — MapLibre symbol hit areas cover only the icon's opaque pixels — so clicking an icon resolves through its companion invisible hit circle (priority 2). This ensures hit circles are preferred over visible circle layers, which in turn are preferred over line and fill layers. The priority system is internal feature-interaction layer knowledge and is not exposed in the public API beyond the layerId carried on feature event details.
InstanceDocument shape
instance.toJSON() returns a canonical serialisable InstanceDocument object:
{
config: {
id: string,
paint?: {
circle?: object,
line?: object,
fill?: object
},
types?: Record<string, {
title?: string,
icon?: string,
iconSize?: number,
paint: {
circle?: object,
line?: object,
fill?: object
}
}>,
debug: boolean
},
state: {
types?: Record<string, { visible?: boolean }>,
debug?: boolean
},
data: {
layers: Array<{
type: "geojson",
data: object,
paint?: {
circle?: object,
line?: object,
fill?: object
}
}>
}
}
config carries only authored/default Waymark config. There is no config.map or state.map slice: style, basemaps and camera belong to the consumer's map and are never serialised by Waymark.
state is persistence delta only. Unchanged/default branches are omitted.
state.typesappears only when type visibility diverges from default ("visible"); hidden types only.state.debugappears only when runtime debug state diverges from config baseline.
instance.toJSON() is intentionally strict and serialisable so that createInstance({ map, document: instance.toJSON() }) can be reused as canonical input.
toJSON() keeps config stable to authored/default intent.
Runtime-enriched metadata (for example GeoJSON source/layer IDs and lifecycle phase) is intentionally excluded from toJSON().
Internal orchestration boundaries are documented in docs/3.instances.md.
Initial GeoJSON overlay
Waymark supports data layers from both:
- initial document input (
instanceDocument.data.layers[]) - runtime additions (
instance.data.addLayer(layer, { fitBounds?: boolean }))
Canonical layer input shape:
{ type?: "geojson", data: <GeoJSON> }
Current support is geojson only. If type is omitted, Waymark defaults it to "geojson".
Mounting and remounting
- If the injected map is already loaded (
map.loaded()), initial layers mount immediately and synchronously. Waymark performs no asynchronous asset loading before mounting. - If the map is not yet loaded, Waymark waits for the map's
loadevent and mounts then. - Waymark listens for
style.loadand remounts every Waymark-owned source and sublayer after a consumer style swap (for examplemap.setStyle(...)), so the overlay survives style changes. destroy()removes only Waymark-owned layers and sources (top-first) and detaches these listeners. It never removes the map or touches its container.
instance.data.addLayer(...) emits:
waymark:data.layer.addedon successwaymark:data.layer.mountedwhen MapLibre sublayers for the logical layer are mounted (initial load, runtime add, and style remount)waymark:data.layer.erroron failure (stageisvalidationorruntime)
Invalid runtime additions throw after emitting waymark:data.layer.error.
instance.data.fitBounds({ padding?: number }) fits the injected map viewport to encompass all currently loaded data layers. It computes the combined bounding box from every layer's GeoJSON and calls map.fitBounds(). padding defaults to 20.
- Multiple GeoJSON layers are supported.
- Data-layer stack order is top-first:
layers[0]is visually on top. - Data layers are inserted below the first
symbollayer already present in the map style; when the style has no symbol layer they are appended. - Geometry families are rendered by layer type:
PointandMultiPoint→circleLineStringandMultiLineString→linePolygonandMultiPolygon→fill
- Each family sublayer is gated by its own geometry-type filter (
["==", ["geometry-type"], "Point" | "LineString" | "Polygon"]), so a mixed FeatureCollection renders each family only for its own geometries — circles no longer appear at line/polygon vertices, and fills no longer close line bands. Type filters are ANDed with this gate via["all", ...].
- A single logical data layer may mount multiple MapLibre sublayers when the GeoJSON contains mixed families.
- Each data layer is assigned a colour from a 20-colour pool, cycling per-layer index. Pool colours are used as the default when no
config.paint,layer.paint, or config.types-based paint is provided. - Each sublayer may also participate in type-based rendering when
config.typesare defined and GeoJSON features include awaymarkTypeproperty. See Paint & Types for the full merge semantics.
Paint & Types
Waymark supports per-layer paint overrides and type-based visual rendering through config.paint, per-layer paint, and config.types.
config.paint (instance-wide paint defaults)
config.paint provides instance-wide paint overrides scoped by geometry family:
{
paint: {
circle?: {
"circle-color"?: string,
"circle-radius"?: number,
"circle-opacity"?: number
},
line?: {
"line-color"?: string,
"line-width"?: number,
"line-opacity"?: number
},
fill?: {
"fill-color"?: string,
"fill-opacity"?: number
}
}
}
Values in config.paint override the pool-assigned colour but sit below per-layer and per-type paint in the merge hierarchy.
Per-layer paint
Each data layer can supply its own family-scoped paint:
{
data: { ... },
paint: {
circle?: { "circle-color"?: string, ... },
line?: { "line-color"?: string, ... },
fill?: { "fill-color"?: string, ... }
}
}
Per-layer paint overrides both the pool colour and config.paint for that layer's sublayers.
config.types (type-based rendering)
config.types enables data-driven visual rendering keyed by feature property values. When types are defined, features with a matching waymarkType property get dedicated sublayers with type-specific paint:
{
types: {
"<typeKey>": {
title?: string,
icon?: string,
iconSize?: number,
paint: {
circle?: { "circle-color"?: string, ... },
line?: { "line-color"?: string, ... },
fill?: { "fill-color"?: string, ... }
}
}
}
}
title is an optional non-empty string label for the type. It is validated, preserved in toJSON().config.types, and returned by instance.types.getAll(), falling back to the type key when omitted.
Every type definition requires a paint block with at least one family (circle, line, or fill).
Type keys must be non-empty and match /^[a-z0-9]+(-[a-z0-9]+)*$/ (1-64 characters, lowercase a-z, digits 0-9, and hyphens only — no leading or trailing hyphens). For each defined type, Waymark creates per-family sublayers filtered by the type key, ANDed with the family's geometry-type gate: ["all", ["==", ["geometry-type"], "<family geometry type>"], ["==", ["get", "waymarkType"], "<typeKey>"]].
icon property
config.types[typeKey].icon (optional non-empty string) is a name hook: it names a map image that the consumer must register with map.addImage(typeDef.icon, image). Waymark never bundles, loads, or registers icon images — it only writes the name into the sublayer's icon-image layout.
When icon is set and the type's paint includes a truthy circle-color (for example paint: { circle: { "circle-color": "#754423" } }), point features of that type render as:
- a
symbolsublayer ({baseLayerId}-symbol-type-{typeKey}) withlayout:icon-imageset to theiconvalue,icon-sizeset toiconSize ?? 1.5,icon-allow-overlap: true,icon-ignore-placement: true; paint{ "icon-opacity": 1 } - an invisible hit circle (
{baseLayerId}-circle-hit-{typeKey}, radius 14,circle-opacity: 0, painted with the type'scircle-color) for reliable pointer targeting — MapLibre symbol hit areas cover only the icon's opaque pixels
Register the image before the layer mounts (or as soon as it becomes available); until then MapLibre logs a missing-image warning and draws no icon. Waymark emits "symbol" in the mountedFamilies payload for these layers.
If icon is set but the type's paint.circle has no truthy circle-color, the type does not take the symbol branch: it renders as the normal typed circle sublayer when paint.circle exists, or is skipped for point features.
iconSize property
config.types[typeKey].iconSize (optional positive number) sets the icon-size layout value for the icon symbol sublayer. Defaults to 1.5 when omitted. For example, iconSize: 1 renders the consumer-registered image at its native size.
iconSize has no effect unless the type takes the symbol branch — see icon property.
Type rendering plan
When types are defined, the render plan for each data layer produces:
- An untyped fallback sublayer per geometry family, filtered to features without
waymarkType(the family geometry-type gate plus["!", ["has", "waymarkType"]]), using pool colour merged withconfig.paintandlayer.paint. - A per-type sublayer for each type that provides a matching family-scoped paint (including icon symbol types), filtered by the
waymarkTypekey ANDed with the family geometry-type gate.
Type paint sits above pool colour, config.paint, and layers[].paint in the merge hierarchy for typed sublayers.
When a type has an icon property and a truthy circle-color, its point family renders as an icon symbol sublayer (with its own invisible hit circle) instead of a visible typed circle. The symbol sublayer's paint uses icon-opacity only — the consumer-registered image supplies the visuals, and the type's circle-color colours the companion hit circle.
Paint merge order (highest to lowest priority)
- Per-feature
waymarkPaint— visible circle/line/fill sublayers only; see Per-featurewaymarkPaint - Type paint (
config.types[typeKey].paint[family]) — icon symbol layers instead carry{ "icon-opacity": 1 } - Layer paint (
layer.paint[family]) - Instance paint (
config.paint[family]) - Pool colour + family defaults (radius, width, opacity)
Per-feature waymarkPaint
Any GeoJSON feature can override its resolved paint with a waymarkPaint property. When a layer contains a feature carrying the property that matches a sublayer's type filter — a matching waymarkType, or its absence for the untyped fallback (any feature, when the layer has no types) — Waymark wraps every resolved paint property of that sublayer as a coalesce expression:
[
"coalesce",
["get", "<prop>", ["get", "waymarkPaint", ["properties"]]],
"<resolved value>",
];
{
type: "Feature",
properties: {
title: "The Scarlet Ibis",
waymarkPaint: { "circle-color": "#000000", "circle-radius": 10 }
},
geometry: { type: "Point", coordinates: [-128.0097, 50.6539] }
}
The feature's value wins when present; otherwise the resolved default applies. waymarkPaint keys are MapLibre paint property names for the feature's family (circle-color, line-width, fill-opacity, and so on). Hit circles and icon symbol layers stay static — per-feature paint affects visible circle/line/fill sublayers only. waymarkPaint is an ordinary feature property: it is not stripped by feature interaction normalisation and round-trips through toJSON().
instance.types runtime API
instance.types.setVisibility(typeKey, visible: boolean) → { previous: boolean, next: boolean } | null
instance.types.getVisibility(typeKey) → boolean
instance.types.getAll() → Record<string, { title: string, visible: boolean }>
instance.types.resetVisibility()
setVisibility(boolean)hides or shows every layer owned by a type — typed family sublayers, icon symbol layers, and their hit circles — by setting MapLibre'svisibilitylayout property to"none"or"visible". Returns{ previous, next }on success,nullif destroyed or type key unknown.getVisibilityreturnstrue(visible) orfalse(hidden). Returnstruefor unknown keys.getAllreturns a map keyed by type key; each entry carries the type'stitle(falling back to the type key when omitted) and its currentvisibleboolean. Paint, icon, and icon-size details are not included.resetVisibilityrestores all types to visible (no return value).
Type visibility produces waymark:state.types.visibility.changed (alongside waymark:state.changed) state events.
Paint serialisation
config.paintandconfig.typesare serialised intoJSON().configas authored.state.typesappears intoJSON()only when type visibility diverges from default (any type set tofalse). It contains only the visibility delta:
{
types: {
"<typeKey>": { visible: false }
}
}
- Per-layer
paintis serialised alongside each layer intoJSON().data.layers[].paint.
const map = new Map({
container: "map",
style: "https://tiles.openfreemap.org/styles/bright",
});
const instance = createInstance({
map,
document: {
config: {
id: "map",
},
data: {
layers: [
{
data: {
type: "FeatureCollection",
features: [],
},
},
],
},
},
});
instance.data.addLayer({
data: {
type: "FeatureCollection",
features: [],
},
});
GeoJSON source/layer IDs are instance-scoped to avoid collisions:
- source:
waymark-{id}-geojson-source-{index} - sublayer:
waymark-{id}-geojson-layer-{index}-{family}
The {id} token is the instance's namespacing id, with characters outside [a-zA-Z0-9_-] replaced by -.
For the full data contract (validation, serialisation, runtime mounting, and dev example usage), see docs/5.data.md.