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.

Quick start — an injected map with a GeoJSON layer rendered
Quick start — an injected map with a GeoJSON layer rendered

Factory signature

createInstance({ map, document? })

ParameterTypeRequiredBehaviour
mapMapLibre Map-compatible objectYesConsumer-owned map. Waymark validates the method contract and throws a TypeError when it fails. See Map injection.
documentobjectNoCanonical 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:

Waymark validates essential geometry semantics at the data-layer boundary:

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:

Each map owns exactly one Waymark runtime:

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:

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

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.

Lifecycle events:

{
  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:

{
  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:

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:

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:

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:

Consumer-owned popup on a marker click
Consumer-owned popup on a marker click
Consumer-owned popup anchored at a line click
Consumer-owned popup anchored at a line click

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.

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:

PriorityLayer type
2Invisible hit-circle layers
1Regular circle layers
0Line 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.

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:

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

instance.data.addLayer(...) emits:

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.

Geometry families — circle, line and fill sublayers
Geometry families — circle, line and fill sublayers
Mixed families from a single data layer
Mixed families from a single data layer

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:

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:

  1. An untyped fallback sublayer per geometry family, filtered to features without waymarkType (the family geometry-type gate plus ["!", ["has", "waymarkType"]]), using pool colour merged with config.paint and layer.paint.
  2. A per-type sublayer for each type that provides a matching family-scoped paint (including icon symbol types), filtered by the waymarkType key 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)

  1. Per-feature waymarkPaint — visible circle/line/fill sublayers only; see Per-feature waymarkPaint
  2. Type paint (config.types[typeKey].paint[family]) — icon symbol layers instead carry { "icon-opacity": 1 }
  3. Layer paint (layer.paint[family])
  4. Instance paint (config.paint[family])
  5. 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()

Type visibility produces waymark:state.types.visibility.changed (alongside waymark:state.changed) state events.

Paint serialisation

{
  types: {
    "<typeKey>": { visible: false }
  }
}
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:

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.