Skip to content
07Platform

Layers & styling

Render types, classification methods and ramps, labels and marks, 3D extrusion, remote raster and vector tiles, interactivity and legends.

What a layer is

A layer is one dataset drawn one way. A project can have many layers over the same dataset, such as a choropleth and an extrusion of the same polygons, or circles and labels over the same points, and every beat decides which of them are visible, at what opacity, with what filter.

FieldMeaning
datasetIdthe data. Omitted for a remote raster/XYZ layer, which carries remoteSource instead
renderTypefill · line · circle · symbol · heatmap · fill-extrusion · raster · hillshade
stylecolour, opacity, stroke, size, dash, mark, labels, heat radius, height, classification, raw escape hatch
baseFiltera MapLibre expression that applies on every beat
minzoom / maxzoomhand-over between layers (a heatmap to zoom 9, circles from zoom 5)
slot · zIndexdraw order: base → mid → top → label, then zIndex inside the slot
dataModefrozen (the snapshot taken at publish) or live (the viewer polls the source)
timeFieldmakes the layer time-enabled
defaultVisiblewhat happens on a beat that does not mention this layer
legendshow it, and under what title
The Layers panel and the layer inspector: stack, legend preview, render type, paint and a quantized classification.
  1. Add layer from dataset: every dataset attached to the project, plus remote raster and tile sources.
  2. Layer stack: drag to reorder; the row shows opacity and the feature count.
  3. Visibility and opacity: the eye and the slider are per layer here, and can be overridden per beat.
  4. Legend preview: what readers will see, generated from the classification.
  5. Render: changing this recompiles the MapLibre layer in place; nothing is lost.
  6. Data: frozen or live for this layer at publish time.
  7. Paint: fill, opacity and stroke; the swatch row is the workspace palette.
  8. Classify by field: switch it on to drive paint from data.
  9. Method: categorical, quantize, equal, jenks, interpolate.
  10. Drives: which property the classification controls: colour, size, opacity or height.
  11. Ramp: nine built-in ramps; brand first, diverging last.
  12. Breaks: the class edges. The method proposes them from the field statistics; you can edit any of them.

Render types

TypeGeometryStyle keys that matter
circlepointssize, color, strokeColor, strokeWidth, blur
fillpolygonscolor, opacity, strokeColor, strokeWidth
linelines (and polygon outlines)size (width), dash, color, arrow (arrowheads — see below)
symbolpointsmarkId (a Spatly mark loaded as an SDF image), iconSize, textField, textSize, textColor, textHalo
heatmappointsheatRadius, heatWeightField, maxzoom
fill-extrusionpolygonsheightField, heightScale, color; needs a pitched camera to read
rasterremote XYZ / WMS / WMTSremoteSource.tiles, tileSize, attribution, opacity
hillshadeterrain-RGB tilesremoteSource, opacity

Arrowheads on a line

A line says where; an arrowhead says which way. style.arrow draws a Spatly mark at the end of every line in the layer, rotated to the line's own heading:

a spread vector with a labelled head
{
  "color": "#22D3EE",
  "size": 2.5,
  "arrow": { "markId": "chevron", "size": 0.8, "at": "end", "label": "{{spread_km}} km" }
}
KeyMeaning
enableddefault true when the block is present; false keeps the block but draws no head
atend (default), center, or both
markIdany SDF map marker — chevron, triangle, diamond, vehicle. It takes the line colour
sizeicon-size multiplier (default 0.7)
color, labelColoroverride the line colour for the head / its label
rotateFielda property holding a compass bearing (degrees clockwise from north) that wins over the geometry — a wind direction, a forecast heading
rotateOffsetdegrees added after rotation, for a mark that does not point "up" at 0°
label, labelSizetext beside the head; {{field}} placeholders are filled from the feature

The heads are derived on the client (one point per line end) from the dataset's GeoJSON, so the layer needs a GeoJSON-served dataset — the same requirement arc and hexbin have. The head inherits the layer's filter, visibility and opacity, so a beat that hides the layer hides its arrows too.

