diff --git a/CLAUDE.md b/CLAUDE.md index 89df5e4d2..a8f134c80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,7 @@ The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); conf - `GLOBE_CONTROL_TOGGLE_SELECTOR` (`packages/map/src/globe-control-toggle.ts`) mirrors the class names MapLibre's own `GlobeControl` puts on its toggle button — `maplibregl-ctrl-globe` and `maplibregl-ctrl-globe-enabled`, swapped on every projection change. `MapCanvas` persists a projection change from a **click** on that button rather than from the `projectiontransition` event, because style initialization and project reconciliation emit that event too and a stale one overwrites the projection of a project that has just loaded. The classes are internal and unexported, so whenever `maplibre-gl` is bumped (including Dependabot PRs) run the frontend suite — `tests/globe-control-toggle.test.ts` builds a real `GlobeControl` and fails if the mirror stops matching. Without that check a renamed class silently stops persisting the user's projection, with no build error. - `BASEMAP_PANEL_SELECTOR` / `BASEMAP_ROW_SELECTOR` / `BASEMAP_ROW_ID_ATTR` (`packages/plugins/src/plugins/basemap-thumbnails.ts`) mirror the DOM `maplibre-gl-basemap-control` renders — `.basemap-control-panel`, `.basemap-control-result`, `data-basemap-id` — which the Basemaps panel's thumbnails hook into to find rows and join each one back to its catalog entry. That package exports only `BasemapControl`/`BasemapDefinition`, so a renamed class fails nothing at build time: the queries stop matching and thumbnails silently stop appearing. Whenever `maplibre-gl-basemap-control` is bumped in `packages/plugins/package.json` — including Dependabot PRs — run the frontend suite; `tests/basemap-thumbnails.test.ts` builds a real control and asserts its rendered panel against the mirror. The same file's `hasUnresolvedPlaceholder` deliberately matches the **complement** of the tile tokens it substitutes rather than mirroring that package's credential placeholders (`{api-key}`, `{aws-region}`), so a new provider's placeholder is skipped instead of being fetched literally — keep it that way rather than enumerating placeholder names. - **Per-layer blend modes** (`packages/map/src/layer-blend-modes.ts`) wrap three *unexported* `maplibre-gl` internals, because MapLibre renders every layer into one canvas and ships no per-layer blend API (upstream draft: maplibre/maplibre-gl-js#8073). The wrappers are `Painter.prototype.renderLayer` (brackets one layer's draws), `Painter.prototype.useProgram` (tells the layer-opacity composite draw from the draws feeding it), and `Context.prototype.setColorMode` (the single place every draw resolves GL blend state). Fill and line layers additionally get `fill-layer-opacity` / `line-layer-opacity` pinned just under 1 by `style-mapper`, which elects MapLibre 6's render-to-texture composite so a layer blends **as a whole** rather than once per overlapping polygon. `installLayerBlendModes` feature-detects every seam and disables the feature (hiding the Style-panel control) rather than breaking the map, so drift fails *quietly* — which is why `tests/layer-blend-modes.test.ts` asserts the seams and `e2e/blend-modes.spec.ts` asserts real pixels. Run both whenever `maplibre-gl` is bumped, including Dependabot PRs. **Do not add a blend mode without checking it in the browser**: MapLibre's blend state covers the alpha channel too, and it composites a blended layer as one viewport-filling quad, so any mode that does not reduce to "leave the destination alone" at zero source alpha repaints the whole map. That is what disqualified `darken` (a `MIN` equation erased the entire basemap to transparent black) and `subtract` (a reverse subtract left the canvas at `dstA - srcA`, showing the page through the layer); the shipped list is `BLEND_MODES` in `@geolibre/core`, and both the unit test's blend simulator and the e2e spec pin their exclusion. Only `fill` and `line` have a `*-layer-opacity` in the style spec, so only they blend as a **whole layer**; `circle` and `fill-extrusion` blend per symbol and visibly double-darken where symbols overlap on screen (measured under Multiply: `rgb(23, 77, 220)` in the overlap vs `rgb(76, 136, 222)` on a single symbol). That is upstream's limitation, documented in `docs/user-guide/layers.md`; the test "has a layer-level composite for fill and line only" fails if a bump adds one of the missing properties, at which point extend `COMPOSITE_LAYER_TYPES` and `style-mapper` together and drop the caveat. The Style-panel control (`blendModeControl` in `StylePanel.tsx`, rendered in each of its terminal branches) is gated on `!pluginOwnsPaint && !controlRendersLayer`: blending only reaches layers **GeoLibre itself paints**, so anything a control renders or paints (3D Tiles, Gaussian splats, LiDAR, the COG raster control, and Add Vector Layer, which sets `customLayerType` *and* `controlOwnsPaint`) is excluded -- layer-sync never applies `fillPaint`/`linePaint` to those, so the `*-layer-opacity` that elects the composite never lands and a Blend menu there would silently do nothing. Keep `docs/user-guide/layers.md` and `tests/layer-blend-modes.test.ts` ("the layer kinds the Blend control is offered for") in step with that gate; build the test's mocks the way the real controls build their metadata, or they pass on shapes that never occur. +- **The PMTiles control's layer ids** (`pmtilesControlLayerId` / `pmtilesIdsForSourceLayers` / `pmtilesIdNamesSourceLayer`, `packages/map/src/pmtiles-layer.ts`, read from `layer-sync.ts` and `packages/plugins/src/plugins/maplibre-components.ts`) mirror an unexported fact about `maplibre-gl-components`' `PMTilesLayerControl`: it names its MapLibre layers `${sourceId}-${name}-${kind}` from the **raw** source-layer name, where `pmtilesVectorLayerId` percent-encodes it. The two agree for every name needing no encoding, so a store layer carrying the control's own ids — the archive kept whole, or a split part, which keeps the ids naming its own source layer — is recognised under the encoded scheme alone until a name holds a `/`, a space or non-ASCII. Then `layer-sync` decides the source layer has no native layer and adds a **second** fill/line/circle trio on top of the control's: drawn twice, and only the control's copy answers the panel. Both schemes are therefore matched, and only ids naming a source layer the store actually holds are kept. What the user **ticked** is deliberately _not_ inferred from those ids: `selectedSourceLayers` is a documented field of the exported `PMTilesLayerControlState` handed to every handler, so `pmtilesLayerOptions` reads it and the compiler checks it — the rules for a stale selection, and for the archive ids the control reuses across a panel close, are written at that function and at `addPMTilesArchive`. A reused id is the one case GeoLibre cannot repair: two archives then name one MapLibre source, the first to sync wins it and the other draws nothing, so `addPMTilesArchive` warns rather than pretending otherwise — while an archive that takes a layer over outright is drawn correctly, keeping the name, folder and styling of the one it replaced. Whenever `maplibre-gl-components` is bumped in `packages/plugins/package.json` — including Dependabot PRs — run the frontend suite: `tests/pmtiles-control-contract.test.ts` drives a **real** `PMTilesLayerControl` against a real archive, through the real `layeradd` handler into the store, and fails if the id scheme moves or the selection stops reaching the handler. - `GeoLibreCogRenderEngine` (`packages/plugins/src/types.ts`) mirrors the `RenderEngine` union `maplibre-gl-raster` exports (`maplibre-gl-raster` | `cog-tiler-wasm` | `titiler`). It is hand-written rather than imported because `types.ts` is the public plugin-API surface and importing there would make that package's types a hard dependency of every external plugin. Unlike the mirrors above this one is checked by the **compiler**, not a test: `CogRenderEngineMirrorIsExact` in `packages/plugins/src/plugins/maplibre-raster.ts` asserts both directions of assignability against the real imported type, so a renamed or dropped engine identifier fails `npm run typecheck`. Nothing extra to do on a `maplibre-gl-raster` bump beyond letting the build run; without it a stale identifier would reach `control.setEngine()` as a string the control no longer recognizes, silently leaving the raster unrendered. - `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the "enforces an expected result type" test in `tests/expressions.test.ts` fails if the shape stops being honored. - `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter _name_, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error. diff --git a/apps/geolibre-desktop/src/components/layout/BasemapExtractPanel.tsx b/apps/geolibre-desktop/src/components/layout/BasemapExtractPanel.tsx index c47f268b9..47c6e4d4c 100644 --- a/apps/geolibre-desktop/src/components/layout/BasemapExtractPanel.tsx +++ b/apps/geolibre-desktop/src/components/layout/BasemapExtractPanel.tsx @@ -703,6 +703,9 @@ export function BasemapExtractPanel({ open, onClose, mapControllerRef }: Basemap setBasemapStyleUrl(registerOfflineBasemapStyle(layerId, style)); trackStyledBasemap(layerId, `${layerId}.pmtiles`); } else { + // One layer, deliberately: an extract is a backdrop to draw over, not the thing being + // inspected, so it stays a single row rather than being split per source layer the way an + // archive added from the PMTiles control or a STAC asset is. addLayer( createPMTilesStoreLayer({ id: layerId, diff --git a/packages/map/src/headless.ts b/packages/map/src/headless.ts index 571e13cb0..dc1526c65 100644 --- a/packages/map/src/headless.ts +++ b/packages/map/src/headless.ts @@ -9,7 +9,7 @@ */ import type { GeoLibreLayer } from "@geolibre/core"; import type * as maplibregl from "maplibre-gl"; -import { removeLayerFromMap, syncLayer } from "./layer-sync"; +import { externalSourceIdsFor, removeLayerFromMap, syncLayer } from "./layer-sync"; import { installMapTransformCompat as _installMapTransformCompat } from "./map-transform-compat"; export { installMapTransformCompat } from "./map-transform-compat"; @@ -36,8 +36,13 @@ export function createLayerSync(map: maplibregl.Map): LayerSync { sync(layers) { const nextIds = new Set(layers.map((layer) => layer.id)); const previousById = new Map(synced.map((layer) => [layer.id, layer])); + // Every layer in `layers` is on the map when this sync ends — including the ones the reorder + // loop below takes off and puts straight back — so a source any of them draws from stays. + const survivingSourceIds = externalSourceIdsFor(layers); for (const previous of synced) { - if (!nextIds.has(previous.id)) removeLayerFromMap(map, previous.id, previous); + if (!nextIds.has(previous.id)) { + removeLayerFromMap(map, previous.id, previous, survivingSourceIds); + } } // Input order is bottom-to-top: each addLayer without an anchor lands on @@ -60,7 +65,7 @@ export function createLayerSync(map: maplibregl.Map): LayerSync { } for (let index = rebuildFrom; index < layers.length; index += 1) { const previous = previousById.get(layers[index].id); - if (previous) removeLayerFromMap(map, layers[index].id, previous); + if (previous) removeLayerFromMap(map, layers[index].id, previous, survivingSourceIds); } for (const layer of layers) syncLayer(map, layer); diff --git a/packages/map/src/layer-sync.ts b/packages/map/src/layer-sync.ts index fbe20650a..6f3f3d6f5 100644 --- a/packages/map/src/layer-sync.ts +++ b/packages/map/src/layer-sync.ts @@ -1,20 +1,27 @@ import { controlRendersLayer, DEFAULT_LAYER_STYLE, - type GeoLibreLayer, - type ExternalNativePaintBridge, generatorCircleRadiusValue, geojsonHasZCoordinates, getExternalNativePaintBridge, - type LayerStyle, pluginOwnsPaint, proportionalRadiusExpression, ruleBasedVisibilityFilter, shouldUseTiledRendering, styleValue, + type ExternalNativePaintBridge, + type GeoLibreLayer, + type LayerStyle, validateMapExpression, } from "@geolibre/core"; -import { normalizePMTilesUrl, PMTILES_PROTOCOL, pmtilesVectorLayerId } from "./pmtiles-layer"; +import { + normalizePMTilesUrl, + PMTILES_PROTOCOL, + pmtilesControlLayerId, + pmtilesIdNamesSourceLayer, + pmtilesLayerKinds, + pmtilesVectorLayerId, +} from "./pmtiles-layer"; import { encodeVectorTileLayerPart } from "./vector-tile-layer-ids"; import { addProtocol, config } from "maplibre-gl"; import type { GeoJSON } from "geojson"; @@ -912,18 +919,9 @@ function ensurePMTilesExternalLayer( } for (const sourceLayer of sourceLayers) { - const fillId = getPMTilesNativeLayerId( - nativeLayerIds, - pmtilesVectorLayerId(sourceId, sourceLayer, "fill"), - ); - const lineId = getPMTilesNativeLayerId( - nativeLayerIds, - pmtilesVectorLayerId(sourceId, sourceLayer, "line"), - ); - const circleId = getPMTilesNativeLayerId( - nativeLayerIds, - pmtilesVectorLayerId(sourceId, sourceLayer, "circle"), - ); + const fillId = getPMTilesNativeLayerId(nativeLayerIds, sourceId, sourceLayer, "fill"); + const lineId = getPMTilesNativeLayerId(nativeLayerIds, sourceId, sourceLayer, "line"); + const circleId = getPMTilesNativeLayerId(nativeLayerIds, sourceId, sourceLayer, "circle"); ensureLayer( map, @@ -1116,22 +1114,12 @@ function getPMTilesRenderableSourceLayers( ): string[] { const sourceLayers = getPMTilesSourceLayers(layer); const savedSourceLayers = sourceLayers.filter((sourceLayer) => - hasPMTilesNativeSourceLayer(nativeLayerIds, sourceId, sourceLayer), + pmtilesIdNamesSourceLayer(nativeLayerIds, sourceId, sourceLayer), ); return savedSourceLayers.length > 0 ? savedSourceLayers : sourceLayers; } -function hasPMTilesNativeSourceLayer( - nativeLayerIds: string[], - sourceId: string, - sourceLayer: string, -): boolean { - return ["fill", "line", "circle"].some((kind) => - nativeLayerIds.includes(pmtilesVectorLayerId(sourceId, sourceLayer, kind)), - ); -} - function getPMTilesSourceLayers(layer: GeoLibreLayer): string[] { const sourceLayers = layer.source.sourceLayers ?? layer.metadata.sourceLayers; return Array.isArray(sourceLayers) @@ -1142,8 +1130,20 @@ function getPMTilesSourceLayers(layer: GeoLibreLayer): string[] { : []; } -function getPMTilesNativeLayerId(nativeLayerIds: string[], fallbackId: string): string { - return nativeLayerIds.find((nativeLayerId) => nativeLayerId === fallbackId) ?? fallbackId; +/** + * The id this source layer is already drawn under, or the one to draw it under. Both schemes are + * consulted — see `pmtilesControlLayerId` — or a control-added layer gets a second set over it. + */ +function getPMTilesNativeLayerId( + nativeLayerIds: string[], + sourceId: string, + sourceLayer: string, + kind: (typeof pmtilesLayerKinds)[number], +): string { + const encoded = pmtilesVectorLayerId(sourceId, sourceLayer, kind); + if (nativeLayerIds.includes(encoded)) return encoded; + const raw = pmtilesControlLayerId(sourceId, sourceLayer, kind); + return nativeLayerIds.includes(raw) ? raw : encoded; } function isWaybackExternalRasterLayer(layer: GeoLibreLayer): boolean { @@ -3629,10 +3629,16 @@ function moveLayer(map: maplibregl.Map, id: string, beforeId?: string): void { } } +/** The external sources a set of layers draws from — what a removal must not pull out from under. */ +export function externalSourceIdsFor(layers: readonly GeoLibreLayer[]): Set { + return new Set(layers.flatMap((layer) => getExternalSourceIds(layer))); +} + export function removeLayerFromMap( map: maplibregl.Map, layerId: string, layer?: GeoLibreLayer, + survivingSourceIds?: ReadonlySet, ): void { // Drop cached paint-bridge state so a later layer reusing this id never // skips a fresh opacity/visibility apply against a new bridge. @@ -3667,6 +3673,25 @@ export function removeLayerFromMap( ]) { if (map.getLayer(id)) map.removeLayer(id); } + // An archive's source layers share one source, so it goes only once nothing draws from it. The + // store half covers a layer that survives this sync; the map half covers its siblings inside one + // — deleting a folder removes its children in a single pass, and MapLibre reports removing a + // source still under a style layer as an error the user can do nothing about. + const stillInUse = survivingSourceIds ?? new Set(); + // Only an external source can be shared — the derived ids below are this layer's alone — so the + // map is asked at most once, and only when a shareable source is actually up for removal. Walked + // layer by layer rather than read from `getStyle()`, which serializes the whole document. + const shareable = new Set(getExternalSourceIds(layer)); + let drawnSources: Set | undefined; + const stillDrawn = (src: string): boolean => { + drawnSources ??= new Set( + map + .getLayersOrder() + .map((styleLayerId) => map.getLayer(styleLayerId)?.source) + .filter((source): source is string => typeof source === "string"), + ); + return drawnSources.has(src); + }; for (const src of [ ...getExternalSourceIds(layer), sourceId(layerId), @@ -3674,7 +3699,9 @@ export function removeLayerFromMap( invertedSourceId(layerId), generatorSourceId(layerId), ]) { - if (src && map.getSource(src)) map.removeSource(src); + if (!src || stillInUse.has(src) || !map.getSource(src)) continue; + if (shareable.has(src) && stillDrawn(src)) continue; + map.removeSource(src); } // Drop radius-override tracking for the removed layer's native ids so a // later layer reusing an id never inherits a stale restore. @@ -3688,9 +3715,16 @@ export function removeLayerFromMap( unregisterGeoJsonVtSource(layerId); // Free an in-memory PMTiles archive (an offline basemap extract) this layer // referenced; a no-op for remote pmtiles:// URLs. + // + // Refcounted the way the shared source above is: a split archive is several layers reading one + // set of bytes, so freeing them when the first child goes would leave its siblings resolving + // tiles against a protocol entry that no longer exists. if (layer?.type === "pmtiles") { const url = stringSource(layer.source.url) ?? layer.sourcePath; - if (typeof url === "string") unregisterPMTilesArchive(url); + const heldByASibling = getExternalSourceIds(layer).some( + (src) => stillInUse.has(src) || stillDrawn(src), + ); + if (typeof url === "string" && !heldByASibling) unregisterPMTilesArchive(url); } } diff --git a/packages/map/src/map-controller.ts b/packages/map/src/map-controller.ts index 9882ed98f..a2098001f 100644 --- a/packages/map/src/map-controller.ts +++ b/packages/map/src/map-controller.ts @@ -45,6 +45,7 @@ import { } from "./geojson-loader"; import { mbtilesStyleLayerIds, + externalSourceIdsFor, removeLayerFromMap, styleValuesEqual, syncLayer, @@ -1242,9 +1243,11 @@ export class MapController { const nextIds = layers.map((l) => l.id); const nextIdSet = new Set(nextIds); const previousLayers = new Map(this.syncedLayers.map((layer) => [layer.id, layer])); + // Built once for the whole pass: every removal below asks the same question of the same list. + const survivingSourceIds = externalSourceIdsFor(layers); for (const id of this.layerIds) { if (!nextIdSet.has(id)) { - removeLayerFromMap(map, id, previousLayers.get(id)); + removeLayerFromMap(map, id, previousLayers.get(id), survivingSourceIds); } } diff --git a/packages/map/src/pmtiles-layer.ts b/packages/map/src/pmtiles-layer.ts index e06457299..8794d5c0e 100644 --- a/packages/map/src/pmtiles-layer.ts +++ b/packages/map/src/pmtiles-layer.ts @@ -19,10 +19,33 @@ export function normalizePMTilesUrl(url: string): string { return url.startsWith(`${PMTILES_PROTOCOL}://`) ? url : `${PMTILES_PROTOCOL}://${url}`; } +/** + * Collisions already reported, keyed by archive URL — an id is not unique over a session. Never + * cleared, so a test asserting one of these warnings needs a URL no other test has warned for. + */ +const reportedCollisions = new Set(); + +/** The MapLibre layers one source layer is drawn with. */ +export const pmtilesLayerKinds = ["fill", "line", "circle"] as const; + +/** The id one of an archive's style layers draws under. See {@link pmtilesControlLayerId}. */ export function pmtilesVectorLayerId(sourceId: string, sourceLayer: string, kind: string): string { return `${sourceId}-${encodeVectorTileLayerPart(sourceLayer)}-${kind}`; } +/** + * The id `maplibre-gl-components`' PMTiles control draws one of an archive's style layers under: + * the same shape as {@link pmtilesVectorLayerId}, but spelling the source layer's name **raw** + * where that encodes it. The two agree except for a name holding `/`, a space or non-ASCII, and + * anything recognising the control's layers must match both or it draws a second set over them. + * + * A mirror of an unexported template, so a bump that renames it is one line here. + * `tests/pmtiles-control-contract.test.ts` drives the real control and fails if they diverge. + */ +export function pmtilesControlLayerId(sourceId: string, sourceLayer: string, kind: string): string { + return `${sourceId}-${sourceLayer}-${kind}`; +} + /** * The MapLibre layer ids `syncLayers` creates for a `pmtiles` store layer, in * the exact naming scheme `ensurePMTilesExternalLayer` uses. A layer built @@ -39,8 +62,34 @@ export function pmtilesNativeLayerIds( return [`${sourceId}-raster`]; } return sourceLayers.flatMap((sourceLayer) => - ["fill", "line", "circle"].map((kind) => pmtilesVectorLayerId(sourceId, sourceLayer, kind)), + pmtilesLayerKinds.map((kind) => pmtilesVectorLayerId(sourceId, sourceLayer, kind)), + ); +} + +/** Which of `nativeLayerIds` are ids these source layers draw under, in either scheme. */ +export function pmtilesIdsForSourceLayers( + nativeLayerIds: readonly string[], + sourceId: string, + sourceLayers: readonly string[], +): string[] { + const drawn = new Set( + sourceLayers.flatMap((sourceLayer) => + pmtilesLayerKinds.flatMap((kind) => [ + pmtilesVectorLayerId(sourceId, sourceLayer, kind), + pmtilesControlLayerId(sourceId, sourceLayer, kind), + ]), + ), ); + return nativeLayerIds.filter((nativeLayerId) => drawn.has(nativeLayerId)); +} + +/** Whether one of `nativeLayerIds` is an id this source layer draws under. */ +export function pmtilesIdNamesSourceLayer( + nativeLayerIds: readonly string[], + sourceId: string, + sourceLayer: string, +): boolean { + return pmtilesIdsForSourceLayers(nativeLayerIds, sourceId, [sourceLayer]).length > 0; } /** Everything {@link createPMTilesStoreLayer} needs beyond the archive's own facts. */ @@ -61,6 +110,8 @@ export interface PMTilesStoreLayerOptions { sourceLayerColors?: Record; /** The MapLibre ids a control created itself; derived from the naming scheme otherwise. */ nativeLayerIds?: readonly string[]; + /** The MapLibre source to draw from, when it is not this layer's own — a shared archive. */ + sourceId?: string; } /** @@ -70,6 +121,7 @@ export interface PMTilesStoreLayerOptions { */ export function createPMTilesStoreLayer(options: PMTilesStoreLayerOptions): GeoLibreLayer { const { id, name, tileType } = options; + const sourceId = options.sourceId ?? id; const sourceLayers = [...options.sourceLayers]; const url = normalizePMTilesUrl(options.url); const fillColor = @@ -81,7 +133,7 @@ export function createPMTilesStoreLayer(options: PMTilesStoreLayerOptions): GeoL name, type: "pmtiles", source: { - sourceId: id, + sourceId, sourceLayers, tileType, type: tileType === "raster" ? "raster" : "vector", @@ -104,18 +156,97 @@ export function createPMTilesStoreLayer(options: PMTilesStoreLayerOptions): GeoL metadata: { externalNativeLayer: true, nativeLayerIds: [ - ...(options.nativeLayerIds ?? pmtilesNativeLayerIds(id, tileType, sourceLayers)), + ...(options.nativeLayerIds ?? pmtilesNativeLayerIds(sourceId, tileType, sourceLayers)), ], pickable: options.pickable ?? true, - sourceId: id, + sourceId, sourceKind: "pmtiles-url", - ...(options.sourceLayerColors ? { sourceLayerColors: options.sourceLayerColors } : {}), sourceLayers, tileType, }, }; } +/** + * One layer per source layer in a vector archive, so the Layers panel can show, reorder, style and + * hide them with the machinery it already has. Raster, or a single source layer, stays one layer. + * + * All of them name the archive's one MapLibre source, so removing one must not remove it — + * `removeLayerFromMap` refcounts it against the survivors. That id doubles as the refcount key; + * anything needing the two to differ needs its own field rather than a third reader of this one. + */ +export function createPMTilesArchiveLayers(options: PMTilesStoreLayerOptions): GeoLibreLayer[] { + // Keyed by the id each source layer would take, not by its name: `encodeVectorTileLayerPart` is + // not injective (`a/b` and `a_2Fb` both encode to `a_2Fb`), and an archive's metadata can repeat + // a name outright. Either way a second layer would carry the first one's id. + if (options.tileType === "raster") { + // Raster tiles never split, so the id math below means nothing for them. + return [createPMTilesStoreLayer(options)]; + } + // The source every part draws from, and so the prefix of every id naming one. A caller that + // points an archive at someone else's source says so here; nothing does today, and forcing + // `options.id` would have quietly ignored it. + const archiveSourceId = options.sourceId ?? options.id; + + const parts = new Map(); + for (const sourceLayer of options.sourceLayers) { + const id = `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`; + const taken = parts.get(id); + if (taken === undefined) { + parts.set(id, sourceLayer); + continue; + } + if (taken === sourceLayer) continue; + // Two names, one id (`a/b` and `a_2Fb` both encode to `a_2Fb`): the id goes to whichever owns + // it outright, or the wrong source layer is drawn under it. Re-`set` keeps the key's position, + // so the folder is not reordered. + const dropped = encodeVectorTileLayerPart(sourceLayer) === sourceLayer ? taken : sourceLayer; + if (dropped === taken) parts.set(id, sourceLayer); + // Said once per archive and name, rather than on every re-read. Serialised rather than joined: + // both halves can hold a space. + const seen = JSON.stringify([options.url, dropped]); + if (!reportedCollisions.has(seen)) { + reportedCollisions.add(seen); + console.warn( + `PMTiles archive "${options.id}": source layer "${dropped}" collides with "${dropped === taken ? sourceLayer : taken}" and is not the project's.`, + ); + } + } + if (parts.size < 2) { + // Built from `parts` like the split path, so both arms agree on what is drawn and which ids go + // with it — a dropped collider's would be styled, hidden and removed on this layer's behalf. + const drawn = [...parts.values()]; + const own = pmtilesIdsForSourceLayers(options.nativeLayerIds ?? [], archiveSourceId, drawn); + return [ + createPMTilesStoreLayer({ + ...options, + sourceLayers: drawn, + // No source layers means nothing to match against and nothing to derive from, so the + // caller's ids stand or the layer becomes a placeholder. + nativeLayerIds: + drawn.length === 0 ? options.nativeLayerIds : own.length > 0 ? own : undefined, + }), + ]; + } + return [...parts].map(([id, sourceLayer]) => { + // Whichever of the archive's ids draw this source layer: deriving a fresh set would put a + // second trio over the control's. Empty means nobody has drawn it, so ids are derived below. + const own = pmtilesIdsForSourceLayers(options.nativeLayerIds ?? [], archiveSourceId, [ + sourceLayer, + ]); + return createPMTilesStoreLayer({ + ...options, + id, + name: sourceLayer, + sourceLayers: [sourceLayer], + // The archive's source, and so the archive's ids: a layer deriving its own would name ids + // nothing on the map answers to. + sourceId: archiveSourceId, + nativeLayerIds: own.length > 0 ? own : undefined, + }); + }); +} + /** Facts about a PMTiles archive needed to build a GeoLibre layer for it. */ export interface PMTilesArchiveInfo { tileType: "vector" | "raster"; diff --git a/packages/plugins/src/plugins/maplibre-components.ts b/packages/plugins/src/plugins/maplibre-components.ts index 7be075c4b..5ffebda21 100644 --- a/packages/plugins/src/plugins/maplibre-components.ts +++ b/packages/plugins/src/plugins/maplibre-components.ts @@ -9,7 +9,13 @@ import { setExternalNativePaintBridge, useAppStore, } from "@geolibre/core"; -import { createPMTilesStoreLayer } from "@geolibre/map/pmtiles-layer"; +import { + createPMTilesArchiveLayers, + pmtilesIdsForSourceLayers, + type PMTilesStoreLayerOptions, +} from "@geolibre/map/pmtiles-layer"; +import { addPMTilesArchive } from "./pmtiles-archive-store"; +import { stringMetadata } from "./web-service-sync"; import type { QueryGeometry, QueryOptions, @@ -1738,6 +1744,30 @@ export function openPMTilesLayerPanel(app: GeoLibreAppAPI): void { void openStandalonePMTilesControl(app); } +/** + * PMTiles archives being added by URL rather than through the panel, counted per URL because two + * adds of one URL can overlap. + * + * The control keeps one tick selection for the whole panel and `addLayer(url)` does not reset it, + * so these adds — Add Data, Source Cooperative, Hugging Face, none of which shows a tick UI — would + * otherwise inherit whatever was last ticked for a different archive and strand the rest of this + * one outside the store. Marked here, they take the whole archive. + */ +const programmaticPMTilesAdds = new Map(); + +/** Mark a programmatic add in flight, returning a disposer for its `finally`. */ +function beginProgrammaticPMTilesAdd(url: string): () => void { + programmaticPMTilesAdds.set(url, (programmaticPMTilesAdds.get(url) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const remaining = (programmaticPMTilesAdds.get(url) ?? 1) - 1; + if (remaining > 0) programmaticPMTilesAdds.set(url, remaining); + else programmaticPMTilesAdds.delete(url); + }; +} + /** * Adds a remote PMTiles archive through the PMTiles control, without opening * its panel. @@ -1803,9 +1833,11 @@ export async function addPMTilesLayerFromUrl( }; map?.on("movestart", onMoveStart); map?.on("moveend", onMoveEnd); + const endAdd = beginProgrammaticPMTilesAdd(url); try { await pmtilesControl.addLayer(url); } finally { + endAdd(); // Preserve a host user's camera interaction that happened while the archive // header was loading, rather than restoring the older pre-load position. if (userMoving) camera = readCamera(); @@ -3981,26 +4013,21 @@ function createPMTilesControl( const control = new PMTilesLayerControlClass(PMTILES_OPTIONS); control.on("collapse", () => control.hide()); control.on("layeradd", createPMTilesLayerAddHandler()); - control.on("layerremove", (event) => { - const store = useAppStore.getState(); - const activeLayerIds = new Set(event.state.layers.map((layer) => layer.id)); - for (const layer of store.layers) { - if (!isPMTilesControlLayer(layer)) continue; - const shouldRemove = event.layerId - ? layer.id === event.layerId - : !activeLayerIds.has(layer.id); - if (shouldRemove) { - store.removeLayer(layer.id); - } - } - }); + control.on("layerremove", createPMTilesLayerRemoveHandler()); pmtilesStoreUnsubscribe ??= useAppStore.subscribe((state, previous) => { - const removedLayers = previous.layers.filter( - (layer) => - isPMTilesControlLayer(layer) && !state.layers.some((current) => current.id === layer.id), - ); - for (const layer of removedLayers) { - pmtilesControl?.removeLayer(layer.id); + // Every store write lands here, and the map writes pointer coordinates on each mousemove. + // Only a layers action replaces the array, so identity settles it before any scanning. + if (state.layers === previous.layers) return; + for (const archiveId of pmtilesArchivesFullyRemoved( + previous.layers, + state.layers, + controlOwnedArchives, + )) { + // Released here, not in the remove handler: a Layers-panel delete takes the archive's last + // layer, so the control's echoed `layerremove` has nothing left to attribute it to and the + // claim would outlive the archive, onto whatever reuses `pmtiles-source-N`. + controlOwnedArchives.delete(archiveId); + pmtilesControl?.removeLayer(archiveId); } }); return control; @@ -4371,9 +4398,15 @@ function teardownGeoTiffRasterOverlay(app: GeoLibreAppAPI): void { geoTiffRasterOverlayMounted = false; } -function teardownPMTilesControl(app: GeoLibreAppAPI): void { +/** @internal Exported only so the control's teardown can be unit-tested. */ +export function teardownPMTilesControl(app: GeoLibreAppAPI): void { pmtilesStoreUnsubscribe?.(); pmtilesStoreUnsubscribe = null; + // Claims belong to the control instance: a reopened panel holds nothing. Dropped *before* the + // control is removed, because `onRemove` clears every layer it drew and emits a `layerremove` + // naming no archive while its handlers are still attached — claims held that late read it as the + // user deleting them all. Released first it means only that the map lost them, redrawn next sync. + controlOwnedArchives.clear(); if (pmtilesControl && pmtilesControlMounted) { app.removeMapControl(pmtilesControl); } @@ -4796,25 +4829,89 @@ function createZarrLayerAddHandler(): ZarrLayerEventHandler { }; } -function createPMTilesLayerAddHandler(): PMTilesLayerEventHandler { +/** + * The archives this session's control added, and so may remove. + * + * Ownership is not `metadata.controlArchiveId`: that mark rides into the saved project and outlives + * the control that set it. Read as ownership, the control's clear-all — which reports an empty list + * rather than a layer id — would take archives it never added. + */ +const controlOwnedArchives = new Set(); + +/** @internal Exported only so a test starts with no control, no claims and no add in flight. */ +export function __resetPMTilesControlForTests(): void { + controlOwnedArchives.clear(); + programmaticPMTilesAdds.clear(); + pmtilesStoreUnsubscribe?.(); + pmtilesStoreUnsubscribe = null; + pmtilesControl = null; + pmtilesControlMounted = false; +} + +/** @internal Exported only so the URL-add path's effect on the tick filter can be unit-tested. */ +export function __beginProgrammaticPMTilesAddForTests(url: string): () => void { + return beginProgrammaticPMTilesAdd(url); +} + +/** @internal Exported only so a test can drive the panel state a real control would hold. */ +export function __getPMTilesControlForTests(): unknown { + return pmtilesControl; +} + +/** @internal Exported only so teardown can be unit-tested with a control mounted. */ +export function __mountPMTilesControlForTests(control: unknown): void { + pmtilesControl = control as typeof pmtilesControl; + pmtilesControlMounted = true; +} + +/** @internal Exported only so the archive's removal can be unit-tested. */ +export function createPMTilesLayerRemoveHandler(): PMTilesLayerEventHandler { + return (event) => { + const store = useAppStore.getState(); + const removed = new Set(pmtilesLayerIdsToRemove(store.layers, event, controlOwnedArchives)); + const dropped = store.layers.filter((layer) => removed.has(layer.id)); + const groupIds = new Set(dropped.map((layer) => layer.groupId)); + // Only what this event actually took: releasing every archive missing from the snapshot would + // hand back ownership of ones it never mentioned, and the control could then no longer clear + // them. + const releasing = new Set( + dropped + .map((layer) => layer.metadata.controlArchiveId) + .filter((id): id is string => typeof id === "string"), + ); + for (const id of removed) { + store.removeLayer(id); + } + // The folder was this plugin's doing, so it goes with its last layer — unless the user put + // something else in it. + const after = useAppStore.getState(); + for (const groupId of groupIds) { + if (!groupId) continue; + if (after.layers.some((layer) => layer.groupId === groupId)) continue; + after.removeLayerGroup(groupId); + } + // The control no longer has it, so neither does the claim. + const stillListed = new Set(event.state.layers.map((layer) => layer.id)); + for (const archiveId of releasing) { + if (!stillListed.has(archiveId)) controlOwnedArchives.delete(archiveId); + } + }; +} + +/** @internal Exported only so the archive's grouping can be unit-tested. */ +export function createPMTilesLayerAddHandler(): PMTilesLayerEventHandler { return (event) => { if (!event.layerId) return; const layerInfo = event.state.layers.find((layer) => layer.id === event.layerId); if (!layerInfo) return; - const store = useAppStore.getState(); - const layer = pmtilesStoreLayer(event.layerId, layerInfo); - if (store.layers.some((item) => item.id === layer.id)) { - store.updateLayer(layer.id, { - metadata: layer.metadata, - opacity: layer.opacity, - source: layer.source, - style: layer.style, - visible: layer.visible, - }); - return; - } - store.addLayer(layer); + addPMTilesArchive( + // The panel's tick selection, read from the state the control hands every handler rather than + // inferred from the ids it drew, which spell the name raw where this package encodes it. + pmtilesStoreLayers(event.layerId, layerInfo, event.state.selectedSourceLayers), + pmtilesArchiveName(event.layerId, layerInfo), + ); + controlOwnedArchives.add(event.layerId); }; } @@ -5410,22 +5507,69 @@ function createGeoTiffRasterStoreLayer(state: GeoTiffRasterLayerState): GeoLibre }; } -/** @internal Exported only so the control's layer shape can be unit-tested. */ -export function pmtilesStoreLayer(id: string, layerInfo: PMTilesLayerInfo): GeoLibreLayer { - return createPMTilesStoreLayer({ +/** @internal The layers a control-reported archive becomes. */ +export function pmtilesStoreLayers( + id: string, + layerInfo: PMTilesLayerInfo, + // Required, not defaulted: a caller that stopped passing it would silently go back to taking the + // whole archive whatever the panel has ticked, which is the behaviour this argument exists to fix. + selectedSourceLayers: readonly string[], +): GeoLibreLayer[] { + return createPMTilesArchiveLayers(pmtilesLayerOptions(id, layerInfo, selectedSourceLayers)).map( + (layer) => ({ + ...layer, + // What the control knows this archive by. A STAC asset builds the same shape without one. + metadata: { ...layer.metadata, controlArchiveId: id }, + }), + ); +} + +/** What an archive is called: the control's own name, or one read off its URL. */ +function pmtilesArchiveName(id: string, layerInfo: PMTilesLayerInfo): string { + return layerInfo.name || layerNameFromUrl(layerInfo.url, id); +} + +function pmtilesLayerOptions( + id: string, + layerInfo: PMTilesLayerInfo, + selectedSourceLayers: readonly string[], +): PMTilesStoreLayerOptions { + // What the control drew: the panel's ticked source layers, or the whole archive when none are + // ticked. A stale tick can name source layers this archive does not even have. + const controlDrew = + selectedSourceLayers.length > 0 ? selectedSourceLayers : layerInfo.sourceLayers; + // A selection naming anything this archive lacks belongs to a different one, and so does the + // checkbox list beside it — the user could not tick the rest back. None of it is trusted. + const stale = controlDrew.some((sourceLayer) => !layerInfo.sourceLayers.includes(sourceLayer)); + // Matched on the URL string exactly as the caller passed it, because the mark is claimed before + // the add and there is no archive id yet to key on. The control stores that string verbatim, and + // `tests/pmtiles-control-contract.test.ts` adds through a URL carrying a query string so a bump + // that starts rewriting it fails there rather than silently reinstating a stale tick selection. + const sourceLayers = + stale || programmaticPMTilesAdds.has(layerInfo.url) + ? layerInfo.sourceLayers + : layerInfo.sourceLayers.filter((sourceLayer) => controlDrew.includes(sourceLayer)); + // The control made these layers, so its ids stand rather than derived ones, which would draw a + // second trio over them. Only ids naming what the store holds are kept — the rest would be styled + // and removed in place of real layers. With no `vector_layers` there is nothing to derive at all, + // so the control's stand whatever they name or the layer renders as a placeholder. + const named = pmtilesIdsForSourceLayers(layerInfo.layerIds, id, sourceLayers); + // Ids left out name control-drawn layers no store layer owns; closing the panel or deleting the + // archive clears them, the control removing them by its own full `layerIds`. + return { id, - name: layerInfo.name || layerNameFromUrl(layerInfo.url, id), + name: pmtilesArchiveName(id, layerInfo), url: layerInfo.url, // The control also reports "unknown", which it and the map both draw as vector tiles. tileType: layerInfo.tileType === "raster" ? "raster" : "vector", - sourceLayers: layerInfo.sourceLayers, + sourceLayers, opacity: layerInfo.opacity, style: { fillOpacity: layerInfo.tileType === "raster" ? 0.6 : 1 }, pickable: layerInfo.pickable, - // The control created these MapLibre layers itself, so its ids stand rather than derived ones. - nativeLayerIds: layerInfo.layerIds, + nativeLayerIds: + sourceLayers.length === 0 ? layerInfo.layerIds : named.length > 0 ? named : undefined, ...(layerInfo.sourceLayerColors ? { sourceLayerColors: layerInfo.sourceLayerColors } : {}), - }); + }; } function createZarrStoreLayer(id: string, layerInfo: ZarrLayerInfo): GeoLibreLayer { @@ -5639,11 +5783,72 @@ function isGeoTiffRasterLayer(layer: GeoLibreLayer): boolean { ); } +/** + * The archive a store layer belongs to. A split-out layer is named after its source layer, so its + * own id is one the control has never heard of; matching on it goes wrong in both directions. + */ +function pmtilesArchiveId(layer: GeoLibreLayer): string | undefined { + return stringMetadata(layer.metadata.controlArchiveId); +} + +/** + * @internal The store layers to drop for a `layerremove`. Matched by archive, not by layer id, so + * removing one takes every layer split out of it and a listed archive keeps all of its own. + */ +export function pmtilesLayerIdsToRemove( + layers: readonly GeoLibreLayer[], + event: { layerId?: string; state: { layers: readonly { id: string }[] } }, + owned: ReadonlySet, +): string[] { + const activeArchiveIds = new Set(event.state.layers.map((layer) => layer.id)); + return layers + .filter((layer) => { + const archiveId = pmtilesArchiveId(layer); + if (!archiveId || !owned.has(archiveId) || !isPMTilesControlLayer(layer)) return false; + return event.layerId ? archiveId === event.layerId : !activeArchiveIds.has(archiveId); + }) + .map((layer) => layer.id); +} + +/** + * @internal The archives whose last layer has just left the store. Deleting one source layer leaves + * the rest drawing, so an archive goes only once none of its layers remain. + */ +export function pmtilesArchivesFullyRemoved( + previous: readonly GeoLibreLayer[], + next: readonly GeoLibreLayer[], + owned: ReadonlySet, +): string[] { + const remaining = new Set(); + const nextIds = new Set(); + for (const layer of next) { + nextIds.add(layer.id); + if (isPMTilesControlLayer(layer)) remaining.add(pmtilesArchiveId(layer)); + } + const gone = new Set(); + for (const layer of previous) { + if (!isPMTilesControlLayer(layer)) continue; + if (nextIds.has(layer.id)) continue; + const archiveId = pmtilesArchiveId(layer); + // Ownership, not the mark — see `controlOwnedArchives`. + if (archiveId && owned.has(archiveId) && !remaining.has(archiveId)) gone.add(archiveId); + } + return [...gone]; +} + +/** + * Whether this layer came from the control: a STAC asset and a basemap extract share its shape. + * + * A project saved before archives carried `controlArchiveId` fails this, which costs it nothing: + * every caller gates on `controlOwnedArchives` as well, and that is session state — a reloaded + * layer is not the control's to remove whether or not it carries the mark. + */ function isPMTilesControlLayer(layer: GeoLibreLayer): boolean { return ( layer.type === "pmtiles" && layer.metadata.sourceKind === "pmtiles-url" && - layer.metadata.externalNativeLayer === true + layer.metadata.externalNativeLayer === true && + pmtilesArchiveId(layer) !== undefined ); } diff --git a/packages/plugins/src/plugins/pmtiles-archive-store.ts b/packages/plugins/src/plugins/pmtiles-archive-store.ts new file mode 100644 index 000000000..a9bc1c29e --- /dev/null +++ b/packages/plugins/src/plugins/pmtiles-archive-store.ts @@ -0,0 +1,121 @@ +/** + * Putting a PMTiles archive into the store: the pieces go in one at a time, then into a folder. + * Shared so an archive from the control and one from a STAC asset cannot drift apart. + */ + +import { type GeoLibreLayer, useAppStore } from "@geolibre/core"; + +/** Archive ids already reported as reused, so a re-add does not warn about them again. */ +const reportedSourceIdClashes = new Set(); + +/** @internal Exported only so one test's clash does not silence another's. */ +export function __resetReportedPMTilesSourceIdClashesForTests(): void { + reportedSourceIdClashes.clear(); +} + +/** Whether this clash is being reported for the first time. */ +function claimPMTilesSourceIdClash(archiveId: string, url: string): boolean { + const key = JSON.stringify([archiveId, url]); + if (reportedSourceIdClashes.has(key)) return false; + reportedSourceIdClashes.add(key); + return true; +} + +/** + * Add an archive's layers, or update them where they are already on the map. + * + * @param layers - What {@link createPMTilesArchiveLayers} built. + * @param name - What to call the folder holding them. + * @returns The ids newly added; empty when every layer was already there. + */ +export function addPMTilesArchive(layers: readonly GeoLibreLayer[], name: string): string[] { + const store = useAppStore.getState(); + // Taken before the adds: ids within one archive are distinct, so nothing added here reads back. + const known = new Set(store.layers.map((item) => item.id)); + const ids = new Set(layers.map((layer) => layer.id)); + const added: string[] = []; + for (const layer of layers) { + if (known.has(layer.id)) { + // Re-pointed, not rebuilt: the user's styling, opacity and visibility stand, and `metadata` + // is merged so a plugin's own keys survive. A re-add is reached by closing the panel and + // adding the archive again — not a reason to undo what was done to the layer since. + // + // The layer may belong to a *different* archive whose id the control reused, in which case + // this takes it over silently, keeping the old name, folder and styling. A changed URL is not + // evidence of a different archive (a presigned URL re-signed), so warning here would cry wolf. + const before = store.layers.find((item) => item.id === layer.id); + store.updateLayer(layer.id, { + metadata: { ...before?.metadata, ...layer.metadata }, + source: layer.source, + // Must follow the archive: it is what the sweep below matches on, and a stale one gets the + // layer swept away as some other archive's old shape. + sourcePath: layer.sourcePath, + }); + continue; + } + store.addLayer(layer); + added.push(layer.id); + } + // The old shape goes, or it stays on the map drawing the whole archive under the layers that + // replaced it. After the adds, so the archive is never momentarily layerless — the store + // subscriber reads that as gone and hands its ownership back to the control. + // + // Matched on the URL as well as the id, because archive ids are not unique over a session: the + // control's counter restarts whenever the panel is reopened. Without it, a new archive would + // delete an unrelated one that happens to hold the same `pmtiles-source-N`. The cost is two + // archives on one `metadata.sourceId`, and so one MapLibre source — hence the warning below. + // + // A departing layer can name native layers a survivor also names, both drawing from that one + // source. Safe only because a sync pass runs every removal before any add + // (`MapController.syncLayers`, `createLayerSync`); move removals after adds and this breaks. + const archiveId = layers[0]?.metadata.sourceId; + const archiveUrl = layers[0]?.sourcePath; + if (typeof archiveId === "string") { + const emptied = new Set(); + // The pre-add snapshot, so `removeLayer` can replace the array while this walks it. + for (const stale of store.layers) { + // `metadata.sourceId` is a key every layer kind sets, so the type is checked as well. + if (stale.type !== "pmtiles" || stale.metadata.sourceId !== archiveId) continue; + // Before the URL check: a layer being taken over still shows its old URL here. + if (ids.has(stale.id)) continue; + if (stale.sourcePath !== archiveUrl) { + // Nothing else can see this, and it looks like a rendering fault: one source between two + // archives means one of them draws nothing. + if (claimPMTilesSourceIdClash(archiveId, archiveUrl ?? "")) { + console.warn( + `PMTiles archive "${archiveId}" is already "${stale.sourcePath}"; "${archiveUrl}" reuses the id and one of them will not draw.`, + ); + } + continue; + } + emptied.add(stale.groupId); + store.removeLayer(stale.id); + } + // The folder the old shape sat in goes with it when nothing is left in it, the same way the + // control's own removal prunes one — otherwise the archive comes back beside an empty husk. + const afterStale = useAppStore.getState(); + for (const groupId of emptied) { + if (!groupId) continue; + if (afterStale.layers.some((layer) => layer.groupId === groupId)) continue; + afterStale.removeLayerGroup(groupId); + } + } + // Read back after the adds, so a source layer reported later joins the folder its siblings are in. + if (layers.length > 1 && added.length > 0) { + const state = useAppStore.getState(); + // A sibling's folder, if any sibling is still in one: a user who dragged them all out has said + // this archive is not a folder any more. Where an id was reused, whatever was taken over counts + // as a sibling, so the two archives share a folder under whichever name got there first. + // + // First match wins, deliberately. A user who has split this archive's layers across folders has + // no folder that is the right one, and picking the most populated would be a guess dressed up + // as a rule — the layers are theirs to move, and this only decides where a *new* one lands. + const existing = state.layers.find((item) => ids.has(item.id) && item.groupId)?.groupId; + if (existing) { + state.moveLayersToGroup(added, existing); + } else { + state.addLayerGroup(name, added); + } + } + return added; +} diff --git a/packages/plugins/src/plugins/stac-layers.ts b/packages/plugins/src/plugins/stac-layers.ts index 991cc4302..86d8fdb59 100644 --- a/packages/plugins/src/plugins/stac-layers.ts +++ b/packages/plugins/src/plugins/stac-layers.ts @@ -1,6 +1,6 @@ -import { useAppStore } from "@geolibre/core"; -import { createPMTilesStoreLayer, readRemotePMTilesInfo } from "@geolibre/map/pmtiles-layer"; +import { createPMTilesArchiveLayers, readRemotePMTilesInfo } from "@geolibre/map/pmtiles-layer"; import { createLayerId } from "../layer-ids"; +import { addPMTilesArchive } from "./pmtiles-archive-store"; /** * Add a STAC item's PMTiles asset as a layer. @@ -8,7 +8,7 @@ import { createLayerId } from "../layer-ids"; * Not through the PMTiles control: it is a singleton that holds the archive it is loading on * itself and reports the outcome through shared state, so a caller cannot tell which add failed or * which layer it produced. Reading the header here is a range request, and the layer shape still - * comes from {@link createPMTilesStoreLayer}. Answers null for an archive with no layers to draw. + * comes from {@link createPMTilesArchiveLayers}. Answers null for an archive with no layers to draw. */ export async function addPMTilesAsset( href: string, @@ -23,9 +23,11 @@ export async function addPMTilesAsset( // Full opacity, unlike the basemap extract's dimmed raster: an asset added from a search result // is the thing the user asked to look at, not a backdrop. + // Split into a layer per source layer and folded into a folder, the same as an archive added + // through the PMTiles control: an asset opened from a catalog is no less worth taking apart. const id = createLayerId(); - useAppStore.getState().addLayer( - createPMTilesStoreLayer({ + const added = addPMTilesArchive( + createPMTilesArchiveLayers({ id, name, url: href, @@ -33,6 +35,8 @@ export async function addPMTilesAsset( ...(info.encoding ? { encoding: info.encoding } : {}), sourceLayers: info.sourceLayers, }), + name, ); - return id; + // The caller reads this as "did anything land", not as a handle on one layer. + return added[0] ?? null; } diff --git a/tests/pmtiles-archive-grouping.test.ts b/tests/pmtiles-archive-grouping.test.ts new file mode 100644 index 000000000..b143cda19 --- /dev/null +++ b/tests/pmtiles-archive-grouping.test.ts @@ -0,0 +1,642 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; +import type { PMTilesLayerInfo } from "maplibre-gl-components"; +import { useAppStore } from "../packages/core/src/store"; +import { isPlaceholderLayer } from "../packages/map/src/placeholders"; +import { createPMTilesArchiveLayers } from "../packages/map/src/pmtiles-layer"; +import { + __resetReportedPMTilesSourceIdClashesForTests, + addPMTilesArchive, +} from "../packages/plugins/src/plugins/pmtiles-archive-store"; +import { + __beginProgrammaticPMTilesAddForTests, + __mountPMTilesControlForTests, + __resetPMTilesControlForTests, + createPMTilesLayerAddHandler, + createPMTilesLayerRemoveHandler, + teardownPMTilesControl, +} from "../packages/plugins/src/plugins/maplibre-components"; + +/** + * What the control reports for an archive it has loaded, with `sourceLayers` it has discovered. + * `layerIds` is built the way the real control builds it — `${sourceId}-${rawName}-${kind}` for + * every source layer it drew, ticked ones alone when the panel has a selection. + */ +function addEvent( + sourceLayers: string[], + id = "pmtiles-1", + url = "https://example.org/units.pmtiles", + selectedSourceLayers: string[] = [], +) { + // What the control draws, and so what it names ids for: the ticked source layers, or the whole + // archive when the panel has none ticked. + const drawn = selectedSourceLayers.length > 0 ? selectedSourceLayers : sourceLayers; + const layer: PMTilesLayerInfo = { + id, + url, + name: "Units", + tileType: "vector", + sourceLayers, + layerIds: drawn.flatMap((sourceLayer) => + ["fill", "line", "circle"].map((kind) => `${id}-${sourceLayer}-${kind}`), + ), + opacity: 0.8, + pickable: true, + }; + return { layerId: id, state: { layers: [layer], selectedSourceLayers } } as never; +} + +function archiveLayers() { + const state = useAppStore.getState(); + return state.layers.filter((layer) => layer.id.startsWith("pmtiles-1")); +} + +// A catalog asset and a control add go through one function, so an archive opened from STAC is +// taken apart the same way rather than landing as one flat layer. +describe("adding an archive from anywhere", () => { + beforeEach(() => { + __resetPMTilesControlForTests(); + __resetReportedPMTilesSourceIdClashesForTests(); + const state = useAppStore.getState(); + for (const layer of [...state.layers]) state.removeLayer(layer.id); + for (const group of [...state.layerGroups]) state.removeLayerGroup(group.id); + }); + + it("splits and folders it whichever door it came in", () => { + const layers = createPMTilesArchiveLayers({ + id: "asset-1", + name: "Quaternary faults", + url: "https://example.org/qfaults.pmtiles", + tileType: "vector", + sourceLayers: ["faults", "folds"], + }); + + const added = addPMTilesArchive(layers, "Quaternary faults"); + + const state = useAppStore.getState(); + assert.deepEqual(added, ["asset-1-faults", "asset-1-folds"]); + assert.equal(state.layerGroups.length, 1); + assert.equal(state.layerGroups[0]!.name, "Quaternary faults"); + assert.deepEqual( + new Set(state.layers.map((layer) => layer.groupId)), + new Set([state.layerGroups[0]!.id]), + ); + }); + + // The same archive can be read twice under one id — re-added after the panel was closed and + // reopened, which restarts the control's counter — and one source layer is named after the + // archive while several are named after each, so its shape decides the id scheme. + // + // The control emits `layeradd` once per add, so this is a *re-add*, never a second event for an + // add still in flight. Nothing here is progressive metadata discovery. + it("replaces the archive when a later read finds more source layers", () => { + const one = createPMTilesArchiveLayers({ + id: "asset-3", + name: "Faults", + url: "https://example.org/f.pmtiles", + tileType: "vector", + sourceLayers: ["faults"], + }); + addPMTilesArchive(one, "Faults"); + assert.deepEqual( + useAppStore.getState().layers.map((layer) => layer.id), + ["asset-3"], + ); + + const many = createPMTilesArchiveLayers({ + id: "asset-3", + name: "Faults", + url: "https://example.org/f.pmtiles", + tileType: "vector", + sourceLayers: ["faults", "folds"], + }); + addPMTilesArchive(many, "Faults"); + + assert.deepEqual( + useAppStore.getState().layers.map((layer) => layer.id), + ["asset-3-faults", "asset-3-folds"], + "the layer under the old scheme went with it", + ); + }); + + // Archive ids are not unique over a session: the control numbers them from a counter a fresh + // instance restarts at 0, and closing the panel destroys the instance while leaving its layers in + // the store. So the next archive added can carry an id another one is already using. + it("leaves an unrelated archive alone when a new one reuses its id", () => { + const first = createPMTilesArchiveLayers({ + id: "pmtiles-source-0", + name: "Faults", + url: "https://example.org/faults.pmtiles", + tileType: "vector", + sourceLayers: ["faults", "folds"], + }); + addPMTilesArchive(first, "Faults"); + + // The panel is closed and reopened, and a different archive takes the same id. + const second = createPMTilesArchiveLayers({ + id: "pmtiles-source-0", + name: "Parcels", + url: "https://example.org/parcels.pmtiles", + tileType: "vector", + sourceLayers: ["parcels"], + }); + const warn = console.warn; + console.warn = () => {}; + try { + addPMTilesArchive(second, "Parcels"); + } finally { + console.warn = warn; + } + + const state = useAppStore.getState(); + assert.deepEqual( + state.layers.map((layer) => layer.name), + ["faults", "folds", "Parcels"], + "the archive that was already there is untouched", + ); + assert.equal(state.layerGroups.length, 1, "and it still has its folder"); + }); + + // The reused id lands on a layer that is already there, so it is updated rather than added — and + // an update that leaves `sourcePath` behind leaves the layer answering to an archive it no longer + // draws, which the sweep above then matches when that archive comes back. + it("re-points a layer a different archive has taken over", () => { + const shaped = (id: string, name: string, url: string, sourceLayers: string[]) => + createPMTilesArchiveLayers({ id, name, url, tileType: "vector", sourceLayers }); + const faults = "https://example.org/faults.pmtiles"; + addPMTilesArchive(shaped("pmtiles-source-0", "Faults", faults, ["faults"]), "Faults"); + // The panel is closed and reopened, and a different archive takes the same id. + addPMTilesArchive( + shaped("pmtiles-source-0", "Parcels", "https://example.org/parcels.pmtiles", ["parcels"]), + "Parcels", + ); + assert.match( + useAppStore.getState().layers[0]!.sourcePath ?? "", + /parcels/, + "the layer draws the archive that took it over", + ); + + // And once more, back to the first archive, now split. Its old shape is gone — that layer is + // the parcels archive now — so nothing of it is there to sweep. + const warn = console.warn; + console.warn = () => {}; + try { + addPMTilesArchive( + shaped("pmtiles-source-0", "Faults", faults, ["faults", "folds"]), + "Faults", + ); + } finally { + console.warn = warn; + } + + assert.deepEqual( + useAppStore.getState().layers.map((layer) => layer.id), + ["pmtiles-source-0", "pmtiles-source-0-faults", "pmtiles-source-0-folds"], + "the parcels archive is left standing rather than swept as the old shape of this one", + ); + }); + + // A re-add is reached by closing the PMTiles panel and adding the same archive again, which is a + // re-read of what is already there — not a reason to undo what the user has done to it since. + it("keeps a layer's styling when its archive is added again", () => { + const shaped = () => + createPMTilesArchiveLayers({ + id: "pmtiles-source-0", + name: "Faults", + url: "https://example.org/faults.pmtiles", + tileType: "vector", + sourceLayers: ["faults"], + }); + addPMTilesArchive(shaped(), "Faults"); + const store = useAppStore.getState(); + store.updateLayer("pmtiles-source-0", { + style: { ...store.layers[0]!.style, fillColor: "#123456" }, + opacity: 0.3, + visible: false, + metadata: { ...store.layers[0]!.metadata, somePluginKey: "kept" }, + }); + + addPMTilesArchive(shaped(), "Faults"); + + const layer = useAppStore.getState().layers[0]!; + assert.equal(layer.style.fillColor, "#123456", "the colour the user chose is still there"); + assert.equal(layer.opacity, 0.3, "and the opacity, which the panel's slider never reports"); + assert.equal(layer.visible, false, "and so is the fact they hid it"); + assert.equal(layer.metadata.somePluginKey, "kept", "and what a plugin parked on it"); + assert.deepEqual( + layer.metadata.sourceLayers, + ["faults"], + "while the archive's own facts stand", + ); + }); + + // A layer the add takes over is not a second archive on the id — it is this one now, so nothing + // is left mis-drawing and nothing is said. The layer keeps the name, folder and styling of the + // archive it used to draw, which is the cost of an id the control handed out twice. + it("says nothing when the new archive takes over every layer of the old", () => { + const shaped = (name: string, url: string) => + createPMTilesArchiveLayers({ + id: "pmtiles-source-0", + name, + url, + tileType: "vector", + sourceLayers: ["roads", "water"], + }); + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message: unknown) => warnings.push(String(message)); + try { + addPMTilesArchive(shaped("A", "https://example.org/a.pmtiles"), "A"); + addPMTilesArchive(shaped("B", "https://example.org/b.pmtiles"), "B"); + } finally { + console.warn = warn; + } + + assert.deepEqual(warnings, [], "nothing was left drawing the archive that was replaced"); + assert.deepEqual( + useAppStore.getState().layers.map((layer) => layer.sourcePath), + ["pmtiles://https://example.org/b.pmtiles", "pmtiles://https://example.org/b.pmtiles"], + ); + }); + + // Two archives on one id name one MapLibre source, so the first to sync creates it and the other + // resolves source layers that are not in it. Nothing else can see that, so it is said here. + it("says so when a new archive reuses an id another one is drawing", () => { + const shaped = (name: string, url: string, sourceLayers: string[]) => + createPMTilesArchiveLayers({ + id: "pmtiles-source-0", + name, + url, + tileType: "vector", + sourceLayers, + }); + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message: unknown) => warnings.push(String(message)); + try { + addPMTilesArchive(shaped("A", "https://example.org/a.pmtiles", ["faults", "folds"]), "A"); + addPMTilesArchive(shaped("B", "https://example.org/b.pmtiles", ["roads", "water"]), "B"); + } finally { + console.warn = warn; + } + + assert.equal(warnings.length, 1, "one clash, one warning"); + assert.match(warnings[0]!, /b\.pmtiles/); + const state = useAppStore.getState(); + assert.equal(state.layerGroups.length, 2, "and the two archives are not filed as one"); + assert.deepEqual( + state.layerGroups.map((group) => group.name), + ["A", "B"], + ); + }); + + it("takes the old folder with the old shape, rather than leaving an empty one", () => { + const shaped = (sourceLayers: string[]) => + createPMTilesArchiveLayers({ + id: "asset-4", + name: "Faults", + url: "https://example.org/f.pmtiles", + tileType: "vector", + sourceLayers, + }); + addPMTilesArchive(shaped(["faults", "folds"]), "Faults"); + assert.equal(useAppStore.getState().layerGroups.length, 1, "the split archive made a folder"); + + // A later read finds only one source layer, so the archive is one layer named after itself. + addPMTilesArchive(shaped(["faults"]), "Faults"); + + const state = useAppStore.getState(); + assert.deepEqual( + state.layers.map((layer) => layer.id), + ["asset-4"], + ); + assert.deepEqual(state.layerGroups, [], "the folder the split layers sat in went with them"); + }); + + // The store subscriber reads "no layers left for this archive" as the archive being gone and + // tells the control so, which hands back the ownership the replacing layers need. So the archive + // must never be momentarily layerless while it changes shape. + it("never leaves the archive layerless while it changes shape", () => { + const shaped = (sourceLayers: string[]) => + createPMTilesArchiveLayers({ + id: "asset-6", + name: "Faults", + url: "https://example.org/f.pmtiles", + tileType: "vector", + sourceLayers, + }); + addPMTilesArchive(shaped(["faults"]), "Faults"); + + const counts: number[] = []; + const unsubscribe = useAppStore.subscribe((state) => { + counts.push(state.layers.filter((l) => l.metadata.sourceId === "asset-6").length); + }); + addPMTilesArchive(shaped(["faults", "folds"]), "Faults"); + unsubscribe(); + + assert.ok(counts.length > 0, "the store did change"); + assert.ok(!counts.includes(0), `archive was empty at some point: ${counts.join(",")}`); + }); + + it("puts only the layers it added into a new folder", () => { + const shaped = (sourceLayers: string[]) => + createPMTilesArchiveLayers({ + id: "asset-7", + name: "Faults", + url: "https://example.org/f.pmtiles", + tileType: "vector", + sourceLayers, + }); + addPMTilesArchive(shaped(["faults", "folds"]), "Faults"); + // The user takes both out of the folder and deletes it. + const first = useAppStore.getState(); + first.moveLayersToGroup( + first.layers.map((layer) => layer.id), + null, + ); + for (const group of [...useAppStore.getState().layerGroups]) { + useAppStore.getState().removeLayerGroup(group.id); + } + + addPMTilesArchive(shaped(["faults", "folds", "scarps"]), "Faults"); + + const state = useAppStore.getState(); + assert.equal(state.layerGroups.length, 1); + const inFolder = state.layers.filter((l) => l.groupId === state.layerGroups[0]!.id); + assert.deepEqual( + inFolder.map((l) => l.id), + ["asset-7-scarps"], + "the ones the user pulled out stay out", + ); + }); + + it("leaves a single-source-layer archive as the one layer it is", () => { + const layers = createPMTilesArchiveLayers({ + id: "asset-2", + name: "Parcels", + url: "https://example.org/parcels.pmtiles", + tileType: "vector", + sourceLayers: ["parcels"], + }); + + const added = addPMTilesArchive(layers, "Parcels"); + + assert.deepEqual(added, ["asset-2"], "no per-source-layer id, and so no folder"); + assert.deepEqual(useAppStore.getState().layerGroups, []); + }); +}); + +describe("the folder an archive's source layers are added into", () => { + beforeEach(() => { + __resetPMTilesControlForTests(); + __resetReportedPMTilesSourceIdClashesForTests(); + const state = useAppStore.getState(); + for (const layer of [...state.layers]) state.removeLayer(layer.id); + for (const group of [...state.layerGroups]) state.removeLayerGroup(group.id); + }); + + it("puts every source layer in one folder named after the archive", () => { + createPMTilesLayerAddHandler()(addEvent(["roads", "water"])); + + const groups = useAppStore.getState().layerGroups; + assert.equal(groups.length, 1); + assert.equal(groups[0]!.name, "Units"); + assert.deepEqual( + new Set(archiveLayers().map((layer) => layer.groupId)), + new Set([groups[0]!.id]), + ); + }); + + it("takes the folder away with the archive, rather than leaving it empty", () => { + const handle = createPMTilesLayerAddHandler(); + handle(addEvent(["roads", "water"])); + assert.equal(useAppStore.getState().layerGroups.length, 1); + + createPMTilesLayerRemoveHandler()({ layerId: "pmtiles-1", state: { layers: [] } }); + + const state = useAppStore.getState(); + assert.deepEqual(archiveLayers(), [], "every layer of the archive went"); + assert.deepEqual(state.layerGroups, [], "and its folder went with them"); + }); + + it("leaves a folder standing when the user has put something else in it", () => { + const handle = createPMTilesLayerAddHandler(); + handle(addEvent(["roads", "water"])); + const groupId = useAppStore.getState().layerGroups[0]!.id; + // Not one of the archive's: a layer the user dragged into the same folder. + const source = archiveLayers()[0]!; + const mine = { + ...source, + id: "a-layer-of-my-own", + groupId: undefined, + metadata: { ...source.metadata, sourceKind: "geojson-file", sourceId: "mine" }, + }; + useAppStore.getState().addLayer(mine); + useAppStore.getState().moveLayersToGroup([mine.id], groupId); + + createPMTilesLayerRemoveHandler()({ layerId: "pmtiles-1", state: { layers: [] } }); + + const state = useAppStore.getState(); + assert.deepEqual(archiveLayers(), [], "the archive still went"); + assert.equal(state.layerGroups.length, 1, "but the folder stayed, holding the user's layer"); + assert.deepEqual( + state.layers.map((layer) => layer.id), + [mine.id], + ); + }); + + // A `layerremove` names one archive. Releasing every archive missing from its snapshot would hand + // back ownership of ones the event never mentioned, and the control could no longer clear those. + it("keeps its claim on the archives an event did not name", () => { + const handle = createPMTilesLayerAddHandler(); + handle(addEvent(["roads", "water"])); + handle(addEvent(["parcels"], "pmtiles-2")); + + // The control drops the first archive, and its snapshot lists neither. + createPMTilesLayerRemoveHandler()({ layerId: "pmtiles-1", state: { layers: [] } }); + // The second archive is still the control's, so its own clear-all still takes it. + createPMTilesLayerRemoveHandler()({ layerId: "pmtiles-2", state: { layers: [] } }); + + assert.deepEqual(useAppStore.getState().layers, [], "both archives went"); + }); + + // Closing the panel destroys the control; reopening builds one holding nothing, while the layers + // it added are still on the map. + it("stops being the control's once the panel that added it has closed", () => { + createPMTilesLayerAddHandler()(addEvent(["roads", "water"])); + teardownPMTilesControl({ removeMapControl: () => true } as never); + + createPMTilesLayerRemoveHandler()({ state: { layers: [] } }); + + assert.equal(archiveLayers().length, 2, "a new control's clear-all does not take them"); + }); + + // The control's own `onRemove` clears every layer it drew and emits a `layerremove` naming no + // archive — before it drops its handlers, so the one above still runs. Claims held that late read + // it as the user deleting all of them, and closing the panel or disabling this plugin would take + // the project's layers with it. Off the map is all it may mean; the next sync redraws them. + it("does not delete them when the closing control clears what it drew", () => { + createPMTilesLayerAddHandler()(addEvent(["roads", "water"])); + const onRemove = createPMTilesLayerRemoveHandler(); + __mountPMTilesControlForTests({}); + + teardownPMTilesControl({ + removeMapControl: () => { + onRemove({ state: { layers: [] } }); + return true; + }, + } as never); + + assert.equal(archiveLayers().length, 2, "the layers it drew are still the project's"); + assert.equal(useAppStore.getState().layerGroups.length, 1, "and so is their folder"); + }); + + // `addLayer(url)` does not reset the panel's tick selection, so an archive added by URL is drawn + // with whatever was last ticked for a different one and reports ids for those alone. Add Data, + // Source Cooperative and Hugging Face have no tick UI, so reading that as this archive's + // selection would strand the rest of it outside the store with no way back. + it("keeps the whole archive when it was added by URL rather than through the panel", () => { + const url = "https://example.org/by-url.pmtiles"; + // The panel holds a tick left over from a different archive. + const event = addEvent(["roads", "water"], "pmtiles-3", url, ["roads"]); + const endAdd = __beginProgrammaticPMTilesAddForTests(url); + try { + createPMTilesLayerAddHandler()(event); + } finally { + endAdd(); + } + + assert.deepEqual( + useAppStore + .getState() + .layers.filter((layer) => layer.id.startsWith("pmtiles-3")) + .map((layer) => layer.name), + ["roads", "water"], + "both source layers are the project's, not just the stale tick", + ); + }); + + // The panel's tick selection is a field of the state the control hands every handler, so it is + // read rather than reconstructed from the ids it drew — which spell a name raw where this + // package encodes it, and so agree only for names needing no encoding. + it("adds only the source layers the panel has ticked", () => { + createPMTilesLayerAddHandler()( + addEvent(["roads", "water", "zones residentielles"], "pmtiles-4", undefined, [ + "roads", + "zones residentielles", + ]), + ); + + assert.deepEqual( + useAppStore + .getState() + .layers.filter((layer) => layer.id.startsWith("pmtiles-4")) + .map((layer) => layer.name), + ["roads", "zones residentielles"], + "the unticked one is not a layer, and the encoded name is not mistaken for one", + ); + }); + + // One ticked source layer is not a folder of one: the archive stays the single layer it was + // before the split, named after itself and drawing only what was ticked. + it("stays one layer named after the archive when a single source layer is ticked", () => { + createPMTilesLayerAddHandler()(addEvent(["roads", "water"], "pmtiles-6", undefined, ["water"])); + + const layers = useAppStore + .getState() + .layers.filter((layer) => layer.id.startsWith("pmtiles-6")); + assert.deepEqual( + layers.map((layer) => [layer.name, layer.source.sourceLayers]), + [["Units", ["water"]]], + ); + assert.deepEqual(useAppStore.getState().layerGroups, [], "and no folder for the one of it"); + }); + + // A selection naming a source layer this archive does not have is the panel still holding another + // archive's, alongside that archive's checkbox list — so the user could not tick the rest back. + it("takes the whole archive when the selection names something it does not have", () => { + createPMTilesLayerAddHandler()( + addEvent(["roads", "water"], "pmtiles-7", undefined, ["roads", "elevation"]), + ); + + assert.deepEqual( + useAppStore + .getState() + .layers.filter((layer) => layer.id.startsWith("pmtiles-7")) + .map((layer) => layer.name), + ["roads", "water"], + "not just the half of a stale selection that happens to overlap", + ); + }); + + // A stale selection is discarded, so the store takes source layers the control drew and ones it + // did not. The ids it did draw still have to be kept: dropping the lot because some of them name + // a phantom leaves the part that is really on the map deriving an encoded id of its own, drawn + // over the control's — which is the whole reason both id schemes are matched. + it("keeps the ids for the source layers the control really drew", () => { + const name = "zones residentielles"; + createPMTilesLayerAddHandler()( + addEvent([name, "water"], "pmtiles-9", undefined, [name, "elevation"]), + ); + + const layers = useAppStore.getState().layers.filter((l) => l.id.startsWith("pmtiles-9")); + assert.deepEqual( + layers.map((layer) => layer.metadata.nativeLayerIds), + [ + // The control's own, spelling the name raw. + [`pmtiles-9-${name}-fill`, `pmtiles-9-${name}-line`, `pmtiles-9-${name}-circle`], + // Nothing drew this one, so it derives its own. + ["pmtiles-9-water-fill", "pmtiles-9-water-line", "pmtiles-9-water-circle"], + ], + ); + }); + + // An archive with no `vector_layers` has nothing to derive ids from, so the control's have to + // stand however stale the tick beside them: dropped, the layer renders as a placeholder. + it("keeps the control's ids for an archive with no source layers of its own", () => { + createPMTilesLayerAddHandler()(addEvent([], "pmtiles-8", undefined, ["roads"])); + + const layer = useAppStore.getState().layers.find((item) => item.id === "pmtiles-8")!; + assert.equal(isPlaceholderLayer(layer), false, "it renders rather than placeholding"); + assert.deepEqual(layer.metadata.nativeLayerIds, [ + "pmtiles-8-roads-fill", + "pmtiles-8-roads-line", + "pmtiles-8-roads-circle", + ]); + }); + + // The control drew what the stale tick named, so its ids name that and not what the store holds. + // Kept, they would be what `layer-sync` hides, styles and removes instead of the real layers. + it("drops the control's ids when they do not name what is drawn", () => { + createPMTilesLayerAddHandler()(addEvent(["buildings"], "pmtiles-5", undefined, ["roads"])); + + const layer = useAppStore.getState().layers.find((item) => item.id === "pmtiles-5")!; + assert.deepEqual( + layer.metadata.nativeLayerIds, + ["pmtiles-5-buildings-fill", "pmtiles-5-buildings-line", "pmtiles-5-buildings-circle"], + "derived for what is drawn, not the phantom `roads` ids the control reported", + ); + }); + + // A re-add of an archive already in the store can report a source layer the first read did not. + // That layer belongs with its siblings, not in a folder of its own. Defence in depth: the control + // emits `layeradd` once per add, so this arrives as a fresh add rather than a follow-up event. + it("puts a source layer a later event reports into the folder that already exists", () => { + const handle = createPMTilesLayerAddHandler(); + handle(addEvent(["roads", "water"])); + const groupId = useAppStore.getState().layerGroups[0]!.id; + + handle(addEvent(["roads", "water", "buildings"])); + + const groups = useAppStore.getState().layerGroups; + assert.equal(groups.length, 1, "no second folder beside the first"); + assert.equal(groups[0]!.id, groupId, "and it is the same folder"); + const layers = archiveLayers(); + assert.equal(layers.length, 3, "the newly reported source layer was added"); + assert.deepEqual( + new Set(layers.map((layer) => layer.groupId)), + new Set([groupId]), + "every source layer sits in it, the late one included", + ); + }); +}); diff --git a/tests/pmtiles-archive-layers.test.ts b/tests/pmtiles-archive-layers.test.ts new file mode 100644 index 000000000..275068c01 --- /dev/null +++ b/tests/pmtiles-archive-layers.test.ts @@ -0,0 +1,505 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createPMTilesArchiveLayers, + type PMTilesStoreLayerOptions, +} from "../packages/map/src/pmtiles-layer"; +import { + externalSourceIdsFor, + hasPMTilesArchive, + registerPMTilesArchive, + removeLayerFromMap, +} from "../packages/map/src/layer-sync"; +import { + blendModeForNativeLayer, + resetLayerBlendModes, + syncLayerBlendModes, +} from "../packages/map/src/layer-blend-modes"; + +const archive: PMTilesStoreLayerOptions = { + id: "grid", + name: "MGRS grid", + url: "https://example.org/mgrs.pmtiles", + tileType: "vector", + sourceLayers: ["gzd", "hundredkm", "labels"], + sourceLayerColors: { gzd: "#b23434", hundredkm: "#3cdd6b", labels: "#8311d4" }, +}; + +// An archive is several things. Expanded into a layer each, the panel can name, reorder, style and +// hide them with what it already has — but they draw from one source, which is the part that needs +// care on both ends: sharing it, and not pulling it out from under the others. +describe("expanding an archive into a layer per source layer", () => { + it("names each layer after the source layer it draws", () => { + const layers = createPMTilesArchiveLayers(archive); + assert.deepEqual( + layers.map((layer) => layer.name), + ["gzd", "hundredkm", "labels"], + ); + assert.deepEqual( + layers.map((layer) => layer.source.sourceLayers), + [["gzd"], ["hundredkm"], ["labels"]], + ); + }); + + it("gives each the colour the archive assigned it", () => { + const layers = createPMTilesArchiveLayers(archive); + assert.deepEqual( + layers.map((layer) => layer.style.fillColor), + ["#b23434", "#3cdd6b", "#8311d4"], + ); + }); + + it("puts them all on one MapLibre source", () => { + const layers = createPMTilesArchiveLayers(archive); + assert.deepEqual(new Set(layers.map((layer) => layer.metadata.sourceId)), new Set(["grid"])); + // Both places name it: readers are split on which one they consult. `getPMTilesSourceId` + // prefers the metadata, but `loadedVectorTileFeatures` reads `source.sourceId` alone and + // swallows a bad id in a `catch` — a layer naming a source that does not exist would report + // no features rather than fail, and the geometry backfill would infer nothing. + assert.deepEqual(new Set(layers.map((layer) => layer.source.sourceId)), new Set(["grid"])); + assert.equal(new Set(layers.map((layer) => layer.id)).size, 3, "but each is its own layer"); + }); + + // `encodeVectorTileLayerPart` is not injective — `a/b` and `a_2Fb` both encode to `a_2Fb` — and an + // archive's metadata can repeat a name outright. Either way a second layer would take the first + // one's id, and the store would hold two layers answering to it. + it("gives two source layers that would share an id a single layer", () => { + const warn = console.warn; + console.warn = () => {}; + let layers; + try { + layers = createPMTilesArchiveLayers({ + ...archive, + sourceLayerColors: undefined, + sourceLayers: ["a/b", "a_2Fb", "roads"], + }); + } finally { + console.warn = warn; + } + + assert.equal(new Set(layers.map((layer) => layer.id)).size, layers.length, "no id twice"); + assert.deepEqual( + layers.map((layer) => layer.name), + ["a_2Fb", "roads"], + "the id goes to the name that is its own id, not to whichever was read first", + ); + }); + + // Order decides which name reaches the id first, but not which keeps it: whichever encodes to + // itself is the one the control's raw ids name, so drawing the other in its place would put a + // source layer on the map that nothing ticked. Where neither name is its own id the winner is + // still first-wins, which nothing on the map depends on. + it("gives the shared id to whichever name is its own id, however they are ordered", () => { + const warn = console.warn; + console.warn = () => {}; + const drawn = (sourceLayers: string[]) => + createPMTilesArchiveLayers({ + ...archive, + sourceLayerColors: undefined, + sourceLayers, + }).map((layer) => layer.name); + try { + assert.deepEqual(drawn(["a_2Fb", "a/b", "roads"]), ["a_2Fb", "roads"]); + assert.deepEqual(drawn(["a/b", "a_2Fb", "roads"]), ["a_2Fb", "roads"]); + } finally { + console.warn = warn; + } + }); + + // The collider is dropped, so the ids that drew it are not this layer's to hide, style or remove. + it("does not carry a dropped collider's ids into the layer that kept the id", () => { + const warn = console.warn; + console.warn = () => {}; + let layers; + try { + layers = createPMTilesArchiveLayers({ + ...archive, + sourceLayerColors: undefined, + sourceLayers: ["a/b", "a_2Fb"], + nativeLayerIds: ["a/b", "a_2Fb"].flatMap((name) => + ["fill", "line", "circle"].map((kind) => `grid-${name}-${kind}`), + ), + }); + } finally { + console.warn = warn; + } + + assert.equal(layers.length, 1); + assert.deepEqual(layers[0]!.metadata.nativeLayerIds, [ + "grid-a_2Fb-fill", + "grid-a_2Fb-line", + "grid-a_2Fb-circle", + ]); + }); + + it("keeps a repeated source layer to one layer", () => { + const layers = createPMTilesArchiveLayers({ + ...archive, + sourceLayerColors: undefined, + sourceLayers: ["roads", "roads", "water"], + }); + + assert.deepEqual( + layers.map((layer) => layer.name), + ["roads", "water"], + ); + }); + + it("says so when a colliding source layer is left undrawn", () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message: unknown) => warnings.push(String(message)); + try { + // Its own archive URL, so the two adds below are two archives rather than one read twice. + createPMTilesArchiveLayers({ + ...archive, + url: "https://example.org/collides.pmtiles", + sourceLayerColors: undefined, + sourceLayers: ["a/b", "a_2Fb", "roads"], + }); + // A repeat of the same name is not a collision — nothing is lost, so nothing is said. + createPMTilesArchiveLayers({ + ...archive, + url: "https://example.org/repeats.pmtiles", + sourceLayerColors: undefined, + sourceLayers: ["roads", "roads", "water"], + }); + } finally { + console.warn = warn; + } + + assert.equal(warnings.length, 1, "one collision, one warning"); + assert.match(warnings[0]!, /"a\/b" collides with "a_2Fb"/); + }); + + // A control re-reports its source layers as an archive's metadata arrives, so the same collision + // would otherwise be logged on every read. + it("says it once, however often the archive is read", () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message: unknown) => warnings.push(String(message)); + try { + for (let read = 0; read < 3; read += 1) { + createPMTilesArchiveLayers({ + ...archive, + url: "https://example.org/read-again.pmtiles", + sourceLayerColors: undefined, + sourceLayers: ["a/b", "a_2Fb", "roads"], + }); + } + } finally { + console.warn = warn; + } + + assert.equal(warnings.length, 1, "three reads, one warning"); + }); + + // Archive ids come from a counter the control resets when it is rebuilt, so `pmtiles-source-0` + // names one archive in this project and a different one in the next. Keyed by id, the second + // would lose a source layer with nothing said about it. + it("says it again for a different archive that reuses the id", () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message: unknown) => warnings.push(String(message)); + try { + for (const url of [ + "https://example.org/first.pmtiles", + "https://example.org/second.pmtiles", + ]) { + createPMTilesArchiveLayers({ + ...archive, + id: "pmtiles-source-0", + url, + sourceLayerColors: undefined, + sourceLayers: ["a/b", "a_2Fb", "roads"], + }); + } + } finally { + console.warn = warn; + } + + assert.equal(warnings.length, 2, "two archives, two warnings"); + }); + + it("says nothing about a raster archive, which never splits", () => { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message: unknown) => warnings.push(String(message)); + try { + createPMTilesArchiveLayers({ + ...archive, + // Its own URL, so the silence is the raster early return and not a collision an earlier + // test already reported for this pair. + url: "https://example.org/raster.pmtiles", + tileType: "raster", + sourceLayerColors: undefined, + sourceLayers: ["a/b", "a_2Fb"], + }); + } finally { + console.warn = warn; + } + + assert.deepEqual(warnings, [], "the id math means nothing for raster tiles"); + }); + + it("does not carry a repeated source layer into an unsplit archive", () => { + const layers = createPMTilesArchiveLayers({ + ...archive, + sourceLayerColors: undefined, + sourceLayers: ["units", "units"], + }); + + assert.equal(layers.length, 1, "one name, so one layer"); + assert.deepEqual(layers[0]!.source.sourceLayers, ["units"], "and it is named once"); + }); + + it("leaves an archive with one source layer, or a raster one, as a single layer", () => { + assert.equal(createPMTilesArchiveLayers({ ...archive, sourceLayers: ["only"] }).length, 1); + assert.equal(createPMTilesArchiveLayers({ ...archive, tileType: "raster" }).length, 1); + }); +}); + +describe("removing one of an archive's layers", () => { + it("leaves the shared source alone while its siblings still draw from it", () => { + const layers = createPMTilesArchiveLayers(archive); + const removedSources: string[] = []; + const map = { + getLayer: () => undefined, + getSource: (id: string) => ({ id }), + removeLayer: () => {}, + removeSource: (id: string) => removedSources.push(id), + getLayersOrder: () => [], + }; + + removeLayerFromMap( + map as never, + layers[0].id, + layers[0], + externalSourceIdsFor(layers.slice(1)), + ); + + assert.equal( + removedSources.includes("grid"), + false, + "the source two siblings still draw from must stay", + ); + }); + + // Deleting the folder removes every child in one store commit, so the sync loop takes them one + // after another with the same (empty) survivor list. The source must outlive the siblings still + // on the map, or MapLibre reports an error for each of them. + it("keeps the source while a sibling's style layers are still on the map", () => { + const layers = createPMTilesArchiveLayers(archive); + let style = layers.flatMap((layer) => + (layer.metadata.nativeLayerIds as string[]).map((id) => ({ id, source: "grid" })), + ); + const removedSources: string[] = []; + const map = { + getLayersOrder: () => style.map((layer) => layer.id), + getLayer: (id: string) => style.find((layer) => layer.id === id), + removeLayer: (id: string) => { + style = style.filter((layer) => layer.id !== id); + }, + getSource: (id: string) => (removedSources.includes(id) ? undefined : { id }), + removeSource: (id: string) => removedSources.push(id), + }; + + // The controller's loop when the whole folder goes: no survivors, one layer at a time. + layers.forEach((layer, index) => { + removeLayerFromMap(map as never, layer.id, layer, new Set()); + const last = index === layers.length - 1; + assert.equal( + removedSources.includes("grid"), + last, + last ? "gone with the last one" : "still held by the siblings", + ); + }); + }); + + it("removes the source once nothing is left to draw from it", () => { + const layers = createPMTilesArchiveLayers(archive); + const removedSources: string[] = []; + const map = { + getLayer: () => undefined, + getSource: (id: string) => ({ id }), + removeLayer: () => {}, + removeSource: (id: string) => removedSources.push(id), + getLayersOrder: () => [], + }; + + removeLayerFromMap(map as never, layers[0].id, layers[0], new Set()); + + assert.equal(removedSources.includes("grid"), true); + }); +}); + +// Source layer names overlap in real archives — `water` and `waterway` in the Protomaps basemap — +// and the ids each layer claims decide what the sync path styles, reorders and hides. +describe("splitting an archive whose source layer names overlap", () => { + it("gives each layer only the ids that are its own", () => { + const layers = createPMTilesArchiveLayers({ + id: "a", + name: "overlapping", + url: "https://example.org/a.pmtiles", + tileType: "vector", + sourceLayers: ["water", "waterway"], + nativeLayerIds: [ + "a-water-fill", + "a-water-line", + "a-water-circle", + "a-waterway-fill", + "a-waterway-line", + "a-waterway-circle", + ], + }); + + assert.deepEqual(layers[0].metadata.nativeLayerIds, [ + "a-water-fill", + "a-water-line", + "a-water-circle", + ]); + assert.deepEqual(layers[1].metadata.nativeLayerIds, [ + "a-waterway-fill", + "a-waterway-line", + "a-waterway-circle", + ]); + }); + + // Blend modes resolve a native layer back to its store layer by the ids that layer declares, so + // the same partition that keeps `water` from styling `waterway` keeps it from blending it. + it("blends only its own natives when one source layer is set to blend", () => { + const layers = createPMTilesArchiveLayers({ + id: "a", + name: "overlapping", + url: "https://example.org/a.pmtiles", + tileType: "vector", + sourceLayers: ["water", "waterway"], + nativeLayerIds: [ + "a-water-fill", + "a-water-line", + "a-water-circle", + "a-waterway-fill", + "a-waterway-line", + "a-waterway-circle", + ], + }); + + syncLayerBlendModes( + layers.map((layer) => + layer.name === "water" + ? { ...layer, style: { ...layer.style, blendMode: "multiply" } } + : layer, + ), + ); + + assert.equal(blendModeForNativeLayer("a-water-fill"), "multiply"); + assert.equal(blendModeForNativeLayer("a-waterway-fill"), null, "its neighbour is left alone"); + resetLayerBlendModes(); + }); + + it("derives ids for a layer whose control named none of them, rather than drawing nothing", () => { + const layers = createPMTilesArchiveLayers({ + id: "a", + name: "renamed", + url: "https://example.org/a.pmtiles", + tileType: "vector", + sourceLayers: ["water", "waterway"], + nativeLayerIds: ["some-other-naming-scheme"], + }); + + // The ids `ensurePMTilesExternalLayer` creates: scoped to the archive, not to this layer, so + // deriving them from the layer's own id would name ids nothing on the map answers to. + assert.deepEqual(layers[0].metadata.nativeLayerIds, [ + "a-water-fill", + "a-water-line", + "a-water-circle", + ]); + }); +}); + +// An offline extract's bytes live in a registry keyed by URL, and every layer of a split archive +// names the same URL. Freeing them when the first child goes leaves its siblings resolving tiles +// against a protocol entry that is no longer there. +describe("removing one layer of an archive whose bytes are held in memory", () => { + const offline = (): PMTilesStoreLayerOptions => ({ + ...archive, + url: registerPMTilesArchive("split-extract.pmtiles", new Uint8Array([1, 2, 3])), + sourceLayerColors: undefined, + }); + + it("keeps the archive registered while a sibling still draws from it", () => { + const layers = createPMTilesArchiveLayers(offline()); + const key = layers[0]!.sourcePath!; + const map = { + getLayer: () => undefined, + getSource: (id: string) => ({ id }), + removeLayer: () => {}, + removeSource: () => {}, + getLayersOrder: () => [], + }; + + removeLayerFromMap( + map as never, + layers[0]!.id, + layers[0], + externalSourceIdsFor(layers.slice(1)), + ); + + assert.equal(hasPMTilesArchive(key), true, "its siblings still read these bytes"); + }); + + it("frees the archive once the last layer of it goes", () => { + const layers = createPMTilesArchiveLayers(offline()); + const key = layers[0]!.sourcePath!; + const map = { + getLayer: () => undefined, + getSource: (id: string) => ({ id }), + removeLayer: () => {}, + removeSource: () => {}, + getLayersOrder: () => [], + }; + + for (const [index, layer] of layers.entries()) { + removeLayerFromMap( + map as never, + layer.id, + layer, + externalSourceIdsFor(layers.slice(index + 1)), + ); + } + + assert.equal(hasPMTilesArchive(key), false, "nothing is left holding them"); + }); +}); + +// Nothing points an archive at a source other than its own id today, but the option exists and the +// split path used to ignore it — every part would have named a source nothing on the map answers to. +describe("splitting an archive that draws from someone else's source", () => { + it("gives every part that source, and the ids that go with it", () => { + const layers = createPMTilesArchiveLayers({ + ...archive, + id: "asset-9", + sourceId: "shared-archive", + sourceLayerColors: undefined, + sourceLayers: ["roads", "water"], + nativeLayerIds: ["fill", "line", "circle"].map((kind) => `shared-archive-roads-${kind}`), + }); + + assert.deepEqual( + layers.map((layer) => [layer.id, layer.source.sourceId, layer.metadata.nativeLayerIds]), + [ + [ + "asset-9-roads", + "shared-archive", + ["shared-archive-roads-fill", "shared-archive-roads-line", "shared-archive-roads-circle"], + ], + [ + "asset-9-water", + "shared-archive", + ["shared-archive-water-fill", "shared-archive-water-line", "shared-archive-water-circle"], + ], + ], + "the control's ids for `roads` are kept, and `water` derives its own against the same source", + ); + }); +}); diff --git a/tests/pmtiles-archive-project.test.ts b/tests/pmtiles-archive-project.test.ts new file mode 100644 index 000000000..67d70a050 --- /dev/null +++ b/tests/pmtiles-archive-project.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; +import { applyProjectToStore, parseProject, projectFromStore } from "../packages/core/src/project"; +import { applyGroupEffects } from "../packages/core/src/layer-groups"; +import { useAppStore } from "../packages/core/src/store"; +import { createPMTilesArchiveLayers } from "../packages/map/src/pmtiles-layer"; +import { addPMTilesArchive } from "../packages/plugins/src/plugins/pmtiles-archive-store"; + +/** Save the store the way the app does, then read it back. */ +function roundTrip() { + const project = projectFromStore(useAppStore.getState() as never); + return applyProjectToStore(parseProject(JSON.stringify(project))); +} + +// The archive is split across several layers held together by a folder, and both halves have to +// survive a save: layers whose folder is gone are scrubbed loose on load. +// Hiding the folder hides what is in it. An archive's source layers are external native layers, +// whose visibility reaches the map by a different path than an ordinary layer's, so the folding +// `applyGroupEffects` does upstream of the sync has to survive that path. +describe("hiding the folder an archive sits in", () => { + beforeEach(() => { + const state = useAppStore.getState(); + for (const layer of [...state.layers]) state.removeLayer(layer.id); + for (const group of [...state.layerGroups]) state.removeLayerGroup(group.id); + }); + + it("hides every source layer of it, and shows them again", () => { + addPMTilesArchive( + createPMTilesArchiveLayers({ + id: "grid", + name: "MGRS grid", + url: "https://example.org/mgrs.pmtiles", + tileType: "vector", + sourceLayers: ["gzd", "hundredkm"], + }), + "MGRS grid", + ); + const store = useAppStore.getState(); + const groupId = store.layerGroups[0]!.id; + + store.setLayerGroupVisibility(groupId, false); + const hidden = useAppStore.getState(); + assert.deepEqual( + applyGroupEffects(hidden.layers, hidden.layerGroups).map((layer) => layer.visible), + [false, false], + "the folder's state reaches each source layer", + ); + + useAppStore.getState().setLayerGroupVisibility(groupId, true); + const shown = useAppStore.getState(); + assert.deepEqual( + applyGroupEffects(shown.layers, shown.layerGroups).map((layer) => layer.visible), + [true, true], + "and showing it again brings them all back", + ); + }); +}); + +// Adding an archive writes to the store once per source layer plus once for the folder. Undo +// coalesces changes made in the same tick, so the whole archive is one step back, not ten. +describe("undoing an archive", () => { + beforeEach(() => { + useAppStore.getState().newProject({ name: "undo" }); + useAppStore.temporal.getState().clear(); + }); + + it("takes it back in a single step, folder and all", () => { + addPMTilesArchive( + createPMTilesArchiveLayers({ + id: "grid", + name: "MGRS grid", + url: "https://example.org/mgrs.pmtiles", + tileType: "vector", + sourceLayers: ["gzd", "hundredkm", "labels"], + }), + "MGRS grid", + ); + assert.equal(useAppStore.getState().layers.length, 3); + + useAppStore.temporal.getState().undo(); + + const state = useAppStore.getState(); + assert.deepEqual(state.layers, [], "every source layer went back"); + assert.deepEqual(state.layerGroups, [], "and the folder with them"); + }); +}); + +describe("an archive across a project save and reload", () => { + beforeEach(() => { + const state = useAppStore.getState(); + for (const layer of [...state.layers]) state.removeLayer(layer.id); + for (const group of [...state.layerGroups]) state.removeLayerGroup(group.id); + }); + + it("comes back as the same layers in the same folder", () => { + addPMTilesArchive( + createPMTilesArchiveLayers({ + id: "grid", + name: "MGRS grid", + url: "https://example.org/mgrs.pmtiles", + tileType: "vector", + sourceLayers: ["gzd", "hundredkm", "labels"], + }), + "MGRS grid", + ); + + const reloaded = roundTrip(); + + const groups = reloaded.layerGroups; + assert.equal(groups.length, 1, "the folder survived"); + assert.equal(groups[0]!.name, "MGRS grid"); + assert.deepEqual( + reloaded.layers.map((layer) => layer.name), + ["gzd", "hundredkm", "labels"], + "every source layer survived", + ); + assert.deepEqual( + new Set(reloaded.layers.map((layer) => layer.groupId)), + new Set([groups[0]!.id]), + "and each is still in the folder", + ); + }); + + it("keeps them all drawing from the archive's one source", () => { + addPMTilesArchive( + createPMTilesArchiveLayers({ + id: "grid", + name: "MGRS grid", + url: "https://example.org/mgrs.pmtiles", + tileType: "vector", + sourceLayers: ["gzd", "hundredkm"], + }), + "MGRS grid", + ); + + const reloaded = roundTrip(); + + // Both halves of the id: a reloaded layer naming its own id as the source would draw from a + // source nothing adds, and `removeLayerFromMap` would stop refcounting the shared one. + assert.deepEqual( + new Set(reloaded.layers.map((layer) => layer.metadata.sourceId)), + new Set(["grid"]), + ); + assert.deepEqual( + new Set(reloaded.layers.map((layer) => layer.source.sourceId)), + new Set(["grid"]), + ); + }); +}); diff --git a/tests/pmtiles-control-contract.test.ts b/tests/pmtiles-control-contract.test.ts new file mode 100644 index 000000000..9a12e5aff --- /dev/null +++ b/tests/pmtiles-control-contract.test.ts @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import { gzipSync } from "node:zlib"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { parseHTML } from "linkedom"; +// Imported before any global is swapped: the package pulls in a wasm loader that reads `fetch`. +import { PMTilesLayerControl } from "maplibre-gl-components"; +import { + pmtilesControlLayerId, + pmtilesLayerKinds, + pmtilesVectorLayerId, +} from "../packages/map/src/pmtiles-layer"; +import { useAppStore } from "../packages/core/src/store"; +import { + __getPMTilesControlForTests, + createPMTilesLayerAddHandler, + __resetPMTilesControlForTests, + addPMTilesLayerFromUrl, +} from "../packages/plugins/src/plugins/maplibre-components"; + +/** + * `maplibre-gl-components`' PMTiles control names its MapLibre layers `${sourceId}-${name}-${kind}` + * from the **raw** source-layer name, and reports ids only for the source layers the panel has + * ticked while still reporting the whole archive in `sourceLayers`. Neither is exported, and + * GeoLibre reads both: `layer-sync` matches those ids so it takes the layers already on the map + * rather than adding its own on top, and the bridge reads the selection so the store holds what the + * user ticked. Drift in either is silent, so this drives the real control against a real archive. + */ + +const V3_HEADER_BYTES = 127; +// Byte offsets into the v3 header, from the PMTiles spec. +const ROOT_OFFSET = 8; +const ROOT_LENGTH = 16; +const METADATA_OFFSET = 24; +const METADATA_LENGTH = 32; +const LEAF_OFFSET = 40; +const LEAF_LENGTH = 48; +const TILE_DATA_OFFSET = 56; +const INTERNAL_COMPRESSION = 97; +const TILE_TYPE = 99; + +/** + * A real PMTiles archive whose metadata names `sourceLayers`, built by rewriting the committed + * raster fixture's metadata section and marking it MVT. `_addLayer` reads the header and the + * metadata and never asks for a tile, so no tiles are needed — but the reader it uses is the real + * one, and it rejects anything that is not a valid v3 archive. + */ +function vectorArchive(sourceLayers: string[]): Uint8Array { + const raster = new Uint8Array( + readFileSync(fileURLToPath(new URL("./fixtures/mini.pmtiles", import.meta.url))), + ); + const view = new DataView(raster.buffer, raster.byteOffset, raster.byteLength); + const u64 = (at: number) => Number(view.getBigUint64(at, true)); + // The rebuild below moves the metadata section and slides everything after it, which holds only + // while the tile data starts where the leaf directories end. `gen-pmtiles-fixture.mjs` emits that + // today; a regenerated fixture that does not is rejected here rather than silently corrupted, + // since no tile is ever read back. + assert.equal(new TextDecoder().decode(raster.subarray(0, 7)), "PMTiles", "a v3 archive"); + assert.equal(raster[7], 3, "spec version 3"); + assert.equal( + u64(TILE_DATA_OFFSET), + u64(LEAF_OFFSET) + u64(LEAF_LENGTH), + "no gap before the tiles", + ); + const gzipped = raster[INTERNAL_COMPRESSION] === 2; + assert.ok( + raster[INTERNAL_COMPRESSION] === 1 || gzipped, + "the metadata written below is plain or gzip, matching what the header declares", + ); + + const root = raster.subarray(u64(ROOT_OFFSET), u64(ROOT_OFFSET) + u64(ROOT_LENGTH)); + const json = JSON.stringify({ vector_layers: sourceLayers.map((id) => ({ id })) }); + const metadata = gzipped ? new Uint8Array(gzipSync(json)) : new TextEncoder().encode(json); + // Everything from the leaf directories on is copied verbatim; only its offset moves. + const tail = raster.subarray(u64(LEAF_OFFSET)); + + // `slice`, not `subarray`: this copies, so writing through `header.buffer` cannot reach the + // fixture's bytes. + const header = raster.slice(0, V3_HEADER_BYTES); + const out = new DataView(header.buffer); + const rootAt = V3_HEADER_BYTES; + const metadataAt = rootAt + root.length; + const leafAt = metadataAt + metadata.length; + out.setBigUint64(ROOT_OFFSET, BigInt(rootAt), true); + out.setBigUint64(METADATA_OFFSET, BigInt(metadataAt), true); + out.setBigUint64(METADATA_LENGTH, BigInt(metadata.length), true); + out.setBigUint64(LEAF_OFFSET, BigInt(leafAt), true); + out.setBigUint64(TILE_DATA_OFFSET, BigInt(leafAt + u64(LEAF_LENGTH)), true); + header[TILE_TYPE] = 1; // mvt, so the control takes its vector branch + + const archive = new Uint8Array(leafAt + tail.length); + archive.set(header, 0); + archive.set(root, rootAt); + archive.set(metadata, metadataAt); + archive.set(tail, leafAt); + return archive; +} + +/** + * A `fetch` serving `bytes` with the Range semantics the PMTiles reader needs. Anything that is not + * the archive goes to the real one — the components package initialises a wasm module through + * `fetch` when it loads, and would otherwise be handed PMTiles bytes. + */ +function serve(bytes: Uint8Array): typeof fetch { + const real = globalThis.fetch; + return (async (url, init) => { + if (!new URL(String(url), "https://x.test").pathname.endsWith(".pmtiles")) { + // The components package initialises a wasm module through `fetch` when it loads, from an + // inlined `data:` URL. Anything else would be a real request: refused rather than passed on, + // so a bump that starts fetching from a CDN fails here instead of quietly needing a network. + assert.match(String(url), /^(data|blob):/, "the contract test must not reach the network"); + return real(url as never, init as never); + } + const range = /bytes=(\d+)-(\d+)/.exec(String(new Headers(init?.headers).get("range") ?? "")); + if (!range) { + return new Response(bytes.slice() as unknown as BodyInit, { + status: 200, + headers: { "content-length": String(bytes.length) }, + }); + } + const start = Number(range[1]); + const end = Math.min(Number(range[2]), bytes.length - 1); + return new Response(bytes.slice(start, end + 1) as unknown as BodyInit, { + status: 206, + headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, + }); + }) as typeof fetch; +} + +/** Records what the control drew, and answers anything else it asks of a map. */ +function recordingMap(document: Document): { added: string[]; map: unknown } { + const added: string[] = []; + const map = new Proxy( + { + addLayer: (layer: { id: string }) => added.push(layer.id), + getLayer: () => undefined, + getSource: () => undefined, + getCanvasContainer: () => document.createElement("div"), + } as Record, + { + get: (target, key) => target[key as string] ?? (() => undefined), + }, + ); + return { added, map }; +} + +async function addArchive(sourceLayers: string[], ticked?: string[]) { + const { document, window } = parseHTML(""); + const globals = globalThis as Record; + const restore = { document: globals.document, window: globals.window, fetch: globals.fetch }; + globals.document = document; + // `_ensureProtocol` prefers a `maplibregl` on `window`, which spares this a real map build. + globals.window = Object.assign(window, { maplibregl: { addProtocol: () => {} } }); + globals.fetch = serve(vectorArchive(sourceLayers)); + try { + const control = new PMTilesLayerControl({}) as unknown as { + onAdd: (map: unknown) => unknown; + addLayer: (url: string) => Promise; + getState: () => { + layers: { id: string; sourceLayers: string[]; layerIds: string[] }[]; + error: string | null; + }; + _state: { selectedSourceLayers: string[] }; + }; + const { added, map } = recordingMap(document as unknown as Document); + control.onAdd(map); + // The panel's checkboxes write here. Reached directly rather than through a rendered click, so + // that a rename of the field fails this test rather than quietly leaving nothing ticked. + if (ticked) control._state.selectedSourceLayers = ticked; + await control.addLayer(`https://example.test/${sourceLayers.join("-")}.pmtiles`); + const state = control.getState(); + // `_addLayer` swallows every failure into `state.error`, so without this a broken fixture, a + // refused fetch or a changed reader reads as "the id scheme moved" via a `TypeError` below. + assert.equal(state.error, null, "the control loaded the archive"); + return { info: state.layers[0]!, added }; + } finally { + globals.document = restore.document; + globals.window = restore.window; + globals.fetch = restore.fetch; + } +} + +describe("the id scheme the PMTiles control reports", () => { + it("names its layers after the raw source layer, not the encoded one", async () => { + const name = "zones residentielles"; + const { info, added } = await addArchive(["roads", name]); + + for (const kind of pmtilesLayerKinds) { + assert.ok( + info.layerIds.includes(pmtilesControlLayerId(info.id, name, kind)), + `the control still names ${kind} ids \`\${sourceId}-\${rawName}-\${kind}\``, + ); + assert.ok( + !info.layerIds.includes(pmtilesVectorLayerId(info.id, name, kind)), + "and still does not encode the name, which is why both schemes are matched", + ); + } + assert.deepEqual(added, info.layerIds, "and it draws exactly the ids it reports"); + }); + + // `pmtilesLayerOptions` reads the same split: every source layer in `sourceLayers`, and ids for + // the ticked ones alone. + it("reports the whole archive but names ids only for the ticked source layers", async () => { + const { info } = await addArchive(["roads", "water"], ["roads"]); + + assert.deepEqual(info.sourceLayers, ["roads", "water"], "the whole archive is reported"); + assert.deepEqual( + info.layerIds, + pmtilesLayerKinds.map((kind) => pmtilesControlLayerId(info.id, "roads", kind)), + "but only the ticked source layer is named", + ); + }); +}); + +/** + * A panel add, end to end: the real control's own `layeradd` through the real handler into the + * store. What the user ticked reaches the store as a field of the emitted state, so a rename or a + * reset of that field fails here rather than silently putting the whole archive in. + */ +describe("adding an archive through the panel with a source layer unticked", () => { + it("puts only the ticked source layer in the store", async () => { + const { document, window } = parseHTML(""); + const globals = globalThis as Record; + const restore = { document: globals.document, window: globals.window, fetch: globals.fetch }; + globals.document = document; + globals.window = Object.assign(window, { maplibregl: { addProtocol: () => {} } }); + globals.fetch = serve(vectorArchive(["roads", "water"])); + const store = useAppStore.getState(); + for (const layer of [...store.layers]) store.removeLayer(layer.id); + for (const group of [...store.layerGroups]) store.removeLayerGroup(group.id); + __resetPMTilesControlForTests(); + try { + const control = new PMTilesLayerControl({}) as unknown as { + onAdd: (map: unknown) => unknown; + on: (event: string, handler: unknown) => unknown; + addLayer: (url: string) => Promise; + _state: { selectedSourceLayers: string[] }; + }; + const { map } = recordingMap(document as unknown as Document); + control.onAdd(map); + control.on("layeradd", createPMTilesLayerAddHandler()); + // The panel's checkboxes write here; reached directly because only they do. + control._state.selectedSourceLayers = ["water"]; + + await control.addLayer("https://example.test/panel.pmtiles"); + + const layers = useAppStore.getState().layers; + assert.deepEqual( + layers.map((layer) => layer.source.sourceLayers), + [["water"]], + "`roads` was unticked, so it is not the project's", + ); + // `_emit` spreads the control's whole state, so a write to a renamed field would still reach + // the handler and this would pass on `sourceLayers` alone. The ids are what the control + // actually drew: under a rename it draws both source layers and reports six of them. + assert.deepEqual(layers[0]!.metadata.nativeLayerIds, [ + "pmtiles-source-0-water-fill", + "pmtiles-source-0-water-line", + "pmtiles-source-0-water-circle", + ]); + } finally { + globals.document = restore.document; + globals.window = restore.window; + globals.fetch = restore.fetch; + __resetPMTilesControlForTests(); + } + }); +}); + +/** + * The URL add path, end to end against the real control: Add Data, Source Cooperative and Hugging + * Face all reach `addPMTilesLayerFromUrl`, and none of them shows a tick UI. + */ +describe("adding an archive by URL while the panel holds a stale tick", () => { + it("puts the whole archive in the store, not just what was ticked for another one", async () => { + const { document, window } = parseHTML(""); + const globals = globalThis as Record; + const restore = { + document: globals.document, + window: globals.window, + fetch: globals.fetch, + requestAnimationFrame: globals.requestAnimationFrame, + }; + globals.document = document; + globals.window = Object.assign(window, { maplibregl: { addProtocol: () => {} } }); + globals.fetch = serve(vectorArchive(["roads", "buildings"])); + globals.requestAnimationFrame = (callback: () => void) => setTimeout(callback, 0); + const store = useAppStore.getState(); + for (const layer of [...store.layers]) store.removeLayer(layer.id); + __resetPMTilesControlForTests(); + try { + const { map } = recordingMap(document as unknown as Document); + const app = { + addMapControl: (control: { onAdd: (map: unknown) => unknown }) => { + control.onAdd(map); + return true; + }, + } as never; + + await addPMTilesLayerFromUrl(app, "https://example.test/by-url.pmtiles"); + // What the panel would hold after the user unticked a source layer of some other archive. + // Reached directly because only the panel's own checkboxes write it. + ( + __getPMTilesControlForTests() as { _state: { selectedSourceLayers: string[] } } + )._state.selectedSourceLayers = ["roads"]; + // A query string, the shape a presigned URL takes: `programmaticPMTilesAdds` is keyed on this + // exact string, so a control that trimmed or rewrote it before echoing it back would let the + // stale tick above through and strand `buildings` outside the store. + await addPMTilesLayerFromUrl(app, "https://example.test/by-url-2.pmtiles?sig=abc%2F123"); + + assert.deepEqual( + useAppStore + .getState() + .layers.filter((layer) => layer.metadata.sourceId === "pmtiles-source-1") + .map((layer) => layer.name), + ["roads", "buildings"], + "`buildings` is in the project even though the stale tick kept it off the map", + ); + } finally { + globals.document = restore.document; + globals.window = restore.window; + globals.fetch = restore.fetch; + globals.requestAnimationFrame = restore.requestAnimationFrame; + __resetPMTilesControlForTests(); + } + }); +}); diff --git a/tests/pmtiles-control-layer.test.ts b/tests/pmtiles-control-layer.test.ts index 4a33be4c3..ad0da1f11 100644 --- a/tests/pmtiles-control-layer.test.ts +++ b/tests/pmtiles-control-layer.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { PMTilesLayerInfo } from "maplibre-gl-components"; import { isPlaceholderLayer } from "../packages/map/src/placeholders"; -import { pmtilesStoreLayer } from "../packages/plugins/src/plugins/maplibre-components"; +import { pmtilesStoreLayers } from "../packages/plugins/src/plugins/maplibre-components"; /** What the PMTiles control reports for an archive it has just loaded. */ function controlLayer(patch: Partial = {}): PMTilesLayerInfo { @@ -12,20 +12,36 @@ function controlLayer(patch: Partial = {}): PMTilesLayerInfo { name: "", tileType: "vector", sourceLayers: ["units"], - layerIds: ["control-fill", "control-line"], + // Built the way the real control builds them: `${sourceId}-${rawName}-${kind}` for what it drew. + layerIds: ["pmtiles-1-units-fill", "pmtiles-1-units-line", "pmtiles-1-units-circle"], opacity: 0.8, pickable: true, ...patch, }; } +/** + * The one layer an archive of a single source layer produces. An archive holding several is split + * into a layer each, which `pmtiles-archive-layers.test.ts` covers. + */ +function pmtilesStoreLayer(id: string, info: PMTilesLayerInfo) { + // Nothing ticked, so the whole archive: what the panel reports before a user touches it. + const layers = pmtilesStoreLayers(id, info, []); + assert.equal(layers.length, 1); + return layers[0]!; +} + describe("the store layer the PMTiles control's layeradd produces", () => { it("renders rather than placeholding, on the control's own layer ids", () => { const layer = pmtilesStoreLayer("pmtiles-1", controlLayer()); assert.equal(isPlaceholderLayer(layer), false); - // The control made these; deriving ids here would name layers that do not exist. - assert.deepEqual(layer.metadata.nativeLayerIds, ["control-fill", "control-line"]); + // The control made these; deriving a second set here would draw over the layers it added. + assert.deepEqual(layer.metadata.nativeLayerIds, [ + "pmtiles-1-units-fill", + "pmtiles-1-units-line", + "pmtiles-1-units-circle", + ]); assert.equal(layer.opacity, 0.8); }); @@ -52,8 +68,8 @@ describe("the store layer the PMTiles control's layeradd produces", () => { controlLayer({ sourceLayerColors: { units: "#ff0000" } }), ); + // The colour lands in the layer's own style, which is the only thing that paints it. assert.equal(layer.style.fillColor, "#ff0000"); - assert.deepEqual(layer.metadata.sourceLayerColors, { units: "#ff0000" }); }); it("carries a picking opt-out through, rather than assuming the default", () => { diff --git a/tests/pmtiles-control-removal.test.ts b/tests/pmtiles-control-removal.test.ts new file mode 100644 index 000000000..d1f064900 --- /dev/null +++ b/tests/pmtiles-control-removal.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { GeoLibreLayer } from "../packages/core/src/types"; +import { createPMTilesArchiveLayers } from "../packages/map/src/pmtiles-layer"; +import { + pmtilesArchivesFullyRemoved, + pmtilesLayerIdsToRemove, +} from "../packages/plugins/src/plugins/maplibre-components"; + +/** An archive added without the control — a STAC asset, a basemap extract. */ +function archive(id: string, sourceLayers: string[]): GeoLibreLayer[] { + return createPMTilesArchiveLayers({ + id, + name: id, + url: `https://example.org/${id}.pmtiles`, + tileType: "vector", + sourceLayers, + }); +} + +/** The archives the control added this session, which are the only ones it may remove. */ +function owned(layers: readonly GeoLibreLayer[]): Set { + return new Set( + layers + .map((layer) => layer.metadata.controlArchiveId) + .filter((id): id is string => typeof id === "string"), + ); +} + +/** The same, as the control adds it: marked with the id the control knows the archive by. */ +function controlArchive(id: string, sourceLayers: string[]): GeoLibreLayer[] { + return archive(id, sourceLayers).map((layer) => ({ + ...layer, + metadata: { ...layer.metadata, controlArchiveId: id }, + })); +} + +// The control knows an archive by one id; the store holds a layer per source layer, each named +// after that source layer. Matching the two on layer id silently does the wrong thing both ways. +describe("removing an archive the control has dropped", () => { + it("takes every layer split out of it", () => { + const layers = [ + ...controlArchive("a", ["roads", "water"]), + ...controlArchive("b", ["parcels", "zoning"]), + ]; + + const removed = pmtilesLayerIdsToRemove( + layers, + { layerId: "a", state: { layers: [] } }, + owned(layers), + ); + + assert.deepEqual(removed, ["a-roads", "a-water"]); + }); + + it("keeps the layers of an archive the control still lists", () => { + const layers = [ + ...controlArchive("a", ["roads", "water"]), + ...controlArchive("b", ["parcels", "zoning"]), + ]; + + // No layerId: the control is reporting which archives it still has, and "a" is one of them. + const removed = pmtilesLayerIdsToRemove( + layers, + { state: { layers: [{ id: "a" }] } }, + owned(layers), + ); + + assert.deepEqual(removed, ["b-parcels", "b-zoning"], "only the archive it dropped"); + }); +}); + +// A STAC asset and a basemap extract build the same kind of layer without going through the +// control, so neither is in its list — which the "everything I still have" branch reads as dropped. +describe("an archive the control never added", () => { + it("survives the control reporting what it still has", () => { + const mine = archive("from-stac", ["faults", "folds"]); + const controls = controlArchive("a", ["roads"]); + + const removed = pmtilesLayerIdsToRemove( + [...mine, ...controls], + { state: { layers: [] } }, + new Set(["a"]), + ); + + assert.deepEqual(removed, ["a"], "only the control's own"); + }); + + it("is never reported back to the control as gone", () => { + const mine = archive("from-stac", ["faults", "folds"]); + + assert.deepEqual(pmtilesArchivesFullyRemoved(mine, [], owned(mine)), []); + }); +}); + +// A saved project keeps `controlArchiveId`, but a reloaded one is drawn by `syncLayers` with the +// control holding nothing — so the mark is not a claim, and clear-all must not act on it. +describe("an archive reloaded from a saved project", () => { + it("is not the control's to remove, mark or no mark", () => { + const reloaded = controlArchive("a", ["roads", "water"]); + + const removed = pmtilesLayerIdsToRemove(reloaded, { state: { layers: [] } }, new Set()); + + assert.deepEqual(removed, [], "nothing was added by this session's control"); + }); +}); + +// Closing the panel drops the claims; the mark on the layers survives, in the project file too. A +// control that never loaded an archive must not be told it has lost one. +describe("telling a control about an archive it never loaded", () => { + it("says nothing, mark or no mark", () => { + const reloaded = controlArchive("a", ["roads", "water"]); + + assert.deepEqual(pmtilesArchivesFullyRemoved(reloaded, [], new Set()), []); + }); +}); + +describe("telling the control an archive is gone", () => { + it("says nothing while other source layers of it are still on the map", () => { + const layers = controlArchive("a", ["roads", "water"]); + + const gone = pmtilesArchivesFullyRemoved(layers, [layers[1]!], owned(layers)); + + assert.deepEqual(gone, [], "'roads' left, but the archive is still drawing 'water'"); + }); + + it("names the archive, not the layer, once its last source layer goes", () => { + const layers = controlArchive("a", ["roads", "water"]); + + const gone = pmtilesArchivesFullyRemoved(layers, [], owned(layers)); + + assert.deepEqual(gone, ["a"]); + }); +}); diff --git a/tests/pmtiles-layer-sync.test.ts b/tests/pmtiles-layer-sync.test.ts index 889c793a3..e28fb252a 100644 --- a/tests/pmtiles-layer-sync.test.ts +++ b/tests/pmtiles-layer-sync.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { syncLayer } from "../packages/map/src/layer-sync"; import { + createPMTilesArchiveLayers, createPMTilesStoreLayer, type PMTilesStoreLayerOptions, } from "../packages/map/src/pmtiles-layer"; @@ -120,3 +121,87 @@ describe("syncing a layer from createPMTilesStoreLayer", () => { ]); }); }); + +// An archive the PMTiles control added and GeoLibre kept whole carries the ids the *control* built, +// which spell the source layer's name raw where this package percent-encodes it. Recognising only +// the encoded form, the sync decides the source layer has no native layer and adds a second trio on +// top of the control's — drawn twice, and only the control's copy answers the panel. +describe("syncing a layer that carries the PMTiles control's own ids", () => { + it("draws under the control's ids for a name it spells differently", () => { + const sourceLayer = "zones residentielles"; + const layer = createPMTilesStoreLayer({ + ...archive, + sourceLayers: [sourceLayer], + nativeLayerIds: ["fill", "line", "circle"].map((kind) => `layer-1-${sourceLayer}-${kind}`), + }); + const { map, addedLayers } = makeMapStub(); + + syncLayer(map as never, layer); + + assert.deepEqual( + addedLayers.map((added) => added.id), + ["fill", "line", "circle"].map((kind) => `layer-1-${sourceLayer}-${kind}`), + "no encoded second set beside the control's", + ); + }); + + it("still derives encoded ids when the layer carries none of its own", () => { + const sourceLayer = "zones residentielles"; + const layer = createPMTilesStoreLayer({ ...archive, sourceLayers: [sourceLayer] }); + const { map, addedLayers } = makeMapStub(); + + syncLayer(map as never, layer); + + assert.deepEqual( + addedLayers.map((added) => added.id), + ["fill", "line", "circle"].map((kind) => `layer-1-zones_20residentielles-${kind}`), + ); + }); +}); + +// A split archive draws from the same MapLibre layers the control made, so each part must carry the +// ids that name *its* source layer. Deriving encoded ones instead puts a second trio on top of the +// control's, and only the control's copy answers the panel. +describe("syncing one part of a split archive the control added", () => { + it("draws under the control's ids for the source layer that part holds", () => { + const names = ["roads", "zones residentielles"]; + const parts = createPMTilesArchiveLayers({ + ...archive, + sourceLayers: names, + nativeLayerIds: names.flatMap((name) => + ["fill", "line", "circle"].map((kind) => `layer-1-${name}-${kind}`), + ), + }); + assert.equal(parts.length, 2, "the archive did split"); + + const added = parts.flatMap((part) => { + const { map, addedLayers } = makeMapStub(); + syncLayer(map as never, part); + return addedLayers.map((layer) => layer.id); + }); + + assert.deepEqual( + added, + names.flatMap((name) => ["fill", "line", "circle"].map((kind) => `layer-1-${name}-${kind}`)), + "each part took the control's own ids, and none derived an encoded second set", + ); + }); + + it("derives ids for a part the control never drew", () => { + const parts = createPMTilesArchiveLayers({ + ...archive, + sourceLayers: ["roads", "water"], + nativeLayerIds: ["fill", "line", "circle"].map((kind) => `layer-1-roads-${kind}`), + }); + const water = parts.find((part) => part.name === "water")!; + const { map, addedLayers } = makeMapStub(); + + syncLayer(map as never, water); + + assert.deepEqual( + addedLayers.map((layer) => layer.id), + ["fill", "line", "circle"].map((kind) => `layer-1-water-${kind}`), + "derived from the archive's source, not borrowed from `roads`", + ); + }); +});