Layers & Loaders¶
How settings.layers turns into layers on the map. The code is in app/src/core/layers/.
Registration¶
registerLayers(map, layers) is the entry point. MapView calls it once, when the map is ready; a basemap switch doesn't call it again. It:
- Checks ids. Duplicate layer ids throw.
- Checks symbology refs and opacity. A
refwhose name isn't insymbology.jsonthrows here, before anything draws. (A top-levelopacityon a layer is also checked here, but the loader's schema check rejects it as an unknown key first.)MapViewcatches these, logs them withconsole.errorand registers no layers, so the map shows only the basemap. - Keeps runtime state. If the store already has metadata for a layer (when
MapViewregisters again, e.g. under React's StrictMode in development), it uses that layer'svisibleinstead of the settings default. Loaders read the layer-list opacity multiplier the same way, throughstartOpacity(layers/recordLayer.ts): the store's value, else the layer'sconfiguredOpacity(Esri'sesriOpacity, a raster tile layer'srasterOpacity), else 1. - Calls the loader for
layer.type, rasters first.isRasterLayer(Esridynamic/tiled, andraster: truePMTiles and tile servers) sorts them ahead of vector layers, so they end up below every vector layer, since MapLibre puts each new layer on top. - Records the layer with
recordLayer(layers/recordLayer.ts). It stores the layer's hooks (getBoundsinlayerBoundsGetters,getFeaturesinlayerFeatureGetters, thefilterhandler inlayerFilterHandlers), reapplies any saved filter, marks the layer inpendingLayerDatauntil itsreadypromise settles (see Loading overlay), and writes itsLayerMetadata, which the layer list and legend read. A loader can override fields of that record through the entry'srecord(PMTiles uses it for parent rows). Loaders that add layers after registration, like whole-archive PMTiles, callrecordLayerthemselves. - Returns
RegisteredLayer[]. Each entry can have:teardown(), which cleans up long-lived resources.getBounds(), which finds the layer's extent for Zoom to Extent when the user clicks it. Results go throughisValidLngLatBounds(layers/bounds.ts), so bad bounds do nothing instead of throwing.getFeatures(), every feature in the layer, unfiltered, for the layer list's Export Data. The caller applies the active filter, then writes the chosen format (core/download.ts,writeKML.ts,writeGeoParquet.ts,writeCSV.ts). Onlygeojson(through the sharedloadGeoJsonFeaturescache, or DuckDB's result withduckdb) andgeoparquetset it, which is what hides Export Data on other types.filter, the layer's filter mechanism (see Filtering).ready, for loaders that fill their source after registering (PMTiles while it reads an archive's layer list). It settles once the data is on the source and never rejects. Loaders that load whole files (DuckDB, GeoJSON by URL, FlatGeobuf) go throughloadWhenVisibleinstead (see Loading on demand), which markspendingLayerDataitself.record, overrides for the store record (see step 5).
If a loader fails, for example because a local file is missing, the error is caught and the layer is skipped with a warning. One bad layer never blanks the map; it's just missing from the layer list and legend.
The loader dispatch¶
type === 'geojson' → loaders/geojson.ts addGeoJsonLayer
type === 'pmtiles' → loaders/pmtiles.ts addPMTilesLayer
type === 'tiles' → loaders/tiles.ts addTilesLayer
type === 'flatgeobuf' → loaders/flatgeobuf.ts addFlatGeobufLayer
type === 'esri' → loaders/esri.ts addEsriLayer
type === 'geoparquet' → loaders/geoparquet.ts addGeoParquetLayer
geoparquet, and geojson with duckdb, both hand off to loaders/duckdbLayer.ts, or to loaders/duckdbTiles.ts with mvt (see DuckDB layers). pmtiles and tiles both build on the shared vector-tile core (see Vector tiles).
Each loader creates the MapLibre source and the styled sublayers, then returns a RegisteredLayer.
Libraries only some layer types need are imported with import() the first time a layer uses them, so they stay out of the startup bundle: the DuckDB JavaScript client (core/duckdb/engine.ts, on top of the WASM engine it already loaded on demand), esri-gl (loaders/esri.ts) and FlatGeobuf's reader (loaders/flatgeobuf.ts). An Esri raster service can't add its layer until esri-gl arrives, so it first adds a hidden <id>-slot background layer to hold its place in the draw order, and adds the raster before it. Because of these splits, vite.config.ts raises chunkSizeWarningLimit to 1500 kB: the startup chunk is mostly MapLibre, and the limit still catches a new heavy import.
The shared sublayer model¶
One layer in settings.layers becomes up to six MapLibre layers, named with a suffix on the layer id:
| Suffix | Type | Used by |
|---|---|---|
-fill |
fill | polygons |
-line |
line | lines, or polygon outlines on polygon layers |
-point |
circle | points |
-label |
symbol | labels |
-raster |
raster | image tiles: Esri raster services, raster PMTiles, raster tile servers |
-shadow |
line | the Draw widget's drop shadows (so they hide with the Drawings layer) |
Every vector layer goes through one builder, addSublayers in loaders/sublayers.ts: GeoJSON, FlatGeobuf, GeoParquet, Esri feature services with kit symbology, and vector tiles (which pass a sourceLayer). Each geometry sublayer is filtered by geometry type (BASE_FILTERS), so one source can mix points, lines and polygons with no extra config. Popups, the legend, opacity, labels and basemap switching work the same for all of them.
- Which sublayers. A declared
geometryTypebuilds only what that geometry needs. Without one the layer is mixed and gets all three, unless its symbology israw:geometryFromRawPaint(layers/symbology.ts) reads the geometry from the paint's prefixes (circle-,fill-,line-), and a paint that points to no single geometry throws, which skips that layer with a console warning. The legend uses the same function, so the two always agree. - Outlines.
BASE_FILTERS['-line']matches only LineStrings, so mixed layers never trace polygon edges (that's heavy on big polygons). A polygon layer's-linesublayer is its outline instead: filtered withPOLYGON_OUTLINE_FILTERand painted withstrokeColor/strokeWidth/strokeOpacity. The builder records that per-sublayer base filter (recordBaseFilter/baseFilterFor) so the layer-list filter can build on it. - Insertion point.
beforeIds(per fill, line, point and label) inserts the sublayers below given layer ids instead of on top. PMTiles archives use it for their draw-order tiers.
FlatGeobuf and plain GeoParquet are converted to GeoJSON in the browser, so both hit the same memory limit as a .geojson file. Tiled layers (PMTiles, tile servers, raster layers) stream only what's in view, and GeoParquet with mvt keeps its data in DuckDB and serves tiles from there.
Vector tiles¶
loaders/vectorTiles.ts is the shared core for every vector-tile layer: PMTiles archives (loaders/pmtiles.ts), tile servers (loaders/tiles.ts) and DuckDB tiles (loaders/duckdbTiles.ts). Each supplies a provider (its MapLibre source, its metadata: source layers and tippecanoe tilestats, and its bounds) to addVectorTileLayer, which turns one layer into one or more drawn source layers, all from a single MapLibre source (src-<id>). Image tiles go through addRasterTileLayer instead: one -raster sublayer, starting at rasterOpacity, below every vector layer.
The rest of this section describes that core; PMTiles was the first provider, so some names still say so.
- One source layer (a one-entry
sourceLayers) is built as a normal layer under the layer's own id. - Two or more (listed, or every layer in the archive's
vector_layerswhen none are listed) become a parent with children. The parent'sLayerMetadatahasisParentandchildIds(draw order, first on top) and draws nothing itself; each child is<id>/<sourceLayer>withparentId. When nothing is listed, the children only exist once the archive's metadata arrives, so the parent returnsreadyfor the boot overlay and records its children later throughrecordLayer. If the archive turns out to have one source layer, the placeholder parent is re-recorded as a normal layer. - Draw order. The parent adds four invisible
backgroundlayers at its place in the stack (<id>-slot-fill,-slot-line,-slot-point,-slot-label). Children insert each sublayer below its tier's marker (beforeIds), last child first, so every child's fills sit under every child's lines, and so on, and children that arrive late still land in the right place. - Resolving a child. Each field falls back from the
sourceLayersentry to the archive layer's own field, then to defaults: aPALETTEcolor (core/palette.ts, shared with Draw) assigned by index, atopacity: 0.4. The archive's per-source-layerminzoomraises the floor unless the entry sets one. Listed names the archive doesn't have are warned about and their children removed. - Archive metadata.
core/layers/pmtilesProtocol.tsholds the onepmtiles://Protocol(registered inMapView).pmtilesArchive(url)reuses the reader MapLibre already uses, so the header isn't fetched twice, andpmtilesMetadata(url)caches the metadata JSON per URL. The loader reads it in the background for the source-layer check, tippecanoe'stilestatsgeometry (a detected polygon swaps its-linesublayer to the outline filter and paint), and zoom floors. Zoom to Extent reads the header's bounds. - Popups. The loader publishes one popup target per drawn source layer to the store (
popupLayerOverrides); see Popups. - PMTiles specifics.
warnOnTileTypeMismatchreads the archive header and warns whenrasterdoesn't match the tile type, or when an MLT archive (tile type 6) has noencoding: 'mlt', since the pmtiles protocol's TileJSON doesn't carry the encoding. - Tile servers. A TileJSON URL is passed to MapLibre as the source
url; the loader also fetches its own copy (cached per URL, so a basemap switch doesn't refetch it) forvector_layersand bounds. A template becomes atilessource withminTileZoom/maxTileZoom/attributionfrom the layer, and must listsourceLayers.
DuckDB layers¶
GeoParquet layers, and GeoJSON layers with duckdb, are read by DuckDB-WASM:
core/duckdb/engine.ts:getDuckDB()starts one shared instance and caches its promise, so every caller shares a single startup (a failed start is cleared so it can retry). It loads from jsDelivr (getJsDelivrBundles, pinned to the installed@duckdb/duckdb-wasmversion) unlesssettings.duckdb.bundleUrlis set. The worker starts from a same-origin blob URL, because browsers won't start a worker from another origin. Then it loads theparquet,httpfsandspatialextensions, fromsettings.duckdb.extensionsUrlif set. Each step goes to the store'sloadingStatusfor the overlay.core/duckdb/readGeo.ts:describeGeoParquetreads the file'sgeometadata andDESCRIBEoutput to find the geometry column, its encoding and CRS (describeGeoJSONdoes the same for GeoJSON); the plain read and the tile table both start from it.readGeoParquetwraps the geometry inST_Transform(…, 'EPSG:4326')when the CRS isn't WGS84, and selects the attributes plusST_AsGeoJSON.readGeoJSONdoes the same fromdescribeGeoJSON, throughST_Read(GDAL). Results become a FeatureCollection, cached per layer id so a basemap switch, which re-registers every layer, reuses them instead of re-querying. 64-bit integers become plain numbers, or strings when they're too large to be exact, so feature properties, filter values and MVT tiles all hold the same type.loaders/duckdbLayer.ts:addDuckDBLayeradds an empty GeoJSON source and the shared sublayers, waits for the engine, then fills the source withsetData.getBounds,getFeaturesand the filter handler all work off that FeatureCollection. A failed load isn't kept: the next toggle, export, filter or zoom tries again, and a successful retry fills the map too.duckdbTiles.tsdoes the same for its tile table.teardownmarks the entry stale, so a late result doesn't write into a map that has since re-registered the layer.
With mvt, the layer is tiled instead:
core/duckdb/tileTable.ts:prepareTileTablebuilds one in-memory table per layer, once (reused across basemap switches): attributes cast to typesST_AsMVTaccepts, geometry in Web Mercator, and bbox and bbox-area columns so each tile query is a range filter. The table is then stored in Hilbert-curve order (ST_Hilbert), so each row group covers a small area.tileBytescuts one tile: features in its envelope, skipping those under about a pixel, then themaxTileFeatureslargest. The envelope goes into the query as plain numbers, since DuckDB only skips row groups by their min/max on comparisons with constants. If the sort fails (an older spatial extension), the layer still works, just with slower tiles.distinctValuesandallFeatures(back in WGS84) serve the filter panel and Export.core/duckdb/tileProtocol.ts: theduckdb://<layerId>/{z}/{x}/{y}protocol, registered inMapView, answers MapLibre's tile requests from that table.loaders/duckdbTiles.ts:addDuckDBTileLayerdraws the layer through the vector-tile core with one source layer, applies the defaultminZoomof 10, and logs a warning for files over 100 MB.
Every configured layer registers at boot, hidden or not, so DuckDB starts at boot whenever any layer needs it, and never otherwise. With map.onlyVisibleOnLoad, a hidden DuckDB layer's read waits until it's first turned on (loadWhenVisible), so if every DuckDB layer starts hidden, DuckDB isn't downloaded until one is shown.
Loading overlay¶
useDataLoading (core/useDataLoading.ts) gates the boot LoadingOverlay. It tracks every configured layer id in settings.layers (hidden ones too, except those deferred by map.onlyVisibleOnLoad); PMTiles children share their parent's source, so the parent covers them. A layer counts as loaded when map.isSourceLoaded is true and it isn't in pendingLayerData. The second check matters for DuckDB layers: their empty source reports loaded almost immediately, long before the data arrives. Tracking hidden layers matters too: a Safari profile showed a hidden 114k-polygon GeoParquet layer's setData blocking the main thread for about 800 ms after an overlay that only waited for visible layers had already faded. Once everything has loaded, a done latch keeps the overlay closed, so a later basemap switch (which marks DuckDB layers pending again) can't reopen it.
The overlay's detail line (ui.loader.showDetails) shows loadingStatus while DuckDB starts (the engine writes each step and clears it when ready), then the name of the last layer in useDataLoading's waiting list.
Loading on demand¶
layers/lazyLoad.ts is how a loader starts its data load. isDeferred(layer) is true when settings.map.onlyVisibleOnLoad is on, the layer is hidden, and its type loads whole files (geojson, geoparquet, flatgeobuf). loadWhenVisible(layer, visibleNow, start) runs start right away unless the layer is deferred; then it subscribes to the store and runs it the first time layerMetadata[id].visible turns true. Either way it marks the layer in pendingLayerData until start settles, which drives the layer list's spinner and the boot overlay. It returns an unsubscribe for the loader's teardown.
The DuckDB loaders keep their read in a lazy promise (load()), so Export Data, filter values and Zoom to Extent on a layer that hasn't loaded yet start the read themselves. A deferred GeoJSON-by-URL layer adds its source empty and hands MapLibre the URL on first show (fillFromUrl, which settles on the source's sourcedata).
Layer operations: layerOps.ts¶
Visibility and opacity changes loop over the sublayer suffixes:
setLayerVisibilitysetsvisibilityon the geometry sublayers. The-labelsublayer is left alone by default, because the layer list has a separate labels button.includeLabelchanges that; the draw widget uses it for text drawings, where the text is the geometry.setLayerOpacityapplies the layer-list multiplier. The symbology's own opacity is the base: whenaddSublayersandaddLabelLayerbuild a sublayer,withLayerOpacity(layers/opacity.ts) records each opacity property's base value and paints base × multiplier.setLayerOpacityrecomputes from those bases. Numbers are multiplied, expressions are wrapped in['*', base, m], and zoom-based expressions (which MapLibre requires at the top level) are left as written. Sublayers the kit didn't build (Draw's, esri-gl's own styling, rasters) have no recorded base and keep the old behavior: numbers are set directly and expressions skipped.setGeoJsonLayerFilterapplies a user filter to a GeoJSON-backed layer, ANDed with each sublayer's geometry filter (baseFilterFor). The base filter is rebuilt on every call, never read back from the map, so filters can't stack. The-labelsublayer is filtered too.
Missing sublayers are skipped, so each operation works whatever sublayers a layer has.
Filtering¶
A layer's filter state (layerFilters in the store) compiles to three outputs in core/filters/compile.ts, all from the same normalized conditions:
| Output | Used by |
|---|---|
| MapLibre expression | geojson and geoparquet drawing, through setGeoJsonLayerFilter |
| JS predicate | Layer search and filtered Zoom to Extent (currentFilterPredicate) |
ArcGIS where |
Esri feature layers, through esri-gl's setWhere, ANDed with the layer's own where |
Each loader that supports filtering returns a filter handler with apply(state) and getValues(column), which returns a column's distinct values and caches them per layer and column. Changes from the UI go through updateLayerFilter (filters/apply.ts), which writes the store and calls the handler. validateLayerFilters rejects unsupported types at boot.
layers/geojsonFeatures.ts caches each geojson layer's whole file. Search, filter values and Zoom to Extent all read from it instead of each fetching the file.
URL resolution¶
resolveLayerUrl.ts turns a path like "./layers/parks.geojson" into a bundled URL with Vite's import.meta.glob('/layers/**/*', { query: '?url' }). Full URLs pass through unchanged. A mistyped local path throws with a list of the files it does know about. Vite inlines small layer files as data URLs and writes larger ones as separate hashed files.
Surviving a basemap switch¶
A basemap switch doesn't touch data layers. swapBasemap (core/basemapLayers.ts) removes the old basemap's layers and sources and adds the new ones underneath the lowest data layer, so sources keep their data and nothing is re-sent to MapLibre's worker. See the warning in the Internals overview. RegisteredLayer.teardown() runs only when MapView unmounts.