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?: { ... } }

data must satisfy one of these minimal GeoJSON shapes:

Validation is strict on essential geometry semantics (RFC7946-focused, non-topology-heavy):

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:

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

Geometry families resolve to MapLibre layer types automatically:

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:

Additional sublayer ID patterns for type-based rendering:

Paint & Types

Waymark supports per-layer paint overrides and type-based visual rendering. Each geometry family has a default colour key:

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)

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

  1. Untyped fallback — features without waymarkType, using pool + config.paint + layer.paint. The filter is the family's geometry-type equality ANDed with ["!", ["has", "waymarkType"]].
  2. Per-type sublayers — one per defined type with a matching family-scoped paint, filtered by the waymarkType feature 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:

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:

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