Skip to content

Layers & Loaders

How settings.layers becomes rendered map layers. Lives in app/src/core/layers/.

Registration

registerLayers(map, layers) is the entry point, called by MapView once the style loads (and again on every style.load). It:

  1. Validates ids — duplicate layer ids throw at boot.
  2. Validates symbology refs — a ref whose name isn't in symbology.ts throws eagerly (clear message at boot, not a silent paint failure later).
  3. Preserves runtime state — for each layer, if the store already has metadata (a basemap switch), it reuses that layer's visible/opacity instead of the settings defaults, so user toggles survive.
  4. Dispatches to a per-type loader based on layer.type.
  5. Writes LayerMetadata into the store (what the layer list and legend read).
  6. Returns RegisteredLayer[] — each may carry a teardown() for persistent resources.

A failing loader (e.g. a missing local file) is caught and skipped with a warning — one bad layer never blanks the whole map; it's just left out of the layer list/legend.

The loader dispatch

type === 'geojson'    → loaders/geojson.ts     addGeoJsonLayer
type === 'pmtiles'    → loaders/pmtiles.ts      addPMTilesLayer
type === 'flatgeobuf' → loaders/flatgeobuf.ts   addFlatGeobufLayer
type === 'esri'       → loaders/esri.ts         addEsriLayer
type === 'geoparquet' → loaders/geoparquet.ts   addGeoParquetLayer

Each loader creates the MapLibre source and the styled sublayers, then returns a RegisteredLayer.

The shared sublayer model

A single logical layer emits up to several MapLibre sublayers, named by suffix off the layer id:

Suffix Type Used by
-fill fill polygons
-line line lines + polygon outlines
-point circle points
-label symbol labels (Phase 15)
-raster raster server-rendered Esri raster services

For GeoJSON, the loader adds all three geometry sublayers, each filtered by geometry type (['==', ['geometry-type'], 'Point' | 'LineString' | 'Polygon']). That's why one GeoJSON file can mix points, lines, and polygons without extra config — the polygon outline reuses the -line layer (its filter matches both LineStrings and Polygons).

Vector formats that decode to GeoJSON in the browser — FlatGeobuf and GeoParquet — reuse the same shared sublayer builder (loaders/geojsonSublayers.ts). So popups, legend, opacity, labels, and basemap-switch survival all work for those formats with zero extra wiring. (There's no streaming-vector path in browser JS for these two: they ultimately hit the same in-memory GeoJSON ceiling as a .geojson file. Only PMTiles/MVT and raster truly stream large data.)

Layer operations — layerOps.ts

Runtime visibility and opacity walk the sublayer suffixes:

  • setLayerVisibility toggles the geometry sublayers' visibility. The -label sublayer is handled separately by default (the layer list has an independent labels button), unless includeLabel is set — used for the draw widget's text drawings, where the text is the geometry.
  • setLayerOpacity sets the right opacity paint prop per sublayer type (fill-opacity, line-opacity, circle-opacity + circle-stroke-opacity, text-opacity, raster-opacity). It skips any property whose value is an expression — a raw symbology's data-driven paint is the user's source of truth and must not be overwritten with a flat number.

Missing sublayers are skipped silently, so the same op works regardless of which sublayers a given layer actually has.

URL resolution

resolveLayerUrl.ts turns a user path like './layers/parks.geojson' into a bundled URL via Vite's import.meta.glob('/layers/**/*', { query: '?url' }). Absolute URLs pass through unchanged. A mistyped local path throws with a helpful message listing the files it does know about. Vite inlines small layer files as data URLs and emits larger ones as separate hashed assets.

Basemap-switch survival

map.setStyle() (the basemap widget) wipes all sources and layers. MapView listens on style.load and re-runs registerLayers + popup registration, calling each RegisteredLayer.teardown() first so persistent per-layer listeners don't stack. Because registration reads visible/opacity from the store's metadata when present, the user's toggles carry across the switch.