type: reference title: "Renderer objects and temporal playback" description: "Experimental renderer objects for standalone MapLibre users: class breaks, unique values, heatmaps and clustering as serializable descriptors that compile deterministically to MapLibre style fragments, plus temporal playback." resource: "https://www.npmjs.com/package/@honua/sdk-js"

Renderer objects and temporal playback

Status: @experimental — the surfaces ship on the stable /style and /map entrypoints but the descriptor shapes may still change in minor releases.

First-class renderer objects give standalone MapLibre users the smart-mapping vocabulary Esri/CARTO users expect — class breaks, unique values, heatmaps, clustering — without Honua becoming a renderer. A renderer object is a serializable, renderer-neutral description that compiles deterministically to MapLibre style fragments through the /expr builder. Compilation is a pure function: the same descriptor always produces byte-identical style JSON, and nothing is cached.

import { classBreaksRenderer } from "@honua/sdk-js/style";

const renderer = classBreaksRenderer({
  field: "magnitude",
  breaks: [
    { min: 0, max: 3, label: "Minor" },
    { min: 3, max: 6, label: "Moderate" },
    { min: 6, label: "Strong" },
  ],
  colors: ["#fed976", "#fd8d3c", "#b10026"],
  defaultColor: "#cccccc",
});

const [fragment] = renderer.toMapLibre("point"); // "point" | "line" | "polygon"
map.addLayer({ id: "quakes", type: fragment.type, source: "quakes", paint: fragment.paint });
renderer.legendItems(); // [{ kind: "class-break", label: "Minor", color: "#fed976", minValue: 0, maxValue: 3 }, …]

The four renderers

All four factories live on @honua/sdk-js/style and share the same contract: toMapLibre(geometryType) returns layer fragments, legendItems() returns legend metadata, and toJSON() returns a serializable descriptor that rendererFromJSON() revives.

Factory Compiles to Notes
classBreaksRenderer({ field, breaks, colors?, defaultColor? }) one step expression per styled property breaks[i].min ?? max is the step threshold; positional colors fill in omitted break colors
uniqueValueRenderer({ field, values, defaultColor? }) one match expression per styled property field2/field3 + fieldDelimiter concatenate multi-field keys
heatmapRenderer({ weightField?, radius?, intensity?, colorRamp? }) a single heatmap layer fragment colorRamp is explicit { stop, color } stops or a color list spread evenly over density 0..1
clusterRenderer({ radius?, maxZoom?, countField?, steps }) clusters + cluster-count + unclustered fragments toMapLibreSource() returns the GeoJSON-source cluster options; countField sums a property per cluster instead of counting points

Advanced entries accept a style: { paint, layout } override instead of the color shorthand — that is how WebMap symbols round-trip losslessly.

Legend metadata (stable contract)

legendItems() always yields { kind, label, color }, with value on unique-value entries, minValue/maxValue on ranged entries (class breaks, cluster steps), and the ramp position in value for heatmap stops. A trailing kind: "default" item appears when the renderer declares a default color/label/style. Legend components can consume this without knowing the renderer kind.

Serialization

Descriptors are plain JSON:

const descriptor = renderer.toJSON(); // safe to persist / send over the wire
const revived = rendererFromJSON(descriptor); // identical compile output

One implementation everywhere

WebMap JSON conversion (@honua/sdk-js/webmap's convertRenderer) and the client-compat renderer shims emit these objects and compile through the same /expr path — there is exactly one class-breaks/unique-value compiler in the SDK, and the WebMap converter's style output is unchanged byte for byte.

Bridge integration

mountSource(map, source, { renderer }) derives the mounted layer set from a renderer object; explicit paint/layout overrides still win per property. A cluster renderer additionally configures GeoJSON-source clustering (cluster, clusterRadius, clusterMaxZoom, clusterProperties) and therefore requires the "geojson" strategy.

import { mountSource } from "@honua/sdk-js/map";
import { uniqueValueRenderer, heatmapRenderer } from "@honua/sdk-js/style";

const mounted = await mountSource(map, data.source(), {
  renderer: uniqueValueRenderer({
    field: "priority",
    values: [
      { value: "high", color: "#b91c1c" },
      { value: "low", color: "#0f766e" },
    ],
  }),
});

// Swap renderers without teardown where MapLibre allows: same layer
// structure → per-property setPaintProperty/setLayoutProperty diff;
// structural change → only the owned layers (and, for cluster-option
// changes, the source) are recreated.
await mounted.setRenderer(heatmapRenderer({ weightField: "priorityScore" }));

Diagnostics record which path ran: renderer-applied (in-place diff), renderer-recreated-layers, or renderer-recreated-source.

Temporal playback

createTemporalPlayback from @honua/sdk-js/map is a renderer-neutral play/pause/scrub controller over a time extent with a bounded frame cadence. It drives time filters through a thin binding — either the bridge handle (MountedSource.setFilter with a generated where clause) or client-side MapLibre layer filters composed with each layer's bind-time filter.

import { createTemporalPlayback } from "@honua/sdk-js/map";

const playback = createTemporalPlayback({
  handle: mounted, // or layer: { map, layerIds: ["quakes"] } for client-side filters
  timeField: "event_time",
  extent: ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"],
  windowMs: 24 * 60 * 60 * 1000, // one-day window
  stepMs: 6 * 60 * 60 * 1000, // advance six hours per frame
  frameIntervalMs: 400,
  loop: true,
});
playback.on("tick", ({ window, progress }) => updateSlider(progress));
playback.play();
playback.scrub("2026-01-15T00:00:00Z");
playback.setWindow(3 * 24 * 60 * 60 * 1000);
playback.dispose();

Memory is bounded by construction: the controller keeps only the current window, and filter application is coalesced — at most one request in flight plus the single latest pending window; intermediate windows are dropped, never queued. No window data is pre-fetched or cached by the controller. Playback over static data has no realtime dependency; layering it over a live feed is the realtime resume contract's concern, and that integration is intentionally out of scope here.

For the layer binding the time property must be numeric (epoch milliseconds) because MapLibre filters compare numbers; the handle binding generates <timeField> >= <startMs> AND <timeField> < <endMs> by default and accepts a formatWhere override for servers that need date literals.

Demos