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:
- Validates ids — duplicate layer ids throw at boot.
- Validates symbology refs — a
refwhose name isn't insymbology.tsthrows eagerly (clear message at boot, not a silent paint failure later). - Preserves runtime state — for each layer, if the store already has metadata (a basemap switch), it reuses that layer's
visible/opacityinstead of the settings defaults, so user toggles survive. - Dispatches to a per-type loader based on
layer.type. - Stores per-layer hooks — each loader's
getBoundsintolayerBoundsGettersand itsfilterhandler intolayerFilterHandlers, then re-applies any stored filter state to the fresh layer. - Writes
LayerMetadatainto the store (what the layer list and legend read). - Returns
RegisteredLayer[]— each may carry:teardown()— cleans up persistent resources.getBounds()— resolves the layer's extent for Zoom to Extent, lazily on click. Results pass throughisValidLngLatBounds(layers/bounds.ts), so bad bounds are a silent no-op instead of a throw.filter— the layer's filter mechanism (see Filtering).
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:
setLayerVisibilitytoggles the geometry sublayers'visibility. The-labelsublayer is handled separately by default (the layer list has an independent labels button), unlessincludeLabelis set — used for the draw widget's text drawings, where the text is the geometry.-
setLayerOpacitysets 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 — arawsymbology's data-driven paint is the user's source of truth and must not be overwritten with a flat number. -
setGeoJsonLayerFilterapplies a user filter to a geojson-source layer, ANDed onto each sublayer's geometry filter (GEOJSON_BASE_FILTERS). The base is recomputed on every call, never read back from the map, so filters can't stack. The-labelsublayer is filtered too.
Missing sublayers are skipped silently, so the same op works regardless of which sublayers a given layer actually has.
Filtering¶
A layer's filter state (layerFilters in the store) compiles to three targets in core/filters/compile.ts, all from the same normalized conditions:
| Target | Used by |
|---|---|
| MapLibre expression | geojson / geoparquet rendering, via setGeoJsonLayerFilter |
| JS predicate | In-layer search and filtered Zoom to Extent (currentFilterPredicate) |
ArcGIS where |
Esri feature layers, via esri-gl's setWhere, ANDed with the layer's own where |
Each filterable loader returns a filter handler — apply(state) and getValues(column) (distinct categorical values, cached per layer + column). UI changes go through updateLayerFilter (filters/apply.ts), which writes the store and calls the handler. Unsupported types are rejected at boot by validateLayerFilters.
layers/geojsonFeatures.ts is the shared whole-file cache for geojson layers — search, filter values, and Zoom to Extent all read it instead of fetching separately.
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.