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: basemidtoplabel, 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
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

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
popupTemplate
**{{place}}** · M{{mag}}
Depth {{depth}} km · {{time_iso}}

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.

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.