Anything the compiler does not express is available through style.raw, a MapLibre paint / layout object merged last, so it always wins:

raw escape hatch: radius interpolated from magnitude
{ "raw": { "paint": { "circle-radius": ["interpolate", ["linear"], ["get", "mag"], 1, 2, 4, 6, 7, 16] } } }

Classification

Pick a field, a method and what it drives. The field statistics collected at ingest (min, max, distinct values, nulls) are what the method uses to propose breaks.

MethodForWhat it produces
categoricaltext fieldsone colour per value from the ramp; unmatched values take nullColor
quantizenumbersn equal-count classes, a step expression on the map
equalnumbersn equal-interval classes between min and max
jenksnumbersnatural-breaks classes; best when the distribution is lumpy
interpolatenumbersa continuous ramp, an interpolate expression, no classes

target decides what the classification drives: color (default), size (with sizeRange: [min, max]), opacity or height (extrusions).

a five-class population choropleth
{
  "field": "POP_EST",
  "method": "quantize",
  "stops": [5000000, 20000000, 60000000, 150000000],
  "ramp": "spatly-brand",
  "target": "color",
  "nullColor": "#d4d4d4"
}

Ramps

spatly-brand · spatly-categorical · spatly-cyan · spatly-ember · spatly-signal · spatly-moss · spatly-dusk · spatly-mono · spatly-diverging. A ramp can also be an explicit array of colours. Categorical data should use spatly-categorical; a value that runs low-to-high should use a sequential ramp; a value with a meaningful middle should use spatly-diverging.

tipa classification whose target is size reads better than colour for point data over a busy basemap, because size survives the map's own contrast.

Labels and marks

A symbol layer draws a Spatly mark as an SDF image, so it takes color from the style, and optionally a label from a field:

json
{ "markId": "pin-solid", "iconSize": 0.9, "textField": "iata_code", "textSize": 11, "textColor": "#ffffff", "textHalo": "#0b0f14" }

Keep labels on their own layer in the label slot with a baseFilter narrowing them to the features worth naming. Labelling everything at world zoom produces a grey smear.

3D extrusion

fill-extrusion with heightField and heightScale extrudes polygons; the classification can drive colour at the same time. Set a pitch on the beat (45 to 60°) or the extrusion is invisible. Keep the flat choropleth underneath at low opacity so the base still reads.

Remote raster and vector tiles

Add a layer from a URL instead of a dataset:

  • XYZ / WMS / WMTS raster: remoteSource.tiles: ["https://…/{z}/{x}/{y}.png"], tileSize 256 or 512, plus the attribution string the provider requires.
  • Vector tiles: a TileJSON or XYZ address plus a sourceLayer name; without the source layer MapLibre has nothing to draw.

Datasets above roughly 5,000 features are served as Spatly's own vector tiles (ST_AsMVT) at /api/v1/tiles/:datasetId/{z}/{x}/{y} and behave the same way. The difference is only where the tiles come from.

noteon a tiled layer, feature counts and client-side filters describe what is currently loaded in the viewport, not the whole dataset. Metrics stay correct because they are computed server-side.

Interactivity

Per layer:

KeyEffect
hovera tooltip built from tooltipFields
clicka popup anchored to the feature
titleFieldthe popup heading
tooltipFieldsthe rows in tooltip and popup
popupTemplatea {{field}} template that replaces the default rows
rowslabelled rows whose value is a {{field}} template — used instead of tooltipFields
mediaa clip or image played inside the popup — see below
popupTemplate
**{{place}}** · M{{mag}}
Depth {{depth}} km · {{time_iso}}

Labelled popup rows

tooltipFields prints one raw field per row under its raw name; popupTemplate replaces the whole body with free markdown. rows is the middle ground — an author-named label beside a templated value:

