Guides
Cookbook.
Recipes for the common tasks. Each one is a complete xgis source block you can paste into the playground and tweak — no setup, no glue code.
Extruded 3D buildings
Use the per-feature `fill-extrusion-height-[expr]` utility. The expression runs against each feature's tags at MVT decode time; missing properties fall through `??` to whatever default you pick.
source pm { type: pmtiles url: "/protomaps-v4.pmtiles"}
layer buildings { source: pm sourceLayer: "buildings" | fill-stone-300 stroke-stone-500 stroke-0.5 | fill-extrusion-height-[.render_height ?? .height ?? 5]}- ·Walks render at the layer's `fill-` colour; the stroke utility paints outlines on the roof + ground edges.
- ·Pair with `pitch=70` in the camera URL to actually see the 3D massing.
Categorical fill from a property
Map discrete values to colours with `match()`. The default arm `_ -> …` runs when no key matches.
source land { type: geojson url: "ne_110m_countries.geojson"}
layer continents { source: land | fill match(.continent) { "Asia" -> red-400 "Europe" -> blue-400 "Africa" -> amber-400 "North America" -> emerald-400 "South America" -> violet-400 "Oceania" -> cyan-400 _ -> gray-300 } | stroke-white stroke-1}Zoom-fade road widths
Wrap any utility value in `interpolate(zoom, …)` to make it scale with the camera. Stops blend linearly between the listed zooms; the values clamp outside the range.
layer highways { source: roads sourceLayer: "transportation" filter: .class == "motorway" | stroke-amber-500 | stroke-[interpolate(zoom, 6, 0.5, 12, 2, 18, 6)] | opacity-[interpolate(zoom, 5, 0, 7, 100)]}- ·Same builtin works on feature properties — `interpolate(.population, 0, white, 1e6, red-500)` gives a continuous gradient.
Data-driven stroke width
Pipe (`|`) feeds the LHS as the first argument of an RHS function. Reads left-to-right, no parens, no temporary variables.
layer roads { source: roads sourceLayer: "transportation" | stroke-zinc-700 | stroke-[.lanes / 2 | clamp(0.5, 8)]}- ·Equivalent to `stroke-[clamp(.lanes / 2, 0.5, 8)]` — pick whichever reads better.
Road casing (two stacked strokes)
Two layers on the same source, the casing wider and darker. Order matters — the casing must declare first so the inner stroke draws on top.
layer road_casing { source: roads sourceLayer: "transportation" filter: .class == "motorway" | stroke-amber-700 stroke-[interpolate(zoom, 6, 1, 18, 9)]}
layer road_inner { source: roads sourceLayer: "transportation" filter: .class == "motorway" | stroke-amber-300 stroke-[interpolate(zoom, 6, 0.5, 18, 6)]}Reusable utility stacks (preset)
A `preset` block names a utility stack you can splat into any layer with `apply-<name>`. Layer-level utilities placed after the apply override the preset values.
preset road_base { | stroke-zinc-600 | stroke-linecap-round stroke-linejoin-round}
layer highway { source: roads sourceLayer: "transportation" filter: .class == "motorway" | apply-road_base | stroke-amber-500 // overrides the preset | stroke-[interpolate(zoom, 6, 1, 18, 6)]}Pulsing opacity animation
A `keyframes` block + `animation-<name>` utility on the layer. The runtime samples the keyframe timeline each frame; multiple paint properties (color, opacity, dashOffset) can animate in parallel.
keyframes pulse { 0%: opacity-100 50%: opacity-30 100%: opacity-100}
layer beacon { source: pings | shape-circle size-12 fill-rose-500 | animation-pulse animation-duration-1500 | animation-ease-in-out animation-infinite}Hide a layer below a zoom threshold
Use `minzoom:` / `maxzoom:` block-properties on the layer. The runtime skips the entire layer's render path below / above the bounds — no per-feature opacity gymnastics.
layer rail { source: roads sourceLayer: "transportation" filter: .class == "rail" minzoom: 11 | stroke-slate-500 stroke-1 | stroke-dasharray-2-3}- ·For a soft fade instead of a hard cut, pair with `opacity-[interpolate(zoom, 10, 0, 12, 100)]` and let both work together.
Restrict PMTiles decoding to specific source-layers
List the source-layer names the source should bother decoding — the worker skips everything else and per-tile work drops in proportion. Same names you reference via `sourceLayer:` on a layer.
source pm { type: pmtiles url: "/protomaps-v4.pmtiles" layers: ["water", "landuse", "transportation"]}- ·Default behaviour is "decode every source-layer any xgis layer references" — explicit listing only matters when you want to also restrict layers below the inferred set.
Per-feature text labels
A `label-[<expr>]` utility on any layer turns its features into SDF text labels. The bracket holds an xgis expression — a bare field reference (`.name`) is the simplest form; quoted templates support Mapbox `{name}` tokens AND Python-style format specs `{lat:.4f}` plus GIS specs `{coord:dms}`.
source cities { type: geojson, url: "cities.geojson" }
layer city_labels { source: cities | label-["{.name}"] label-size-14 label-color-#fff label-halo-1 label-halo-color-#000 label-uppercase}- ·Text colour falls back to the layer's `fill-` colour when `label-color-*` is unset — handy for matching point + label tints with one declaration.
- ·Labels render via the OffscreenCanvas worker → R8 atlas → WGSL pipeline. The first frame seeds basic Latin + degree sign so cursor / coord readouts hit the cache cold.
- ·Non-string format specs: `{lat:dms}` → 37°33′59.4″N, `{brg:bearing}` → 045°, `{ts:%H:%M:%SZ}` → 14:32:18Z. Locale `;C` forces deterministic ASCII for audit / regression testing.
Imperative text overlays (addOverlay)
`map.addOverlay({...})` anchors a label at a (lon, lat) pair imperatively — no DSL needed. Useful for measurement results, programmatic annotations, or anything driven by app state outside the style.
import { XGISMap } from '@xgis/runtime'
const map = new XGISMap(document.getElementById('map'))await map.run(stylesource)
const cities = [ { name: 'Seoul', anchor: [126.978, 37.5665] }, { name: 'Tokyo', anchor: [139.6917, 35.6895] }, { name: 'Sydney', anchor: [151.2093, -33.8688] },]
const handles = cities.map(c => map.addOverlay({ text: c.name, anchor: c.anchor, size: 16, color: [1, 1, 1, 1], halo: { color: [0, 0, 0, 0.85], width: 1.5 },}))
// Later: remove just onehandles[0].remove()// Or allmap.clearOverlays()- ·Same pipeline (atlas + shader) as DSL-driven labels — both paths share one TextStage instance per map.
- ·Glyphs are LRU-cached in the atlas keyed by (font, codepoint, sdf-radius), so repeated chars across overlays only rasterise once.
- ·Each overlay re-projects every frame against the current camera — labels stay locked to their geographic anchor through pan / zoom / pitch.
Discrete thresholds with `step`
`step(input, default, stop1, val1, stop2, val2, …)` is the discrete cousin of `interpolate`. Below `stop1` you get `default`; from each `stop_i` upward the value snaps to `val_i`. Useful for road class width tiers, opacity-on-zoom hard cuts, score-to-color buckets — anywhere a smooth gradient would be wrong because the value categories are inherently distinct.
source roads { type: pmtiles, url: "tiles.pmtiles" }
layer road_lines { source: roads sourceLayer: "transportation" | stroke-stone-300 // Width tiers by class: motorway 4 → trunk 3 → primary 2 → rest 1 | stroke-[step(.class_rank, 1, 1, 2, 2, 3, 3, 4, 4)] // Hard cut: only paint roads from z=8 upward | opacity-[step(zoom, 0, 8, 1)]}- ·Mapbox `["step", ...]` round-trips 1:1 — converted styles use the same shape.
- ·For just one threshold the legacy 4-arg form `step(value, threshold, below, above)` still works (back-compat).
- ·Strings (e.g. color hex) are valid val slots — `step(zoom, "#fff", 10, "#ccc", 14, "#888")` switches color tiers without blending.
Composite labels with `concat`
`concat(a, b, c, …)` glues any number of arguments after coercing each to its string form (numbers, booleans, even nulls — nulls drop silently). Inside `label-[<expr>]` you get the same behavior as the inline `"{template}"` form, but composable — round numbers first, format units, fall through alternatives.
source places { type: geojson, url: "cities.geojson" }
layer city_labels { source: places // City + country in parens | label-[concat(.name, " (", .country_code, ")")] label-color-#fff label-halo-1 label-halo-color-#000}
layer peak_labels { source: peaks // Peak name + elevation rounded to whole metres | label-[concat(.name, " ", round(.elevation_m), " m")] label-size-12 label-color-#444}- ·Pair with `??` for safe fallbacks: `concat(.name, " — ", .role ?? "(unknown role)")`.
- ·`downcase` / `upcase` work on the result: `upcase(concat(.iso_a2, "-", .iso_a3))`.
- ·For coord readouts the Python-style format spec is usually cleaner: `label-["Lat: {.lat:.4f}"]`. Use `concat` when the pieces are themselves expressions (computed values, fallbacks, conditional branches).
Along-path road labels (`label-along-path`)
`label-along-path` (Mapbox `symbol-placement: line`) walks the line geometry instead of anchoring at a point — each feature gets one label per first segment, rotated to match the local tangent. Roads read along their direction, river names follow the bend, highway names align with their corridor. Pair with `label-line-center` when you want exactly one label at the midpoint of every line.
source roads { type: pmtiles, url: "tiles.pmtiles" }
layer road_lines { source: roads sourceLayer: "transportation" | stroke-stone-300 stroke-1}
layer road_names { source: roads sourceLayer: "transportation" | label-[.name] label-color-#fff label-halo-1 label-halo-color-#000 label-along-path}- ·v1 places one rigid label per feature at the FIRST segment's midpoint with that segment's tangent — fine for typical 1-3 segment road tiles. Per-glyph along-curve trajectory + repeat-along-line spacing for long roads land in v2.
- ·A keep-upright "lite" pass auto-flips text-bottom-up segments by 180° so left-to-right readers always see right-side-up labels. Full per-glyph flip on curves is the v2 follow-up.
- ·Rotation is computed in screen space (atan2 on the projected endpoints) so it stays correct under pitch / bearing — labels visually "stick" to the visible line direction even when the camera tilts.
One-line import: any Mapbox v8 style
Splice form `import "url"` (no name list) pulls every top-level statement from the URL into your program. The runtime auto-detects Mapbox style.json (looks for `version` + `layers`) and runs the converter before parsing — so a single line drops a full base map (OpenFreeMap, OSM Bright, your own custom Mapbox style) into the canvas, and your own layers below override or sit on top.
import "https://tiles.openfreemap.org/styles/bright"
source places { type: geojson, url: "places.geojson" }
layer my_pois { source: places | fill-rose-500 fill-opacity-80 | label-[.name] label-color-#fff label-halo-1 label-halo-color-#000}- ·Auto-detection is a one-line check (must be JSON, must have `version >= 7` and a `layers` array). xgis content goes through unchanged — same syntax works for shared `.xgis` modules.
- ·The fetch happens once per URL per page load (module-level cache). Reload-with-edit only re-fetches if the URL itself changes.
- ·Cherry-pick form (`import { preset_name } from "./styles.xgis"`) still works for project-internal modules — useful when you only want one preset, not a whole base map.
- ·CORS still applies — the style host must serve `Access-Control-Allow-Origin`. OpenFreeMap and pmtiles.io do; private CDNs may not.
Multiline labels + font-stack fallback
`label-max-width-<em>` triggers greedy word-break wrapping; `label-line-height-<N>` and `label-justify-<a>` control spacing and alignment within the wrapped block. Multiple `label-font-<name>` utilities stack into a CSS font-fallback chain — the browser walks it glyph-by-glyph, so a Latin font + CJK font pair covers mixed-script labels without per-glyph resolution.
source cities { type: geojson, url: "cities.geojson" }
layer city_labels { source: cities | label-[.name] label-size-15 label-color-#fff label-halo-1 label-halo-color-#000 label-max-width-7 label-line-height-1.1 label-justify-center label-font-Inter-Regular label-font-Noto-Sans-CJK-JP-Regular}- ·max-width is in em units (Mapbox convention) — the browser-measured glyph advances are summed and the next word breaks to a new line when the line exceeds the budget.
- ·Justify: `left` / `center` / `right` align lines within the block; `auto` mirrors the block to the anchor side (e.g. right-aligned text for a left-anchored label).
- ·The font-stack maps Mapbox `text-font: ["Inter Regular", "Noto Sans CJK JP Regular"]` 1:1 — converted styles inherit the same glyph fallback the browser applies to CSS.