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.
| Field | Meaning |
|---|---|
datasetId | the data. Omitted for a remote raster/XYZ layer, which carries remoteSource instead |
renderType | fill · line · circle · symbol · heatmap · fill-extrusion · raster · hillshade |
style | colour, opacity, stroke, size, dash, mark, labels, heat radius, height, classification, raw escape hatch |
baseFilter | a MapLibre expression that applies on every beat |
minzoom / maxzoom | hand-over between layers (a heatmap to zoom 9, circles from zoom 5) |
slot · zIndex | draw order: base → mid → top → label, then zIndex inside the slot |
dataMode | frozen (the snapshot taken at publish) or live (the viewer polls the source) |
timeField | makes the layer time-enabled |
defaultVisible | what happens on a beat that does not mention this layer |
legend | show it, and under what title |

- Add layer from dataset: every dataset attached to the project, plus remote raster and tile sources.
- Layer stack: drag to reorder; the row shows opacity and the feature count.
- Visibility and opacity: the eye and the slider are per layer here, and can be overridden per beat.
- Legend preview: what readers will see, generated from the classification.
- Render: changing this recompiles the MapLibre layer in place; nothing is lost.
- Data:
frozenorlivefor this layer at publish time. - Paint: fill, opacity and stroke; the swatch row is the workspace palette.
- Classify by field: switch it on to drive paint from data.
- Method: categorical, quantize, equal, jenks, interpolate.
- Drives: which property the classification controls: colour, size, opacity or height.
- Ramp: nine built-in ramps; brand first, diverging last.
- Breaks: the class edges. The method proposes them from the field statistics; you can edit any of them.
Render types
| Type | Geometry | Style keys that matter |
|---|---|---|
circle | points | size, color, strokeColor, strokeWidth, blur |
fill | polygons | color, opacity, strokeColor, strokeWidth |
line | lines (and polygon outlines) | size (width), dash, color |
symbol | points | markId (a Spatly mark loaded as an SDF image), iconSize, textField, textSize, textColor, textHalo |
heatmap | points | heatRadius, heatWeightField, maxzoom |
fill-extrusion | polygons | heightField, heightScale, color; needs a pitched camera to read |
raster | remote XYZ / WMS / WMTS | remoteSource.tiles, tileSize, attribution, opacity |
hillshade | terrain-RGB tiles | remoteSource, opacity |
Anything the compiler does not express is available through style.raw, a MapLibre paint / layout object merged last, so it always wins:
{ "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.
| Method | For | What it produces |
|---|---|---|
categorical | text fields | one colour per value from the ramp; unmatched values take nullColor |
quantize | numbers | n equal-count classes, a step expression on the map |
equal | numbers | n equal-interval classes between min and max |
jenks | numbers | natural-breaks classes; best when the distribution is lumpy |
interpolate | numbers | a continuous ramp, an interpolate expression, no classes |
target decides what the classification drives: color (default), size (with sizeRange: [min, max]), opacity or height (extrusions).
{
"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:
{ "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"],tileSize256 or 512, plus theattributionstring the provider requires. - Vector tiles: a TileJSON or XYZ address plus a
sourceLayername; 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:
| Key | Effect |
|---|---|
hover | a tooltip built from tooltipFields |
click | a popup anchored to the feature |
titleField | the popup heading |
tooltipFields | the rows in tooltip and popup |
popupTemplate | a {{field}} template that replaces the default rows |
**{{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:
baseFilteron the layer, always on.- the per-beat
filterinlayerStates, the chapter's own narrowing. - the runtime filter store, which is what a reader does with dashboard filters and selections.
All three are MapLibre expressions:
[">=", ["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).
Something wrong or missing? Write to hello@spatly.io.