diff --git a/CLAUDE.md b/CLAUDE.md index 010438d39..89df5e4d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,7 @@ The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); conf - `MAX_VECTOR_BYTES` (`packages/plugins/src/plugins/remote-file-formats.ts`) mirrors `MAX_REMOTE_FILE_BYTES`, an **internal, unexported** constant in `maplibre-gl-vector` (2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported, so whenever `maplibre-gl-vector` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check `src/lib/utils/remote.ts` in that package and update the mirror if it moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face) silently block GeoParquet the engine could now open, or offer an Add that is certain to fail. Updating the constant is enough: the limit the user is shown is rendered from it, not written into the copy. `remote-file-formats.ts` is the **single** home for this and the other format/reader/size rules those panels share — a per-panel copy would miss this check, so add new browse panels against that module rather than duplicating it (`source-coop-api.ts` re-exports it under its own names for compatibility). - `MAP_PANEL_SELECTOR` (`apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx`) mirrors the **rendered** control class names from `maplibre-gl-components` — `maplibre-gl-html-control`, `maplibre-gl-legend`, `maplibre-gl-colorbar` — so the Record Video "Include map panels" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately **not** the `*-gui-control` authoring editors. The classes are internal and unexported, so whenever `maplibre-gl-components` is bumped (in `packages/plugins/package.json`) — including Dependabot PRs — re-check them against the rendered controls and update the selector if they moved. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error. - `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. - `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. diff --git a/apps/geolibre-desktop/src/index.css b/apps/geolibre-desktop/src/index.css index 4f6468559..e129f825d 100644 --- a/apps/geolibre-desktop/src/index.css +++ b/apps/geolibre-desktop/src/index.css @@ -2691,6 +2691,31 @@ body, onto the app design tokens when the app is in dark mode. Scoped to the package's own root/child classes, mirroring the STAC `.pc-control-*` block above. Never edit the package's stylesheet in node_modules. */ +.basemap-control-result:has(.geolibre-basemap-thumbnail), +.basemap-control-result[data-geolibre-basemap-preview="pending"] { + grid-template-columns: 56px 1fr auto; +} + +.basemap-control-result[data-geolibre-basemap-preview="pending"]:not( + :has(.geolibre-basemap-thumbnail) +)::before { + content: ""; + width: 56px; + height: 42px; + border-radius: 3px; + background: hsl(var(--muted)); +} + +.geolibre-basemap-thumbnail { + grid-row: 1 / span 3; + width: 56px; + height: 42px; + object-fit: cover; + border-radius: 3px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--muted)); +} + .dark .basemap-control { background: hsl(var(--popover)); color: hsl(var(--popover-foreground)); diff --git a/packages/plugins/src/plugins/basemap-thumbnails.ts b/packages/plugins/src/plugins/basemap-thumbnails.ts new file mode 100644 index 000000000..2b18d2ae4 --- /dev/null +++ b/packages/plugins/src/plugins/basemap-thumbnails.ts @@ -0,0 +1,472 @@ +import type { BasemapControl, BasemapDefinition } from "maplibre-gl-basemap-control"; +import { Map as MapLibreMap } from "maplibre-gl"; + +/** + * The panel class, row class and row id attribute below mirror the DOM + * `maplibre-gl-basemap-control` renders (`_createPanel`/`_renderResults`). None + * of them are exported or typed by that package — only `BasemapControl` and + * `BasemapDefinition` are — so a rename on a version bump does not fail the + * build: the queries simply stop matching and thumbnails silently stop + * appearing. Re-check them whenever `maplibre-gl-basemap-control` is bumped in + * `packages/plugins/package.json`, including Dependabot PRs; + * `tests/basemap-thumbnails.test.ts` builds a real control and fails if they + * drift. Same convention as `GLOBE_CONTROL_TOGGLE_SELECTOR` and + * `MAP_PANEL_SELECTOR`. + */ +export const BASEMAP_PANEL_SELECTOR = ".basemap-control-panel"; +export const BASEMAP_ROW_SELECTOR = ".basemap-control-result"; +export const BASEMAP_ROW_ID_ATTR = "data-basemap-id"; + +const ATTR = "data-geolibre-basemap-preview"; +/** + * How long a basemap switch keeps the preview map off the GPU. The caller has + * no completion signal for the style swap, so the gate reopens on its own + * rather than stranding every row that is waiting for a snapshot. + */ +const PAUSE_MS = 1500; +/** + * Caps both preview paths. A host that accepts the connection but never + * completes the response would otherwise leave the swatch promise unsettled and + * the row on its placeholder for good — the snapshot path has always had this, + * and the fetch needs it for the same reason. + */ +const PREVIEW_TIMEOUT_MS = 6000; +const STATE_RANK: Record = { skip: 0, pending: 1, ready: 2, loaded: 3 }; + +/** + * Whether a row currently showing `current` may be repainted as `next`. + * + * The flat-colour swatch and the real render race independently, and + * `snapCache` outlives dispose(), so a reopened panel can paint "loaded" before + * a slow swatch fetch settles. A row only ever moves forward. An unrecognized + * state is treated as advancing, so a future state cannot silently freeze a row. + */ +export function advances(current: string | null, next: string): boolean { + if (!current) return true; + return !(STATE_RANK[current] >= STATE_RANK[next]); +} +const swatchCache = new Map>(); +const snapCache = new Map(); + +/** The placeholders `rasterPreviewUrl` substitutes below. */ +const SUBSTITUTED_TOKEN = /^\{[zxys]\}$/; + +/** + * Reject a template that still carries a placeholder nothing has filled in. + * + * `maplibre-gl-basemap-control` substitutes the user's credentials into + * provider URLs before it loads a basemap (`{api-key}` and `{aws-region}` in + * the catalog it ships today — `API_KEY_PLACEHOLDER`/`AWS_REGION_PLACEHOLDER` + * in that package). Mirroring that list would silently miss a new provider's + * placeholder and fetch a URL with the literal token still in it, so match the + * complement instead: anything other than the tile coordinates this module + * itself resolves counts as unresolved. That also covers raster templates whose + * scheme is not filled in here (`{quadkey}`, `{-y}`, ...), which would render a + * broken tile rather than a preview. + */ +function hasUnresolvedPlaceholder(value: string, substituted?: RegExp): boolean { + return (value.match(/\{[^{}]*\}/g) ?? []).some((token) => !substituted?.test(token)); +} + +/** The zoom every raster preview tile is sampled at. */ +const PREVIEW_Z = 2; + +export function rasterPreviewUrl(basemap: BasemapDefinition): string | null { + if (basemap.source.type !== "raster" || !basemap.source.tiles?.[0]) return null; + const template = basemap.source.tiles[0]; + if (hasUnresolvedPlaceholder(template, SUBSTITUTED_TOKEN)) return null; + // A `tms` source numbers rows from the bottom (MapLibre flips `{y}` for it), + // so an xyz row index would fetch the vertically mirrored tile — a different + // part of the world than the basemap actually renders there. + const y = basemap.source.scheme === "tms" ? 2 ** PREVIEW_Z - 1 - 1 : 1; + return template + .replace(/\{z\}/g, String(PREVIEW_Z)) + .replace(/\{x\}/g, "1") + .replace(/\{y\}/g, String(y)) + .replace(/\{s\}/g, "a"); +} + +export function styleUrlOf(basemap: BasemapDefinition): string | null { + if (basemap.source.type !== "style" && basemap.source.type !== "vector-style") return null; + // Nothing substitutes a token in a style URL — it is fetched verbatim — so + // even the tile tokens rasterPreviewUrl fills in make it unusable here. + return hasUnresolvedPlaceholder(basemap.source.url) ? null : basemap.source.url; +} + +function styleSwatch(url: string): Promise { + const hit = swatchCache.get(url); + if (hit) return hit; + const next = fetch(url, { signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) }) + .then((response) => (response.ok ? response.json() : Promise.reject(response.status))) + .then((style: { layers?: Array<{ type?: string; paint?: Record }> }) => { + const canvas = document.createElement("canvas"); + canvas.width = 112; + canvas.height = 84; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const color = style.layers?.find((layer) => layer.type === "background")?.paint?.[ + "background-color" + ]; + ctx.fillStyle = typeof color === "string" ? color : "#d0d5dd"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return canvas.toDataURL("image/png"); + }) + .catch(() => null); + swatchCache.set(url, next); + // A transient fetch failure must not be cached for the lifetime of the page, + // or the row keeps its placeholder and never retries on a later panel open. + void next.then((src) => { + if (src === null && swatchCache.get(url) === next) swatchCache.delete(url); + }); + return next; +} + +function createStyleCamera(): { + snapshot(url: string): Promise; + pause(): void; + dispose(): void; +} { + let hidden: { map: MapLibreMap; el: HTMLDivElement } | null = null; + let tail = Promise.resolve(); + let disposed = false; + // Closed while the main map applies a new style. Queued jobs wait on it + // rather than resolving null, so their rows still get a thumbnail once it + // reopens — a synchronous flag would be back to `false` before any job, which + // all run in a later microtask off `tail`, could ever observe it. + let gate: Promise = Promise.resolve(); + let openGate: (() => void) | null = null; + let resumeTimer = 0; + /** + * Snapshots already queued, keyed by url. `snapCache` only fills in once a + * job has resolved, so without this a second request for the same style + * before the first settles would queue a redundant full style load — the same + * reuse `styleSwatch` gets from caching its in-flight promise. + */ + const inFlight = new Map>(); + /** Settles the job that is already waiting on the hidden map, if any. */ + let cancelInFlight: (() => void) | null = null; + + const resume = () => { + window.clearTimeout(resumeTimer); + resumeTimer = 0; + openGate?.(); + openGate = null; + }; + + const teardown = () => { + // A job already past the gate has its listeners on the map about to be + // removed, so `style.load` can never fire for it: without this it could + // only settle through the 6s timeout, and since jobs are serialized that + // would stall every later preview well past PAUSE_MS. + cancelInFlight?.(); + hidden?.map.remove(); + hidden?.el.remove(); + hidden = null; + }; + + const ensure = () => { + if (hidden) return hidden; + const el = document.createElement("div"); + el.style.cssText = + "position:fixed;top:0;left:0;width:168px;height:126px;opacity:0;pointer-events:none;z-index:-1"; + document.body.append(el); + hidden = { + el, + map: new MapLibreMap({ + container: el, + style: { version: 8, sources: {}, layers: [] }, + center: [8, 47], + zoom: 2, + interactive: false, + attributionControl: false, + fadeDuration: 0, + pixelRatio: 1, + canvasContextAttributes: { preserveDrawingBuffer: true }, + }), + }; + return hidden; + }; + + return { + snapshot(url) { + const cached = snapCache.get(url); + if (cached) return Promise.resolve(cached); + const queued = inFlight.get(url); + if (queued) return queued; + const job = async (): Promise => { + await gate; + if (disposed) return null; + // `ensure()` constructs a MapLibre map, which throws when a WebGL + // context cannot be created. Every other failure here resolves to null; + // letting this one reject would surface as an unhandled rejection + // instead of the row falling back to name-only. + try { + return await capture(); + } catch { + return null; + } + }; + const capture = () => + new Promise((resolve) => { + const { map } = ensure(); + let settled = false; + let loaded = false; + let captureTimer = 0; + const finish = (src: string | null) => { + if (settled) return; + settled = true; + if (cancelInFlight === cancel) cancelInFlight = null; + window.clearTimeout(timer); + window.clearTimeout(captureTimer); + map.off("style.load", onLoad); + map.off("error", onError); + resolve(src); + }; + const cancel = () => finish(null); + const timer = window.setTimeout(cancel, PREVIEW_TIMEOUT_MS); + // An unreachable or invalid style URL emits `error` and never fires + // `style.load`. Without this the job would hold the serialized queue + // for the full timeout, delaying every later preview by 6s. Errors + // raised *after* the style loaded are individual tile failures — the + // render is still worth capturing, so they are ignored. + const onError = () => { + if (!loaded) finish(null); + }; + const onLoad = () => { + loaded = true; + captureTimer = window.setTimeout(() => { + try { + const src = map.getCanvas().toDataURL("image/jpeg", 0.72); + if (src) snapCache.set(url, src); + finish(src); + } catch { + finish(null); + } + }, 400); + }; + map.once("style.load", onLoad); + map.on("error", onError); + cancelInFlight = cancel; + map.setStyle(url, { diff: false }); + }); + const next = tail.then(job, job); + tail = next.then( + () => undefined, + () => undefined, + ); + inFlight.set(url, next); + void next.then(() => { + if (inFlight.get(url) === next) inFlight.delete(url); + }); + return next; + }, + pause() { + if (disposed) return; + teardown(); + if (!openGate) { + gate = new Promise((resolveGate) => { + openGate = resolveGate; + }); + } + window.clearTimeout(resumeTimer); + resumeTimer = window.setTimeout(resume, PAUSE_MS); + }, + dispose() { + disposed = true; + // Release anything waiting on the gate so it can observe `disposed` and + // bail instead of resurrecting the hidden map after teardown. + resume(); + teardown(); + }, + }; +} + +function stamp(row: HTMLElement, src: string): void { + const existing = row.querySelector(".geolibre-basemap-thumbnail"); + if (existing) { + existing.src = src; + return; + } + const img = document.createElement("img"); + img.className = "geolibre-basemap-thumbnail"; + img.alt = ""; + // The guessed z=2 raster tile can 404 — a source whose minzoom is deeper, a + // dead endpoint, hotlink protection. Drop the image so the row falls back to + // the name-only layout instead of showing the browser's broken-image glyph, + // and mark the row skipped so the CSS placeholder does not linger either. + img.addEventListener("error", () => { + img.remove(); + row.setAttribute(ATTR, "skip"); + }); + img.src = src; + row.prepend(img); +} + +function rowSelector(id: string): string { + return `${BASEMAP_ROW_SELECTOR}[${BASEMAP_ROW_ID_ATTR}="${CSS.escape(id)}"]`; +} + +function apply(row: HTMLElement, src: string, state: string): void { + if (!advances(row.getAttribute(ATTR), state)) return; + row.setAttribute(ATTR, state); + stamp(row, src); +} + +/** Repaint by id, for a preview that resolved long after the row was scanned. */ +function paint(id: string, src: string, state: string): void { + document.querySelectorAll(rowSelector(id)).forEach((row) => apply(row, src, state)); +} + +/** + * Drop a row back to the name-only layout when no preview could be produced — + * an unreachable style host, CORS, or the snapshot timeout. Without this the + * row keeps its "pending" placeholder for good, where a failed *raster* tile + * already degrades this way through `stamp`'s error handler. Rows that did get + * an image are left alone, and a later snapshot can still upgrade a skipped row + * because "loaded" outranks "skip". + */ +function markSkipped(id: string): void { + document.querySelectorAll(rowSelector(id)).forEach((row) => { + if (row.querySelector(".geolibre-basemap-thumbnail")) return; + row.setAttribute(ATTR, "skip"); + }); +} + +export function installBasemapThumbnails(control: BasemapControl): { + dispose(): void; + pause(): void; +} { + const noop = { dispose() {}, pause() {} }; + if (typeof window === "undefined" || !document.body) return noop; + + const camera = createStyleCamera(); + // Cleared by dispose() so an in-flight preview cannot repaint rows belonging + // to a control the plugin has already torn down. + let active = true; + let panel: HTMLElement | null = null; + let visible: IntersectionObserver | null = null; + + /** + * Run the preview a row was prepared for. Every request a preview makes — + * the raster tile, the style JSON and the full snapshot alike — is deferred + * to here, so opening the panel contacts only the providers whose rows are + * actually on screen rather than every host in the catalog at once. + */ + function preview(row: HTMLElement): void { + const url = row.dataset.previewUrl; + const id = row.getAttribute(BASEMAP_ROW_ID_ATTR); + if (!url || !id) return; + if (row.dataset.previewKind === "raster") { + // The tile URL is ready to show, and the row is in hand — no need to go + // back through the document for it. + apply(row, url, "ready"); + return; + } + // The swatch is a flat background colour from one small JSON fetch; the + // snapshot is a real render. Both start here, and the rank in `paint` + // settles which one the row ends up showing. + void styleSwatch(url).then((src) => { + if (!active) return; + if (src) paint(id, src, "ready"); + else markSkipped(id); + }); + void camera.snapshot(url).then((src) => { + if (!active) return; + if (src) paint(id, src, "loaded"); + else markSkipped(id); + }); + } + + function onVisible(entries: IntersectionObserverEntry[]): void { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const row = entry.target as HTMLElement; + visible?.unobserve(row); + preview(row); + } + } + + function enhance(): void { + if (!panel) return; + const catalog = control.getBasemaps(); + panel.querySelectorAll(`${BASEMAP_ROW_SELECTOR}:not([${ATTR}])`).forEach((row) => { + const id = row.getAttribute(BASEMAP_ROW_ID_ATTR); + const basemap = id ? catalog.find((item) => item.id === id) : undefined; + const raster = basemap ? rasterPreviewUrl(basemap) : null; + const jsonUrl = !raster && basemap ? styleUrlOf(basemap) : null; + const url = raster ?? jsonUrl; + if (!url) { + row.setAttribute(ATTR, "skip"); + return; + } + row.dataset.previewUrl = url; + row.dataset.previewKind = raster ? "raster" : "style"; + row.setAttribute(ATTR, "pending"); + // Without an IntersectionObserver there is nothing to defer to, so fall + // back to previewing every row as it is found. + if (visible) visible.observe(row); + else preview(row); + }); + } + + // Every scan is scoped to the control's own panel: rooting them at + // `document.body` would rebuild the catalog and re-scan the whole document on + // every unrelated UI mutation, and would make the IntersectionObserver treat + // rows scrolled out of the panel as visible. + const scoped = new MutationObserver(() => enhance()); + + function attach(): void { + const found = document.querySelector(BASEMAP_PANEL_SELECTOR); + if (found === panel) return; + panel = found; + scoped.disconnect(); + visible?.disconnect(); + visible = null; + watchForPanel(); + if (!panel) return; + scoped.observe(panel, { childList: true, subtree: true }); + if (typeof IntersectionObserver === "function") { + visible = new IntersectionObserver(onVisible, { root: panel, rootMargin: "80px" }); + } + enhance(); + } + + // The control builds a fresh panel element in every `onAdd`, so the one this + // watched can be replaced (a position change) or not exist yet. This callback + // costs a single `isConnected` check per mutation batch, and only then falls + // back to a lookup. + const bootstrap = new MutationObserver(() => { + if (!panel?.isConnected) attach(); + }); + + /** + * `BasemapControl` does not expose its panel, but it appends it as a direct + * child of the map container — so once the panel has been seen, watch that + * one element's child list rather than the whole application's DOM. A + * body-wide subtree observer would fire on every unrelated UI mutation for as + * long as the plugin is active, which is the whole session. The body is only + * a bootstrap for the window before the panel first appears. + */ + function watchForPanel(): void { + bootstrap.disconnect(); + const host = panel?.parentElement; + if (host) bootstrap.observe(host, { childList: true }); + else bootstrap.observe(document.body, { childList: true, subtree: true }); + } + + attach(); + // `attach` installs the watch when it finds a panel; cover the case where it + // did not. + if (!panel) watchForPanel(); + + return { + pause: () => camera.pause(), + dispose() { + active = false; + bootstrap.disconnect(); + scoped.disconnect(); + visible?.disconnect(); + camera.dispose(); + }, + }; +} diff --git a/packages/plugins/src/plugins/maplibre-basemap-control.ts b/packages/plugins/src/plugins/maplibre-basemap-control.ts index 8260e8429..9bb5e8843 100644 --- a/packages/plugins/src/plugins/maplibre-basemap-control.ts +++ b/packages/plugins/src/plugins/maplibre-basemap-control.ts @@ -13,6 +13,7 @@ import { type ManagedRasterBasemap, } from "maplibre-gl-basemap-control"; import type { GeoLibreAppAPI, GeoLibreMapControlPosition, GeoLibrePlugin } from "../types"; +import { installBasemapThumbnails } from "./basemap-thumbnails"; const basemapEnv = ( import.meta as ImportMeta & { @@ -135,6 +136,7 @@ export function setBasemapControlLabels(next: Partial): vo } let basemapControl: BasemapControl | null = null; +let thumbnails: ReturnType | null = null; // GeoLibre layer ids of registered raster basemaps, keyed by basemap id. In // multiple mode several raster basemaps can be registered at once. const registeredRasterLayers = new Map(); @@ -190,6 +192,8 @@ export const maplibreBasemapControlPlugin: GeoLibrePlugin = { // style basemap or a removal can unregister them (the module state does not // survive a new session). relinkRestoredRasterBasemaps(); + thumbnails?.dispose(); + thumbnails = installBasemapThumbnails(basemapControl); // Seed the fresh control instance with every basemap already on the map — // the active style basemap plus any stacked rasters we just relinked — so // the reopened panel highlights them as active and a re-click on a stacked @@ -219,6 +223,8 @@ export const maplibreBasemapControlPlugin: GeoLibrePlugin = { // reactivation relinks them from the store via relinkRestoredRasterBasemaps // (the same path a reopened project takes). See #1113 follow-up. registeredRasterLayers.clear(); + thumbnails?.dispose(); + thumbnails = null; app.removeMapControl(basemapControl); basemapControl = null; // Drop any pending style-failure fallback so a later reactivation cannot @@ -329,6 +335,7 @@ function handleBasemapChange(app: GeoLibreAppAPI, event: BasemapControlEventPayl // before touching the layer manager, so an unrecognized future source type // does not evict the raster overlays without replacing the style. if (source.type !== "style" && source.type !== "vector-style") return; + thumbnails?.pause(); // Provider style basemaps (Amazon Location, MapTiler, Mapbox, ...) carry a // templated source.url with `{api-key}`/`{aws-region}` placeholders that the // control substitutes from the user's credentials. Apply the resolved URL the diff --git a/tests/basemap-thumbnails.test.ts b/tests/basemap-thumbnails.test.ts new file mode 100644 index 000000000..37a942daa --- /dev/null +++ b/tests/basemap-thumbnails.test.ts @@ -0,0 +1,344 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { parseHTML } from "linkedom"; +import { BasemapControl, type BasemapDefinition } from "maplibre-gl-basemap-control"; +import { + BASEMAP_PANEL_SELECTOR, + BASEMAP_ROW_ID_ATTR, + BASEMAP_ROW_SELECTOR, + advances, + installBasemapThumbnails, + rasterPreviewUrl, + styleUrlOf, +} from "../packages/plugins/src/plugins/basemap-thumbnails"; + +function raster(tiles: string[]): BasemapDefinition { + return { + id: "osm", + name: "OSM", + provider: "osm", + type: "raster", + source: { type: "raster", tiles }, + }; +} + +function tmsRaster(tiles: string[]): BasemapDefinition { + return { ...raster(tiles), source: { type: "raster", tiles, scheme: "tms" } }; +} + +function styleBasemap(url: string): BasemapDefinition { + return { + id: "positron", + name: "Positron", + provider: "openfreemap", + type: "style", + source: { type: "style", url }, + }; +} + +function style(url: string): BasemapDefinition { + return { + id: "positron", + name: "Positron", + provider: "openfreemap", + type: "style", + source: { type: "style", url }, + }; +} + +describe("basemap preview urls", () => { + it("fills z/x/y/s on a raster template", () => { + assert.equal( + rasterPreviewUrl(raster(["https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"])), + "https://a.tile.openstreetmap.org/2/1/1.png", + ); + }); + + it("skips rasters that still need a key", () => { + assert.equal( + rasterPreviewUrl(raster(["https://tiles.example/{z}/{x}/{y}.png?key={api-key}"])), + null, + ); + }); + + it("keeps a keyless style url and skips keyed ones", () => { + assert.equal( + styleUrlOf(style("https://tiles.openfreemap.org/styles/positron")), + "https://tiles.openfreemap.org/styles/positron", + ); + assert.equal( + styleUrlOf(style("https://api.maptiler.com/maps/basic/style.json?key={key}")), + null, + ); + }); + + it("skips any placeholder it does not substitute itself", () => { + // The control substitutes {api-key}/{aws-region} from the user's + // credentials; a preview must not request a URL that still carries one, nor + // a tile scheme rasterPreviewUrl leaves unresolved. + assert.equal( + styleUrlOf( + style("https://maps.geo.{aws-region}.amazonaws.com/v2/styles/Standard/descriptor"), + ), + null, + ); + assert.equal(rasterPreviewUrl(raster(["https://tiles.example/{quadkey}.png"])), null); + assert.equal(rasterPreviewUrl(raster(["https://tiles.example/{z}/{x}/{-y}.png"])), null); + }); + + it("flips the row index for a tms source", () => { + // Tencent's basemaps number rows from the bottom; an xyz row index would + // preview the vertically mirrored tile. + assert.equal( + rasterPreviewUrl(tmsRaster(["https://tiles.example/tile?z={z}&x={x}&y={y}"])), + "https://tiles.example/tile?z=2&x=1&y=2", + ); + }); + + it("skips a style url carrying any placeholder at all", () => { + // A style URL is fetched verbatim, so even a tile token nothing substitutes + // for it makes the URL unusable. + assert.equal(styleUrlOf(style("https://{s}.example.com/style.json")), null); + }); + + it("never previews a url carrying a configured credential", () => { + // Previews fire on scroll, not on an explicit pick, so they must never + // reach a keyed endpoint and spend the user's quota. The control keeps the + // catalog's raw `{api-key}`/`{aws-region}` templates and only substitutes + // when a basemap is actually applied, so `hasUnresolvedPlaceholder` rejects + // every keyed entry. This fails if that ever stops being true. + const secrets = { + mapboxAccessToken: "SECRET-MAPBOX", + maptilerApiKey: "SECRET-MAPTILER", + googleMapsApiKey: "SECRET-GOOGLE", + tomtomApiKey: "SECRET-TOMTOM", + hereApiKey: "SECRET-HERE", + stadiaApiKey: "SECRET-STADIA", + tiandituApiKey: "SECRET-TIANDITU", + amazonApiKey: "SECRET-AMAZON", + protomapsApiKey: "SECRET-PROTOMAPS", + }; + const control = new BasemapControl({ ...secrets, amazonRegion: "us-east-1" } as never); + for (const basemap of control.getBasemaps()) { + const url = rasterPreviewUrl(basemap) ?? styleUrlOf(basemap); + if (!url) continue; + for (const secret of Object.values(secrets)) { + assert.ok(!url.includes(secret), `${basemap.id} would preview with a credential: ${url}`); + } + } + }); + + it("ignores the other source kind", () => { + assert.equal(rasterPreviewUrl(style("https://tiles.openfreemap.org/styles/positron")), null); + assert.equal(styleUrlOf(raster(["https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"])), null); + }); +}); + +/** + * `BASEMAP_PANEL_SELECTOR` / `BASEMAP_ROW_SELECTOR` / `BASEMAP_ROW_ID_ATTR` + * mirror DOM that `maplibre-gl-basemap-control` renders but does not export, so + * nothing but this file notices if a bump renames one — the queries would just + * stop matching and thumbnails would silently stop appearing. Rather than + * restate the strings, this builds a real `BasemapControl` and asks it for its + * rendered panel. + */ +describe("the preview state rank", () => { + it("only ever moves a row forward", () => { + // The swatch and the snapshot resolve independently, so a slow flat-colour + // swatch must not overwrite a row already showing the real render. + assert.equal(advances("ready", "loaded"), true); + assert.equal(advances("loaded", "ready"), false); + assert.equal(advances("pending", "ready"), true); + assert.equal(advances("ready", "pending"), false); + assert.equal(advances("loaded", "loaded"), false, "a repaint at the same state is a no-op"); + }); + + it("paints a row that has no state yet, and can revive a skipped one", () => { + assert.equal(advances(null, "pending"), true); + // markSkipped drops a row that produced nothing; a snapshot arriving later + // still gets to fill it in. + assert.equal(advances("skip", "loaded"), true); + assert.equal(advances("skip", "ready"), true); + }); +}); + +describe("the maplibre-gl-basemap-control DOM mirror", () => { + let restoreGlobals: () => void; + + beforeEach(() => { + const { document, window } = parseHTML(""); + const previous = { + document: globalThis.document, + window: globalThis.window, + MutationObserver: globalThis.MutationObserver, + IntersectionObserver: globalThis.IntersectionObserver, + requestAnimationFrame: globalThis.requestAnimationFrame, + CSS: (globalThis as { CSS?: unknown }).CSS, + }; + // The control assigns `select.value`, which linkedom exposes as a getter + // only. Give it a setter so its filter row renders. + const selectValue = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value", + ); + Object.defineProperty(window.HTMLSelectElement.prototype, "value", { + configurable: true, + get: selectValue?.get, + set(next: string) { + this.setAttribute("value", next); + }, + }); + Object.assign(globalThis, { + document, + window, + MutationObserver: window.MutationObserver, + // linkedom ships no rAF; the control only uses it to position the panel. + requestAnimationFrame: () => 0, + // Nor `CSS.escape`, which `rowSelector` uses to build its attribute + // selector. Basemap ids are kebab-case, so escaping anything else is + // enough for these tests. + CSS: { escape: (value: string) => value.replace(/[^\w-]/g, (ch) => `\\${ch}`) }, + }); + restoreGlobals = () => Object.assign(globalThis, previous); + }); + + afterEach(() => restoreGlobals()); + + /** A map stub with the surface `BasemapControl.onAdd`/`onRemove` touch. */ + function fakeMap(container: HTMLElement) { + return { getContainer: () => container, on: () => {}, off: () => {} } as never; + } + + it("matches the panel, the rows and the row id attribute", () => { + const container = document.createElement("div"); + document.body.append(container); + const control = new BasemapControl({ collapsed: false }); + control.onAdd(fakeMap(container)); + + const panel = container.querySelector(BASEMAP_PANEL_SELECTOR); + assert.ok(panel, `no ${BASEMAP_PANEL_SELECTOR} — the control renamed its panel`); + // `watchForPanel` observes the panel's parent to notice a rebuilt panel, so + // the panel has to stay a direct child of the map container. + assert.equal(panel.parentElement, container, "the panel is no longer a map-container child"); + const rows = [...panel.querySelectorAll(BASEMAP_ROW_SELECTOR)]; + assert.ok(rows.length > 0, `no ${BASEMAP_ROW_SELECTOR} rows — the control renamed its rows`); + + // `enhance` joins a row back to its catalog entry through this attribute, + // so the ids the rows carry must be ids `getBasemaps()` reports. + const catalog = new Set(control.getBasemaps().map((basemap) => basemap.id)); + const ids = rows.map((row) => row.getAttribute(BASEMAP_ROW_ID_ATTR)); + assert.ok( + ids.every((id) => id !== null && catalog.has(id)), + `rows no longer join the catalog through ${BASEMAP_ROW_ID_ATTR}`, + ); + }); + + it("re-finds the panel the control rebuilds when it is repositioned", async () => { + // `setMapControlPosition` moves the control with a removeMapControl / + // addMapControl pair, and `onAdd` builds a *fresh* panel each time. The + // watch is anchored to the panel's parent — the map container, which the + // control reuses — so both the removal and the insertion land on the node + // it observes. Narrow that watch to the panel itself and thumbnails would + // silently stop appearing after a reposition, with nothing else to catch it. + const container = document.createElement("div"); + document.body.append(container); + const control = new BasemapControl({ + collapsed: false, + includeDefaultBasemaps: false, + basemaps: [raster(["https://tiles.example/{z}/{x}/{y}.png"])], + } as never); + control.onAdd(fakeMap(container)); + const thumbnails = installBasemapThumbnails(control); + const thumbnailCount = () => container.querySelectorAll(".geolibre-basemap-thumbnail").length; + + assert.equal(thumbnailCount(), 1, "the first panel was not enhanced"); + + control.onRemove(); + control.onAdd(fakeMap(container)); + assert.equal(thumbnailCount(), 0, "expected a fresh, unenhanced panel"); + + // MutationObserver callbacks are queued, not synchronous. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(thumbnailCount(), 1, "the rebuilt panel was never enhanced"); + thumbnails.dispose(); + }); + + it("drops a style row to name-only when neither preview can be produced", async () => { + // Neither preview path can succeed here: there is no canvas 2d context for + // the swatch and no WebGL for the snapshot, which is exactly how a style + // host that is unreachable behaves. Both have to degrade quietly to the + // name-only row rather than reject or strand the placeholder. + const rejections: unknown[] = []; + const onRejection = (reason: unknown) => rejections.push(reason); + process.on("unhandledRejection", onRejection); + const fetched: string[] = []; + Object.assign(globalThis, { + fetch: (url: string) => { + fetched.push(String(url)); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ layers: [] }) }); + }, + }); + + const container = document.createElement("div"); + document.body.append(container); + const control = new BasemapControl({ + collapsed: false, + includeDefaultBasemaps: false, + basemaps: [styleBasemap("https://tiles.example/style.json")], + } as never); + control.onAdd(fakeMap(container)); + const thumbnails = installBasemapThumbnails(control); + + const row = container.querySelector(BASEMAP_ROW_SELECTOR); + assert.ok(row, "no row was rendered"); + assert.equal(fetched.length, 1, "the style json was not fetched"); + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal( + row.getAttribute("data-geolibre-basemap-preview"), + "skip", + "the row kept its placeholder instead of falling back to name-only", + ); + assert.equal(container.querySelectorAll(".geolibre-basemap-thumbnail").length, 0); + assert.deepEqual(rejections, [], "a failed preview rejected instead of resolving null"); + process.off("unhandledRejection", onRejection); + thumbnails.dispose(); + }); + + it("defers every preview request until the row is on screen", () => { + // Opening the panel must not contact every provider in the catalog at once: + // the raster tile and the style JSON are deferred the same way the full + // snapshot always was. + const observed: HTMLElement[] = []; + let fire: (entries: IntersectionObserverEntry[]) => void = () => {}; + class FakeIntersectionObserver { + constructor(callback: (entries: IntersectionObserverEntry[]) => void) { + fire = callback; + } + observe(target: HTMLElement) { + observed.push(target); + } + unobserve() {} + disconnect() {} + } + Object.assign(globalThis, { IntersectionObserver: FakeIntersectionObserver }); + + const container = document.createElement("div"); + document.body.append(container); + const control = new BasemapControl({ + collapsed: false, + includeDefaultBasemaps: false, + basemaps: [raster(["https://tiles.example/{z}/{x}/{y}.png"])], + } as never); + control.onAdd(fakeMap(container)); + const thumbnails = installBasemapThumbnails(control); + const thumbnailCount = () => container.querySelectorAll(".geolibre-basemap-thumbnail").length; + + assert.equal(observed.length, 1, "the row was never observed"); + assert.equal(thumbnailCount(), 0, "the tile was requested before the row was visible"); + + fire([{ isIntersecting: true, target: observed[0] } as unknown as IntersectionObserverEntry]); + assert.equal(thumbnailCount(), 1, "the row was not previewed once visible"); + thumbnails.dispose(); + }); +});