interactive.rows
[
  { "label": "Spread", "template": "{{spread_dir_label}} · {{spread_km}} km · {{spread_kmh_3h}} km/h" },
  { "label": "Sources", "template": "{{sources}}", "valueLabels": { "firms_eu": "FIRMS", "slstr_b": "SLSTR-B" } },
  { "label": "FRP", "template": "{{frp_max}} MW", "decimals": 1 },
  { "template": "{{expected_note}}" }
]

valueLabels renames a raw value on its way into the row — and a comma-separated list item by item ("firms_eu,slstr_b" → "FIRMS, SLSTR-B"), through the same helper tableWidget.columns uses, so a popup and a table never disagree about what a key is called. decimals fixes the decimal places of every numeric placeholder in the row (otherwise numbers print grouped, to at most three places).

A row with no label prints as a full-width note line. A row whose placeholders all resolve empty is dropped, so a feed that has not started carrying a field yet shows fewer rows rather than Spread: · km; set hideWhenEmpty: false when the literal text is the point. Up to 12 rows are printed. When rows is present it replaces the tooltipFields rows in the popup (the hover tooltip still uses tooltipFields).

In a published scroll story the camera is beat-driven, so wheel-zoom over the map scrolls the narrative instead. Hover, click and popups still work.

Media in popups

A popup can play a clip or an image that belongs to the feature, inline — no download, no leaving the page. Set interactive.media on the layer:

interactive.media
{
  "videoField": "video_url",
  "posterField": "poster_url",
  "imageField": "image_url",
  "expiresField": "expires_at",
  "autoplay": true
}
PropertyDefault fieldContract
clipvideo_urla direct URL to an MP4 (or anything the browser plays). Rendered as a <video> above the property rows, sized to the popup
stillposter_urlthe poster behind the play button. Optional
imageimage_urlshown as an <img> when the feature has no clip
expiryexpires_atan ISO timestamp. Once it is in the past the popup shows its property rows and no media — a feed that signs its URLs for 48 h stops offering dead links. A missing or unreadable expiry is not an expiry

It is opt-in, and that is deliberate. A layer with no media never shows media, so attaching a dataset that happens to carry a video_url column does not make the map start playing on its own. What the Studio does instead: when the dataset carries those fields the Media toggle in Interactivity says detected, and switching it on fills every field name in for you. One click, no typing, no surprises.

Every field name is yours to change; the defaults above are just the contract most feeds already speak — the same one videoWidget reads.

Autoplay is always muted (browsers require it) and does not start at all when the reader asks for reduced motion; the frame is reserved at 16:9 before the file loads, so the popup does not jump. When the surface also carries a videoWidget, the popup grows an Open in panel button that selects the feature and scrolls the big player into view. A media-only layer opens a popup only for the features that actually carry live media — never an empty card.

Legend

legend.show puts the layer in the viewer's legend under legend.title. For a classified layer the legend is generated from the classification: classes for quantize/equal/jenks, a gradient for interpolate, one swatch per value for categorical. On a dashboard the legendWidget can go further: with click to filter on, clicking a class hides that category in every widget, not only on the map.

Filters

Three filters stack, and they are ANDed:

  1. baseFilter on the layer, always on.
  2. the per-beat filter in layerStates, the chapter's own narrowing.
  3. the runtime filter store, which is what a reader does with dashboard filters and selections.

All three are MapLibre expressions:

json
[">=", ["get", "mag"], 5]
["all", [">=", ["get", "time_iso"], "2024-04-01"], ["<=", ["get", "time_iso"], "2024-04-30T23:59:59Z"]]
["==", ["get", "type"], "major"]

Zoom hand-over

Give a heatmap a maxzoom and the circles a minzoom that overlap by a zoom level or two: the density view resolves into individual features as the reader zooms, without a hard cut.

Verified on 2026-09-02 against the local development build (sample projects 01, 02, 04, 06 and 07).

Was this page helpful?

Something wrong or missing? Write to hello@spatly.io.