Data
Canonical data-layer and GeoJSON contract for Waymark instances.
Canonical layer shape
Waymark uses this canonical input shape for each entry in data.layers and for instance.data.addLayer(...):
{ type?: "geojson", data: <GeoJSON>, paint?: { ... } }
typedefaults to"geojson".- Only
geojsonis currently supported. paintis optional family-scoped paint overrides for this layer. See Paint & Types below.- Unknown keys on a layer are rejected.
data must satisfy one of these minimal GeoJSON shapes:
FeatureCollectionwithfeaturesarrayFeaturewithgeometrykey (objectornull)- Geometry object with
typeinPoint,MultiPoint,LineString,MultiLineString,Polygon,MultiPolygon, orGeometryCollection GeometryCollectionadditionally requires ageometriesarray (nested geometries follow the same geometry-type rule)
Validation is strict on essential geometry semantics (RFC7946-focused, non-topology-heavy):
- position arrays require at least two finite numbers (
longitude,latitude) LineStringrequires at least two positionsLinearRingrequires at least four positions and must be closed (first and last positions equivalent)Polygonrequires a non-empty ring list and each ring must be a validLinearRingMultiPoint,MultiLineString, andMultiPolygonrequire valid per-member coordinate structuresGeometryCollectionvalidation is recursive
Public API for adding data
Runtime additions are done through:
instance.data.addLayer(layer, { fitBounds?: boolean });
fitBounds defaults to true. When true, the injected map viewport is fitted to the layer's bounding box with padding: 20 after the GeoJSON sublayers are mounted. This option is runtime-only and is not persisted in instance.toJSON().
To fit the viewport to all currently loaded data layers combined, use:
instance.data.fitBounds({ padding: 20 });
This iterates all loaded layers, computes the combined bounding box across every layer's GeoJSON, and calls map.fitBounds(). padding defaults to 20. Useful when adding multiple layers in a batch where no single layer should drive the viewport.
Runtime emits minimal data events:
waymark:data.layer.addedon successful layer addwaymark:data.layer.mountedwhen MapLibre sublayers for a logical data layer are mountedwaymark:data.layer.erroron failure with{ id, stage: "validation" | "runtime", message }
Invalid instance.data.addLayer(...) calls emit waymark:data.layer.error and then throw the same error.
Example:
instance.data.addLayer({
data: {
type: "FeatureCollection",
features: [],
},
});
toJSON() serialises layers as explicit canonical objects:
{
data: {
layers: [
{
type: "geojson",
data: { type: "FeatureCollection", features: [] },
},
],
},
}
Initial layers and runtime behaviour
- Initial layers can be provided in
createInstance({ map, document: { data: { layers: [...] } } }). - If the injected map is already loaded, initial layers mount immediately; otherwise Waymark waits for the map's
loadevent and mounts then. - Runtime-added and initial layers share the same ordering semantics: top-first (
layers[0]visually on top). - Layers are inserted below the first
symbollayer already present in the map style; when the style has no symbol layer they are appended. - Layers are re-mounted after a consumer style swap (
style.load), for examplemap.setStyle(...). destroy()removes only Waymark-owned layers and sources.- A single logical layer may mount multiple MapLibre sublayers when its GeoJSON mixes geometry families.
Geometry families resolve to MapLibre layer types automatically:
Point/MultiPoint→circleLineString/MultiLineString→linePolygon/MultiPolygon→fill
Every family sublayer is gated by a geometry-type filter so it renders only its own geometry: ["==", ["geometry-type"], "Point" | "LineString" | "Polygon"]. MapLibre normalises Multi* geometries to these base types for ["geometry-type"], so one equality per family covers every variant it owns. Without the gate, a mixed FeatureCollection would draw circles at every line and polygon vertex and render fills as closed bands; with it, each family renders only its own geometry. Type filters are combined with the geometry gate as ["all", <geometry filter>, ["==", ["get", "waymarkType"], "<typeKey>"]].
Instance-scoped IDs
GeoJSON source and layer IDs are instance-scoped and index-based to avoid collisions:
- source:
waymark-{id}-geojson-source-{index} - sublayer:
waymark-{id}-geojson-layer-{index}-{family}
Additional sublayer ID patterns for type-based rendering:
- typed sublayer:
waymark-{id}-geojson-layer-{index}-{family}-type-{typeKey} - symbol sublayer:
waymark-{id}-geojson-layer-{index}-symbol-type-{typeKey} - hit-circle layer:
waymark-{id}-geojson-layer-{index}-{family}-hit - icon-type hit-circle:
waymark-{id}-geojson-layer-{index}-circle-hit-{typeKey}
Paint & Types
Waymark supports per-layer paint overrides and type-based visual rendering. Each geometry family has a default colour key:
circle→circle-colorline→line-colorfill→fill-color
Each data layer is assigned a colour from a 20-entry pool, cycling by layer index. These pool colours are the baseline when no other paint source provides a colour value.
Paint merge order (highest to lowest)
- Per-feature
waymarkPaint— visible circle/line/fill sublayers only; see Per-featurewaymarkPaint - Type paint (
config.types[typeKey].paint[family]) - Layer paint (
layer.paint[family]) - Instance paint (
config.paint[family]) - Pool colour + family defaults (
circle-radius: 5,line-width: 3,fill-opacity: 0.35)
Per-feature waymarkPaint
A GeoJSON feature can override its resolved paint with a waymarkPaint property. When a feature carrying the property 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: {
waymarkType: "beer",
waymarkPaint: { "circle-color": "#000000", "circle-radius": 10 }
},
geometry: { type: "Point", coordinates: [0, 0] }
}
The feature's value wins when present; otherwise the resolved default applies. waymarkPaint keys are MapLibre paint property names for the feature's family. Hit circles and icon symbol layers stay static, so per-feature paint affects visible circle/line/fill sublayers only. waymarkPaint is an ordinary feature property and round-trips through toJSON(). See Per-feature waymarkPaint in the API docs.
Type-based rendering
When config.types is defined, each data layer's render plan splits into:
- Untyped fallback — features without
waymarkType, using pool +config.paint+layer.paint. The filter is the family's geometry-type equality ANDed with["!", ["has", "waymarkType"]]. - Per-type sublayers — one per defined type with a matching family-scoped paint, filtered by the
waymarkTypefeature property ANDed with the family geometry-type equality.
When a type has an icon property and a truthy circle-color in paint.circle (see API docs), its point family renders as a MapLibre symbol sublayer plus an invisible hit circle, instead of a visible circle layer. icon is a name hook: the consumer registers the image with map.addImage(icon, image), and Waymark uses the name as icon-image with iconSize (default 1.5). Waymark never loads or registers images itself. The mountedFamilies payload includes "symbol" for such layers.
Each point family (regular circle or icon symbol) also has a companion invisible hit-circle layer (radius 14, circle-opacity: 0) for reliable pointer targeting. See Hit-circle layers and Click priority in the API docs for layer ID naming and priority.
Type visibility can be controlled at runtime via instance.types.setVisibility(key, boolean). Toggling a type hides or restores every layer owned by it — typed family sublayers, icon symbol layers, and their hit circles.
The waymarkType feature property is used as a string match against config.types keys. Type keys must match /^[a-z0-9]+(-[a-z0-9]+)*$/ (1-64 characters, lowercase a-z, digits 0-9, and hyphens only — no leading or trailing hyphens).
paint key per data layer
{
data: { type: "FeatureCollection", features: [...] },
paint: {
circle: { "circle-color": "#ff0000", "circle-radius": 8 },
line: { "line-color": "#00ff00", "line-width": 2 },
fill: { "fill-color": "#0000ff", "fill-opacity": 0.2 }
}
}
paint is serialised alongside the layer in toJSON().data.layers[].
config.types example
createInstance({
map,
document: {
config: {
id: "map",
types: {
road: {
title: "Road",
paint: {
line: { "line-color": "#e6194b", "line-width": 4 },
},
},
park: {
title: "Park",
paint: {
fill: { "fill-color": "#3cb44b", "fill-opacity": 0.5 },
},
},
},
},
data: {
layers: [
{
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { waymarkType: "road" },
geometry: {
type: "LineString",
coordinates: [
[0, 0],
[1, 1],
],
},
},
{
type: "Feature",
properties: { waymarkType: "park" },
geometry: {
type: "Polygon",
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 1],
[0, 0],
],
],
},
},
],
},
},
],
},
},
});
waymark:data.layer.mounted extended payload
When types are involved, the mounted event includes the resolved type keys:
{
id: string,
layerIndex: number,
mountedFamilies: ["line", "fill"],
mountedLayerIds: [
"waymark-map-geojson-layer-0-line-type-road",
"waymark-map-geojson-layer-0-fill-type-park"
],
mountedTypes: ["road", "park"]
}
Feature interaction events
Waymark owns the layer knowledge needed to resolve what the pointer is over — hit-circle layers, per-layer priority, and the mapping from a rendered feature back to its MapLibre layer. Presentation is consumer-owned: popups, tooltips, cursor styling, and property filtering all live in the consumer.
Feature events are dispatched on the instance container:
waymark:feature.click— a click resolved to a rendered Waymark feature.waymark:feature.hover— the pointer entered a rendered Waymark feature (once per entry).waymark:feature.leave— the pointer left the hovered feature.
Detail shape (all three events):
{
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 fires on entry only; feature identity is layerId plus feature id, with a geometry/properties fallback for features without an id. Leave fires when the pointer moves to empty space, onto a different feature (leave then hover), or off the canvas; its feature and layerId describe the feature that was left, while lngLat and originalEvent come from the current pointer event. After a consumer style.load, Waymark clears its observed-layer tracking and hover state; map-level pointer handlers survive and re-observe layers as they remount. Listeners detach on destroy(); no feature events fire after destroy.
Waymark does not open popups, set the cursor, move the camera, or whitelist properties. The dev playground (dev/dev.js) shows the recommended consumer-owned popup and cursor pattern:
import { Popup } from "maplibre-gl";
// Point features anchor on their coordinate; other geometries on the click.
function resolvePopupLngLat(feature, lngLat) {
const geometry = feature?.geometry;
if (geometry?.type === "Point" && Array.isArray(geometry.coordinates)) {
return geometry.coordinates;
}
return lngLat;
}
let activePopup = null;
instance.on("waymark:feature.click", (event) => {
const { feature, lngLat } = event.detail;
if (activePopup) {
activePopup.remove();
activePopup = null;
}
const anchor = resolvePopupLngLat(feature, lngLat);
if (!anchor) {
return;
}
activePopup = new Popup({ focusAfterOpen: false })
.setLngLat(anchor)
// The dev harness renders a title, a single image (first present of
// image_thumbnail_url → image_medium_url → image_large_url), and the
// remaining property rows.
.setDOMContent(buildPopupContent(feature))
.addTo(map);
});
instance.on("waymark:feature.hover", () => {
map.getCanvas().style.cursor = "pointer";
});
instance.on("waymark:feature.leave", () => {
map.getCanvas().style.cursor = "";
});
lngLat is the pointer event position, so non-Point features anchor where the feature was clicked; Point features can anchor on feature.geometry.coordinates instead. feature is a plain GeoJSON Feature, so feature.properties is available for consumer-side presentation decisions. The dev harness's buildPopupContent(feature) renders a title heading, at most one image whose URL is the first present of image_thumbnail_url → image_medium_url → image_large_url, and the remaining properties as key/value rows; all three image_*_url keys are omitted from the rows whenever an image is displayed.
Click priority
When multiple observed layers overlap at the pointer, the highest-priority layer wins; ties keep MapLibre render order. Priority (highest first): invisible hit-circles (2), regular circles (1), lines and fills (0). Symbol icon layers are never queried — MapLibre symbols only hit-test their opaque pixels — so an icon click resolves through its companion hit circle instead. See Click priority in the API docs for the full table and layer ID naming.
Implementation references:
src/entry.jssrc/document/instanceDocument.jssrc/runtime/runtimeRegistry.jssrc/geojson/createGeoJSONModule.jssrc/geojson/createFeatureInteractionModule.jssrc/runtime/createInstanceCore.jssrc/utils/typeUtils.jsdev/dev.js
Dev example reference
The dev playground (dev/dev.js) creates its own MapLibre map on the #map container (the vendored OpenGIS basemap style served at /basemap/style.json, initial centre on Holberg, fadeDuration: 0), registers an inline photo-marker PNG via map.loadImage + map.addImage before injecting the map, then calls createInstance({ map, document }). It loads one initial InstanceDocument from /route.json plus four GeoJSON layers (track-north-island.geojson, track-port-hardy-cape-scott.geojson, markers.json, shapes.json) from /geojson/. Those files live under tests/fixtures/ — the single source of truth for the dev harness and the browser tests, served by pointing vite.config.dev.js's publicDir at that directory. The route config gives every type a title (including the area fill type) and maps photo to the photo-marker icon; the GeoJSON fixtures carry waymarkType only (the legacy duplicated type property has been removed). shapes.json adds a single area-typed Polygon that carries only image_medium_url, exercising the popup fallback. The big route line is track-port-hardy-cape-scott, and Holberg (the pub "The Scarlet Ibis") is the demo-closeup anchor. Its toolbar controls call instance.data.addLayer(...), instance.data.fitBounds(), instance.types.setVisibility(...), and instance.debug.setEnabled(...).
Adding a layer calls:
instance.data.addLayer({ data: samplePoint }, { fitBounds: true });
samplePoint is tests/fixtures/geojson/sample-point.geojson, carrying waymarkType: "beer" so the added layer also responds to the type-visibility control. The fixture point sits at [-127.998, 50.661]; the add-layer browser test asserts that centre after fitBounds.
The instance and map are also exposed as globals for manual console testing:
window.waymarkInstance; // the instance
window.waymarkInstances; // registry keyed by dev map key
window.waymarkInstances.map; // the #map instance
window.waymarkMaps; // registry keyed by dev map key
window.waymarkMaps.map; // the MapLibre map
window.createWaymarkInstance; // the createInstance factory