From 96991823bac8c861ab3c69536ecb628abb9da2ba Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 16:04:06 +0100 Subject: [PATCH 01/10] Shapes: non-blocking off-thread loading + vertex-pulling FlatPolygonLayer Shapes no longer gate first paint. The geometry worker decodes WKB -> flat buffers and tessellates the render topology, transferring both back zero-copy; ShapesResolver.blockingResources = [] with a main-thread tessellation fallback. This removes the full-element main-thread WKB decode behind the loading overlay and lets Visium HD square_002um (~2.7M polygons) load without running out of memory. Polygon shapes render through a new hand-rolled FlatPolygonLayer (@spatialdata/layers) instead of SolidPolygonLayer + a PathLayer outline: - Vertex pulling: an attribute-less draw where the vertex shader reconstructs each vertex's position + boundary edge-distance from two shared geometry textures via gl_VertexID, and imputes an fwidth anti-aliased outline in the fragment shader (no separate outline layer). Per-frame cost ~ the fill; geometry memory ~ the stock indexed fill; arbitrary polygons at ~2.7M scale. - Feature state via a per-feature colour texture (the reusable "table column -> buffer" primitive): colour-by-column / hide / fade re-upload only a small texture, never the geometry buffers. Picking is computed in-shader from the feature index. - Outline is a lightened derivation of the fill, width-capped to a fraction of each shape's on-screen size and faded out for sub-pixel shapes (clear zoomed in, non-dominating / no moire zoomed out). Also decouples the one-shot auto-fit from the shapes-blocking transition, and fixes the fill-colour "one column behind" bug and the hover/pan buffer-thrash from unstable deck updateTrigger arrays. New public surface: core exports tessellateFlatPolygons / TessellatedPolygons and carries the tessellated topology on ShapesRenderData; layers exports FlatPolygonLayer. Includes docs updates and a changeset. Known follow-up: the main-thread GPU texture upload (~seconds on the largest elements) is not yet off the main thread; a WGSL/WebGPU variant (storage buffers instead of texture-packing) is the intended fix. Co-Authored-By: Claude Opus 4.8 --- .../shapes-nonblocking-flat-polygon-layer.md | 45 ++ docs/docs/vis/layer-prop-flow.mdx | 69 ++- .../plans/shapes-nonblocking-tiled-loading.md | 334 +++++++++++++ packages/core/package.json | 3 +- packages/core/src/earcut.d.ts | 15 + packages/core/src/engine/ShapesResolver.ts | 27 +- packages/core/src/index.ts | 13 + packages/core/src/models/VShapesSource.ts | 133 +++--- packages/core/src/models/index.ts | 4 - packages/core/src/shapes.ts | 27 +- packages/core/src/shapesGeometryDecode.ts | 177 +++++++ packages/core/src/shapesLoader.ts | 134 ++++++ packages/core/src/shapesPolygonTessellate.ts | 158 +++++++ packages/core/src/spatialViewFit.ts | 38 ++ packages/core/src/workers/index.ts | 1 + packages/core/src/workers/points-worker.ts | 56 +++ .../core/src/workers/pointsWorkerClient.ts | 45 ++ .../core/src/workers/pointsWorkerProtocol.ts | 22 +- .../core/tests/shapesGeometryDecode.spec.ts | 87 ++++ packages/core/tests/shapesLoader.spec.ts | 101 ++++ .../tests/shapesPolygonTessellate.spec.ts | 118 +++++ packages/core/tests/shapesResolver.spec.ts | 8 +- packages/core/tests/vshapes.spec.ts | 72 +-- packages/layers/package.json | 3 +- packages/layers/src/FlatPolygonLayer.ts | 233 ++++++++++ .../layers/src/flatPolygonLayerShaders.ts | 173 +++++++ packages/layers/src/index.ts | 1 + packages/layers/src/shapesLayer.ts | 440 +++++++++++++++++- packages/layers/tests/shapesLayer.spec.ts | 125 ++++- .../src/SpatialCanvas/SpatialCanvasViewer.tsx | 27 +- .../SpatialCanvas/renderers/shapesRenderer.ts | 5 +- .../vis/src/SpatialCanvas/shapesProjection.ts | 30 +- .../vis/src/SpatialCanvas/useLayerData.ts | 28 +- packages/vis/tests/shapesProjection.spec.ts | 67 +++ pnpm-lock.yaml | 6 + 35 files changed, 2642 insertions(+), 183 deletions(-) create mode 100644 .changeset/shapes-nonblocking-flat-polygon-layer.md create mode 100644 docs/plans/shapes-nonblocking-tiled-loading.md create mode 100644 packages/core/src/earcut.d.ts create mode 100644 packages/core/src/shapesGeometryDecode.ts create mode 100644 packages/core/src/shapesLoader.ts create mode 100644 packages/core/src/shapesPolygonTessellate.ts create mode 100644 packages/core/tests/shapesGeometryDecode.spec.ts create mode 100644 packages/core/tests/shapesLoader.spec.ts create mode 100644 packages/core/tests/shapesPolygonTessellate.spec.ts create mode 100644 packages/layers/src/FlatPolygonLayer.ts create mode 100644 packages/layers/src/flatPolygonLayerShaders.ts create mode 100644 packages/vis/tests/shapesProjection.spec.ts diff --git a/.changeset/shapes-nonblocking-flat-polygon-layer.md b/.changeset/shapes-nonblocking-flat-polygon-layer.md new file mode 100644 index 00000000..01fd8323 --- /dev/null +++ b/.changeset/shapes-nonblocking-flat-polygon-layer.md @@ -0,0 +1,45 @@ +--- +"@spatialdata/core": minor +"@spatialdata/layers": minor +"@spatialdata/vis": minor +--- + +Non-blocking shapes loading + a vertex-pulling `FlatPolygonLayer`. + +Shapes no longer gate first paint. The geometry column is decoded (WKB → flat buffers) +**and tessellated** into render topology inside the geometry worker and transferred back +zero-copy; `ShapesResolver.blockingResources` is now `[]`, and a main-thread tessellation +fallback covers the no-worker path. This removes the full-element main-thread WKB decode +that previously blocked behind the "Loading layer data…" overlay, and it lets Visium HD +`square_002um` (~2.7M polygons) load without running out of memory. + +Polygon shapes now render through a new hand-rolled `FlatPolygonLayer` +(`@spatialdata/layers`) instead of deck's `SolidPolygonLayer` + a `PathLayer` outline: + +- **Vertex pulling** — an attribute-less draw where the vertex shader reconstructs each + vertex's position and a boundary edge-distance from two shared geometry textures via + `gl_VertexID`, and imputes an anti-aliased outline with `fwidth` in the fragment shader + (no separate outline layer). Per-frame cost ≈ the fill; geometry memory ≈ the stock + indexed fill. Works on arbitrary polygons (cell segmentation, not just grids). +- **Feature state via a per-feature colour texture** (the reusable "table column → + buffer" primitive): colour-by-column, hide, and fade re-upload only a small texture, + never the geometry buffers. Picking colours are computed in-shader from the feature + index. +- **Outline** is a lightened derivation of the fill, width-capped to a fraction of each + shape's on-screen size and faded out for sub-pixel shapes — clear when zoomed in, + non-dominating (no moiré) when zoomed out. + +New/changed public surface: `@spatialdata/core` exports `tessellateFlatPolygons` / +`TessellatedPolygons` and carries the tessellated topology on `ShapesRenderData`; +`@spatialdata/layers` exports `FlatPolygonLayer`. `@spatialdata/vis` decouples the +one-shot auto-fit from the shapes-blocking transition so a shapes-only view still frames +correctly. + +Also fixes a fill-colour "one column behind" bug (the feature-state runtime cache is now +keyed on the fill-colour entry identity, not just its column signature) and the +hover/pan buffer-thrash from unstable deck `updateTrigger` arrays. + +Known follow-ups: the main-thread GPU texture upload (~seconds on the largest elements) +is not yet off the main thread — a WGSL/WebGPU variant (storage buffers instead of +texture-packing) is the intended fix; explicit per-feature stroke override on the polygon +path; non-blocking associated-table load. diff --git a/docs/docs/vis/layer-prop-flow.mdx b/docs/docs/vis/layer-prop-flow.mdx index 7e27d59c..0e5d0d2c 100644 --- a/docs/docs/vis/layer-prop-flow.mdx +++ b/docs/docs/vis/layer-prop-flow.mdx @@ -272,22 +272,38 @@ geometry, bounds, shape prebuilds, or feature-state runtimes. | Putting `featureState` wholesale in `updateTriggers` | Any new object identity invalidates all colour attributes | | Per-feature accessors for layer-wide opacity | Use deck `opacity` on the layer instead | -### Direction: binary attributes (not implemented) - -deck.gl's fastest path is **precomputed attribute buffers** (optionally built in -a worker/WASM) passed via `data.attributes`, bypassing accessor calls entirely. -Our shapes path still uses `PolygonLayer` / `ScatterplotLayer` with per-feature -accessors for fill/stroke lookup by `featureId`. That is acceptable for current -scale targets but is not the long-term ceiling. - -Future work (document-only for now): - -1. Columnar fill/stroke colours aligned with `featureIds` / `rowIndexByFeatureIndex` -2. Push colours into typed arrays when encodings change, not when opacity changes -3. Optional worker/WASM attribute generation for multi-million feature sets - -Until then, keep hot paths allocation-free across cosmetic renders and rebuild -feature-state runtimes only when filtering or encodings actually change. +### Shapes render path: vertex-pulling `FlatPolygonLayer` + +Circles/points still use `ScatterplotLayer` with per-feature colour accessors (their +counts are modest). **Polygon** shapes — up to millions of features (Visium HD +`square_002um` ≈ 2.7M) — render through a hand-rolled `FlatPolygonLayer` +(`@spatialdata/layers`) instead of deck's `SolidPolygonLayer`/`PolygonLayer`, because +accessor re-execution and a separate outline layer do not scale to that count. + +- **Non-blocking, off-thread decode + tessellation.** `VShapesSource` decodes the WKB + geometry column into flat buffers **and** tessellates it into render topology inside + the geometry worker (`shapesPolygonTessellate`), transferring both back zero-copy. + Shapes never gate first paint (`ShapesResolver.blockingResources = []`). +- **Vertex pulling — no per-vertex attributes.** The layer draws an attribute-less + triangle list: `gl_VertexID` selects the triangle + corner, and the vertex shader + fetches the topology and shared ring positions from two data textures, computing each + vertex's position and a boundary edge-distance on the fly. This keeps geometry memory + to two shared textures instead of large de-indexed attribute buffers, and imputes an + anti-aliased outline with `fwidth` in the fragment shader — no separate outline layer. +- **Feature state = "table column → buffer".** Colour-by-column, hide, and fade live in + a small **per-feature colour texture** indexed by feature; a feature-state change + re-uploads only that texture, never the (large) geometry textures. This is the reusable + primitive for column-driven encodings; picking colours are computed in-shader from the + feature index. + +Keep hot paths allocation-free across cosmetic renders: the geometry textures and the +per-feature colour buffer keep stable identities, so deck re-uploads only when the +geometry or the feature-state runtime actually changes. + +Deferred: emitting texture-ready (padded) buffers from the worker to shrink the +main-thread GPU-upload cost; a WGSL variant (WebGPU can use storage buffers instead of +texture-packing); a true polygon SDF path. See +[`docs/plans/shapes-nonblocking-tiled-loading.md`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/docs/plans/shapes-nonblocking-tiled-loading.md). ## Shape/table annotation controls @@ -302,12 +318,21 @@ When adding these controls, keep the visual encoding layer-specific: choosing one fill-colour column for a shapes layer must not affect another layer that renders the same shapes element. -Fill-colour encodings should normally control both polygon fill and outline -colour. Polygon outlines should use the shared shape stroke defaults from -`@spatialdata/layers`: common-coordinate line width, `lineWidthMinPixels: 0`, -and a small max-pixel clamp. That keeps outlines from dominating when many -shapes become tiny on screen, while still allowing headless callers to override -stroke width, units, and min/max pixel clamps per layer. +The fill is the *specified* colour (a layer default or a data-column encoding); +the outline is **derived** from it. `@spatialdata/layers` (`deriveStrokeColor`) +lightens each feature's resolved fill for its outline, so adjacent shapes read as +distinct — an outline the same colour as the fill is invisible at the thin default +width, which is why shapes previously did not read as shapes. A genuine per-feature +stroke override still wins over the derivation, so the vis layer must **not** +pre-mirror the fill into `strokeColorByFeatureId` (that would look like an explicit +override and defeat the derivation). On the object (`ScatterplotLayer`) path this is a +per-feature accessor. On the polygon (`FlatPolygonLayer`) path the outline is **imputed +in the fragment shader** from the per-vertex boundary edge-distance — no separate +outline layer — and lightens the fill there; its width is capped to a fraction of each +shape's on-screen size and fades out entirely for sub-pixel shapes, so it stays clear +when zoomed in but never dominates (or aliases into moiré) when zoomed out. A genuine +per-feature stroke override on the polygon path is a follow-up; today it always +lightens the fill. ## See also diff --git a/docs/plans/shapes-nonblocking-tiled-loading.md b/docs/plans/shapes-nonblocking-tiled-loading.md new file mode 100644 index 00000000..5fa77d8a --- /dev/null +++ b/docs/plans/shapes-nonblocking-tiled-loading.md @@ -0,0 +1,334 @@ +# Shapes: non-blocking + viewport-tiled loading + +Status: **Phase 0–1 implemented** (2026-07-17); Phase 2 (tiled artifact) still design. +Supersedes the shapes items in +[`resource-resolver-handoff.md` §Track B](./resource-resolver-handoff.md) with a +concrete, researched design. Implements the shapes half of +[ADR 0002](../adr/0002-spatially-aware-vector-loading.md) ("Shapes format 0.3 +remains on the current parquet path; large-shape spatial tiling still needs a +separate GeoParquet artifact/writer slice"). + +## Status — what shipped (Phase 0–1) + +The **non-blocking** goal is done; the design landed somewhere better than the original +"binary `PolygonLayer`" sketch below (see Phase 1 notes for the evolution): + +- **Off-thread, non-blocking geometry.** The geometry worker decodes WKB → flat buffers + **and tessellates** the render topology (`core/shapesPolygonTessellate`), transferring + both back zero-copy. `ShapesResolver.blockingResources = []`; shapes never gate first + paint. A main-thread tessellation fallback covers the no-worker path. +- **`FlatPolygonLayer`** (`@spatialdata/layers`) — a hand-rolled, **vertex-pulling** luma + layer: an attribute-less draw where the vertex shader reconstructs each vertex's + position + boundary edge-distance from two shared geometry textures via `gl_VertexID`, + and imputes an anti-aliased outline with `fwidth` in the fragment (no separate outline + layer). Memory ≈ the stock indexed fill; renders arbitrary polygons at ~2.7M scale. +- **Feature state as a per-feature colour texture** (the reusable "table column → buffer" + primitive): colour-by-column / hide / fade re-upload only a small texture, never the + geometry. Picking is computed in-shader from the feature index. +- **Outline** is a lightened derivation of the fill, width-capped to a fraction of each + shape's on-screen size and faded out for sub-pixel shapes (clear zoomed in, no + domination/moiré zoomed out). + +Open follow-ups: emit texture-ready (padded) buffers from the worker to shrink the +main-thread GPU-upload block; a WGSL variant (WebGPU storage buffers replace the +texture-packing and likely absorb the upload cost); explicit per-feature stroke override +on the polygon path; non-blocking associated-table load; Phase 2 tiling; renaming the +points worker to a general parquet worker (coordinate with the points worktree). + +## Goal + +Two things, and they are **separable** — the plan ships them in that order: + +1. **Non-blocking.** Shapes must stop gating first paint. Today + `ShapesResolver.blockingResources = ['geometry']` and geometry means a + **full-element WKB decode on the main thread**, behind the modal + `"Loading layer data..."` overlay. Points never do this — they load *inside* + their composite layer. +2. **A spatial query that actually reduces I/O + decode.** Not a post-load cull: + fetch and decode only the parquet row groups whose geometry overlaps the + viewport. + +The second requires a spatial-index artifact; the first does not. Non-blocking is +a *consequence* of moving the load into the layer, not a feature bolted on. + +## What's true today (the starting line) + +- `ShapesResolver` blocks on `geometry`; `element.loadRenderData()` → + `VShapesSource.loadShapesRenderData()` does a **whole-element** WKB parquet + decode on the **main thread** (`ol/format/WKB`). `VShapesSource` has **zero** + worker usage; `VPointsSource` imports the whole worker client. +- The batch type is pathological: `ShapePolygon = Array>` + — one JS array object per vertex. Not transferable, not cheap. This is *why* + shapes never went to the worker. +- `VShapesSource.loadShapesInBounds` **exists and lies**: ignores `bounds`, + `zoom`, `columns`; does a full load; stamps `loadMode: 'full-filter'`. Zero + callers, zero tests. **Delete or honestly fix it first.** +- Rendering is one-shot: `buildShapesPrebuiltData` materialises the entire + feature array and hands it to a plain `PolygonLayer` / `ScatterplotLayer` as one + `data` prop. No `TileLayer`, no `getTileData`, no `AbortSignal`. +- **But shapes already inherit the whole range-read stack** from `VTableSource` + (`loadParquetDatasetMetadata`, `readParquetRowGroupBytesByGroupIndex`, + `canLoadParquetRowGroups`, multipart handling, `ParquetRowGroupBytesChunk`). + The tiling plumbing points uses is *already available* to shapes. + +## The points template to mirror (verified file anchors) + +| Concern | Points file | Shapes analog to build | +|---|---|---| +| Core loader seam (deck-free) | `core/src/pointsLoader.ts` (`CorePointsLoader`, `capabilities`, `loadInBounds`, batch union) | `core/src/shapesLoader.ts` | +| Self-loading composite | `layers/src/PointsLayer.ts` | `layers/src/ShapesLayer.ts` | +| The actual TileLayer | `layers/src/mortonTiledStrategy.ts` (`getTileData` → `loadInBounds({bounds, signal})`, abort handling) | `layers/src/geoparquetTiledStrategy.ts` | +| Strategy dispatch on `capabilities.kind` | `layers/src/pointsRenderStrategies.ts` | shapes strategy table | +| Resource + `experimentalOptimizations` | `layers/src/resolvePointsRenderResource.ts` | `resolveShapesRenderResource.ts` | +| Worker decode + transferables | `core/src/workers/points-worker.ts`, `pointsWorkerClient.ts` (`decodeGeometryWithFeaturesInWorker`), `pointsWorkerProtocol.ts` | add a `decode-shapes-wkb` request + handler + client, or a `shapes-worker.ts` | +| Row-group range reads (INHERITED — reuse as-is) | `core/src/models/VTableSource.ts` | — | +| Tiling metadata + real bounds loader | `core/src/pointsTiling.ts`, `VPointsSource.loadPointsInBounds` (morton bisect) | `ShapesTilingMetadata`, `VShapesSource.loadShapesInBounds` (bbox bisect) | +| Python spatial writer | `python/spatialdata-experimental-writer/src/.../points.py` (morton sort, sentinels, row groups) | shapes GeoParquet writer under `shapes.experimental//` | + +Note: the live vis points path currently sets `experimentalOptimizations: 'off'` +(`PointsRendererAdapter.ts`), so even the points `TileLayer` is test-exercised but +**not yet the app default**. Mirroring the architecture is not enough to *see* +tiling — the switch has to be flipped too. Budget for that. + +## Research findings that shape the design + +### Finding 1 — GeoArrow is transferable; that part of your optimism holds + +GeoArrow polygon/point columns are flat Arrow buffers (a single interleaved or +separated coordinate buffer + int32 ring/geometry offset buffers) — **zero +per-vertex JS objects**, so they `postMessage` across the worker boundary as +`ArrayBuffer`s. This is exactly the batch property the handoff doc requires and +the current `Array>` batch lacks. +[GeoArrow format](https://geoarrow.org/format.html) + +So **"GeoArrow with the existing WKB store"** resolves cleanly: GeoArrow is not a +second store, it is the **in-memory target layout**. Two load paths converge on +it: + +- **wild-type WKB parquet** → worker decodes WKB → GeoArrow buffers (decode + unavoidable, but off-main-thread and into a transferable layout). +- **tiled artifact** → GeoArrow-encoded (or WKB) GeoParquet, spatially pruned → + buffers arrive ready (no WKB decode on the GeoArrow-encoded variant). + +Same `ShapesBatch` either way; ADR 0003's strategy registry dispatches on +`loader.capabilities.kind`. + +### Finding 2 — the feature-state gap is the real risk, and it's NOT where you expected + +You were optimistic that "carrying the requisite data isn't the problem." The data +*carries* fine. What `@geoarrow/deck.gl-geoarrow`'s `GeoArrowPolygonLayer` does +**not** cleanly support is **dynamic per-feature hide / fade / filter** — which is +core to this app (feature-state, picking-driven highlight; see +`shape-picking-requirements`, the MDV integration). + +- Per-feature **color** from an Arrow column: **yes**. **Picking** to a row index: + **yes** (`GeoArrowPickingInfo.index`/`.object`). `updateTriggers`: **yes**. +- Per-feature **filter/hide**: **no first-class path.** Passing an Arrow column to + `getFilterValue` silently fails to render + ([issue #169](https://github.com/geoarrow/deck.gl-layers/issues/169), open); + the function-accessor route is reported slow. The only working fallback is to + rebuild the fill-color alpha attribute via `updateTriggers.getFillColor` — a + **full-attribute recompute over all features** per change, not a cheap + `DataFilterExtension` uniform toggle. +- Also: open bug [#214](https://github.com/geoarrow/deck.gl-layers/issues) — + Arrow-IPC padded offset buffers break polygon **strokes**. + +Version risk is **low**, contrary to the deck/luma/viv coordination worry: the +package (renamed `@geoarrow/deck.gl-layers` → `@geoarrow/deck.gl-geoarrow@0.4.1`) +declares a `^9.0.0` peer range on `@deck.gl/*` and **no `@luma.gl/*` peer at all**. + +**Consequence — the recommended split:** use **GeoArrow as the data plane** (the +transferable worker-decode target and the tiled-artifact encoding), but keep our +**own binary deck layer** for rendering, feeding it GeoArrow's flat +`positions`/`polygonIndices` buffers. We already hand-roll custom deck attributes ++ shader injection for points colour-by-feature +(`deck-layer-extension-attribute-gotchas`), so retaining a binary `PolygonLayer` +we control — rather than adopting `GeoArrowPolygonLayer` wholesale — keeps +feature-state fast and picking exactly as specified. The handoff doc already +sanctions this: *"hand-rolled flat arrays are a sanctioned outcome if +[deck.gl-geoarrow] cannot [carry feature-state]."* Research says it cannot. + +> **DECIDED (2026-07-16): GeoArrow data plane + our own binary layer.** We render +> through a binary `PolygonLayer`/`ScatterplotLayer` we control, fed GeoArrow's +> flat buffers — not `GeoArrowPolygonLayer` wholesale. Everything else in the plan +> is invariant to this call. + +### Finding 3 — viewport row-group pruning on GeoParquet is confirmed feasible + +A browser range-reader **can** prune row groups by viewport using GeoParquet +1.1's `covering.bbox` column, **if** the file is spatially sorted at write time. + +- The `covering.bbox` metadata points at a Parquet **struct column** + (`bbox.{xmin,ymin,xmax,ymax}`), whose four leaves each carry per-row-group + min/max **statistics**. Overlap test per row group (no geometry decode): + `rg.max(xmin) ≥ qxmin ∧ rg.min(xmax) ≤ qxmax ∧ rg.max(ymin) ≥ qymin ∧ rg.min(ymax) ≤ qymax`. + [GeoParquet 1.1](https://geoparquet.org/releases/v1.1.0/) +- **Spatial sort is mandatory for selectivity** — unsorted, every row group's bbox + spans the dataset and nothing prunes. This is the direct analog of the morton + sort the points writer already does. +- **Write recipe (single durable artifact, read by geopandas AND browser):** + DuckDB `spatial`, `ORDER BY ST_Hilbert(geom, )`, zstd, + `ROW_GROUP_SIZE ≈ 100k`. DuckDB auto-emits the 1.1 bbox covering column. Pass + the dataset extent to `ST_Hilbert` or ordering degrades. + [DuckDB + GeoParquet Hilbert](https://cloudnativegeo.org/blog/2025/01/using-duckdbs-hilbert-function-with-geoparquet/) +- **Reader:** the repo already uses parquet-wasm (selective `rowGroups: [...]` + reads) and the `VTableSource` range-read stack. Footer statistics give the + min/max; hyparquet is a pure-JS fallback for stats if parquet-wasm's binding + doesn't surface per-row-group column stats (flagged to verify). +- **FlatGeobuf** (packed Hilbert R-tree, feature-level) prunes finer but is + row-oriented and a *second* artifact — rejected against the "one columnar + artifact geopandas + browser both read" requirement. Recorded as the fallback + if row-group granularity proves too coarse. + +## Plan + +### Phase 0 — honesty + seam (no artifact, no worker) + +1. **Delete `VShapesSource.loadShapesInBounds`** (the lying stub) or reduce it to + an honest full-load that reports its mode truthfully. +2. **Define the loader seam** `core/src/shapesLoader.ts`, cloning `pointsLoader.ts`: + `ShapesLoaderCapabilities { kind, batchFormat, bounds?, supportsViewportTiles }`, + `ShapesBatch` (GeoArrow-buffer variant + today's decoded variant during + migration), `ShapesLoadInBoundsOptions { bounds, signal }`, + `CoreShapesLoader { capabilities, loadInBounds, loadAll? }`. Kinds: + `'wkb-full' | 'geoparquet-tiled'` (+ reserved geoarrow variants). +3. Ship **one loader**: `wkb-full`, `supportsViewportTiles: false`, + `loadInBounds` ignores bounds and returns the full batch honestly. + +> **Phase 1 status: DONE (2026-07-16), verified live on Visium HD `square_016um`.** +> Non-blocking; WKB decode off the main thread in the points worker → transferable +> flat buffers; binary `SolidPolygonLayer` fill render with index-driven +> feature-state + picking; auto-fit decoupled from blocking. Event loop stays +> responsive through decode (the single remaining main-thread stall is deck's +> polygon **tessellation** — inherent, and what Phase 2 tiling ultimately bounds by +> loading only the viewport). Validated at scale on Visium HD `square_002um` (~2.7M +> polygons): loads without OOM, non-blocking. +> +> **Interaction buffer-thrash fixed (2026-07-17).** Diagnosis found deck rebuilding +> the entire per-feature fill-colour attribute on *every* re-render — 2.7M +> `getFillColor` calls, a ~600ms main-thread stall on every hover/pan. Root cause: a +> fresh `[100,100,200,180]` default-colour array per render (a default parameter + +> `??` fallbacks) fed `updateTriggers.getFillColor`, which deck compares shallowly, +> so it read as "colours changed". Fix: a stable `DEFAULT_SHAPE_FILL_COLOR` module +> constant + a colour `updateTrigger` memoised on the (stable) feature-state runtime. +> Verified: hover 670ms→66ms, zoom→5ms, `getFillColor` calls on hover 2.7M→0. +> Regression test in `shapesLayer.spec.ts`. A residual ~150ms blip on drag-*end* +> remains — likely the picking-buffer rebuild on the pan/zoom `pickable` toggle +> (`spatialcanvas-pan-zoom-picking-cost`), a separate, smaller matter. +> +> **Fill-colour "one column behind" fixed (2026-07-17).** Exposed by the thrash fix +> (deck now only rebuilds when the trigger truly changes): `getStableShapeFeatureStateRuntime` +> keyed its runtime cache on a *column-based* signature string, but a column switch +> serves the previous column's rows until the new ones load — so the runtime was +> built from stale rows and never invalidated when the real rows arrived (same +> signature). Fix: key the runtime cache on the fill-colour entry's **identity** +> (it's a fresh object when the rows change), not just its signature. Regression +> test `shapesProjection.spec.ts`; verified live cycling `array_col` → `array_row` +> → `in_tissue` (each shows the latest column). +> +> **Follow-ups surfaced:** (a) **binary stroke / distinct outlines** — `PolygonLayer`'s +> stroke sublayer (`_getPaths`) iterates data as objects and cannot consume a binary +> buffer, so the binary path is fill-only; fill-coloured outlines read poorly as +> shapes, so add distinct outlines via a binary `PathLayer` over the same ring +> buffers. (b) **Non-blocking associated-table load** — the fill/tooltip table still +> blocks for seconds on large elements: `_loadParquetTableUncached` (`VTableSource`) +> runs the WASM `readParquet` + `tableFromIPC` decode on the main thread. The fix is +> the shapes-geometry pattern applied to whole-table decode: a `decodeParquetTable` +> worker request + route the base loader through it (main-thread fallback retained). +> **Held as follow-up, not done in this pass (2026-07-17), because it shares two +> surfaces with the active points worktree:** (1) `_loadParquetTableUncached` is a +> `SpatialDataTableSource` base method `VPointsSource` extends and calls, so the change +> ripples into the points load path; (2) it extends the **worker protocol**, which the +> points pass is concurrently reshaping (adding abort/cancellation between row-group +> chunks). No textual conflict on today's trees (points touches only `VPointsSource.ts`), +> but reshaping the shared protocol in parallel is high-friction. This *is* the +> "points worker → general parquet worker" generalization already deferred below — +> sequence it after both passes land, together with that rename. (c) **Filling colour buffers +> off-thread** — even the *legitimate* colour rebuild (on a real feature-state +> change) is an O(vertices) main-thread pass; a worker-built binary colour attribute +> would remove it. (d) **tooltip ping-pong**, still deferred (pick/tooltip identity). + +### Phase 1 — non-blocking + off-thread decode + +> **Scope decision (2026-07-16, revised):** the worker **is** in this pass — the +> non-blocking flip alone achieves little because the WKB decode still janks the +> main thread. What is deferred is only the **rename** of `points-worker.ts` to a +> general *parquet worker*: shapes reuse the existing points worker (making it more +> general-purpose), and if that generality holds we rename it later, coordinated +> with the points worktree, rather than churning shared files twice. The +> self-loading composite and the tiled artifact remain Phase 2 (tiling). + +4. **Flip blocking** — DONE. `ShapesResolver.blockingResources = []`; shapes + dropped from `useLayerData`'s `isBlocking`. Modal overlay gone; non-modal + `isLoading` spinner covers the wait; `getLayers` skips a shapes layer until its + geometry is ready. +5. **Off-thread decode → transferable flat buffers.** Add a shapes-geometry decode + request to the **existing** points worker (additive: new union member + handler + case + client fn, to keep the merge surface small). The worker reads the shapes + parquet bytes, decodes the WKB geometry column into **flat GeoArrow-style typed + arrays** (positions + polygon `startIndices`; circles are already columnar), and + transfers them back zero-copy. Main-thread fallback = today's + `_decodeWkbColumnNested`/`Flat`. `ShapesRenderData`/`ShapesBatch` gains a + flat-buffer polygon representation. +6. **Binary polygon render.** Feed deck's `PolygonLayer` in **binary mode** + (`data: { length, startIndices, attributes }`) from the flat buffers — the layer + we already own, no `GeoArrowPolygonLayer`. Map picking `index → featureId` and + drive feature-state (fill/stroke/hide/fade) by index. Circles stay on the + existing columnar `ScatterplotLayer` path. + +**Deferred:** the tooltip ping-pong (step below — reaches the pick/tooltip identity +model, not just a cache); the `ShapesLayer` self-loading composite and tiled +artifact (Phase 2); renaming the worker (future). + +**Follow-ups this pass surfaced:** + +- **Auto-fit was coupled to shapes-blocking (fix in this pass).** The one-shot + auto-fit in `SpatialCanvasViewer` (and the fullscreen refit in `index.tsx`) gates + on `!isBlocking` and fits to `getWorldBoundsForVisibleLayers()`. It relied on the + `isBlocking` `true→false` transition to fire *exactly* when shapes bounds became + ready. With shapes non-blocking, a **shapes-only** view fires the fit before + bounds exist and never re-fits → opens un-framed until "Center on layer". + Image/points/labels views are unaffected (they still block and provide bounds + first). Fix = decouple "wait for world bounds" from "block first paint", race-safe + against the enable→loading window. Verified live on Visium HD `square_016um`. +- **Tooltip ping-pong (deferred):** reaches the pick/tooltip identity model, not just + the resolver cache — `getTooltipMetadata(key)` is called by element only at 4 + sites, 3 in pick/tooltip handlers where two layers sharing an element with + different `tooltipFields` are inherently ambiguous. Deferred rather than + half-fixed. + +### Phase 2 — the tiled artifact + tiled loader + +8. **Python writer** under `python/spatialdata-experimental-writer/`, a + `shapes` subcommand cloning `points.py` but: DuckDB Hilbert sort on geometry, + GeoParquet 1.1 bbox covering column, zstd, sized row groups. Target + `shapes.experimental//` (ADR 0002 — standard readers can't consume a + re-sorted GeoParquet as canonical). **Open sub-decision:** GeoArrow-native + encoding (zero browser WKB decode) vs WKB (max geopandas interop) — verify the + geopandas round-trip and parquet-wasm→arrow zero-copy layout before committing. +9. **`ShapesTilingMetadata`** + a real **`VShapesSource.loadShapesInBounds`** that + reads footer bbox stats, bisects/prunes row groups, range-reads survivors + (reusing `readParquetRowGroupBytesByGroupIndex`), decodes in the worker to + GeoArrow buffers. +10. **`geoparquetTiledStrategy`** (clone `mortonTiledStrategy.ts`): deck `TileLayer`, + `getTileData` → `loader.loadInBounds({ bounds, signal })`, abort on tile + teardown, per-tile binary `PolygonLayer`. +11. **`resolveShapesRenderResource` + `experimentalOptimizations`** wiring (clone + `resolvePointsRenderResource.ts`); `geoparquet-tiled` selected when the + artifact's tiling metadata is present, else `wkb-full`. **Flip the switch on + in the live adapter** (points is still `'off'`). + +## Sub-decisions to resolve in-flight (not blocking to start) + +- Artifact geometry encoding: GeoArrow-native vs WKB (Phase 2 step 8). +- Worker: extend `points-worker.ts` vs a dedicated `shapes-worker.ts`. +- Row-group granularity: if 100k-row groups prune too coarsely for dense small + polygons, revisit FlatGeobuf as the tiling-only artifact. + +## Unverified flags carried from research + +- parquet-wasm surfacing per-row-group column min/max on its metadata binding + (else source stats from hyparquet). +- `GeoArrowPolygonLayer` MultiPolygon-with-holes correctness (layout supports it; + no explicit test found) — moot if we render with our own binary layer. +- DuckDB version actually emitting GeoParquet 1.1 `covering` metadata. diff --git a/packages/core/package.json b/packages/core/package.json index 54f9bfd0..43a59c69 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,7 +41,8 @@ "apache-arrow": "catalog:", "ol": "^10.6.1", "zarrita": "catalog:", - "zod": "catalog:" + "zod": "catalog:", + "earcut": "^3.0.2" }, "overrides": { "zarrita": "catalog:" diff --git a/packages/core/src/earcut.d.ts b/packages/core/src/earcut.d.ts new file mode 100644 index 00000000..b11de471 --- /dev/null +++ b/packages/core/src/earcut.d.ts @@ -0,0 +1,15 @@ +// earcut v3 ships no type declarations. We use only the default triangulation call. +declare module 'earcut' { + /** + * Triangulate a flat coordinate array into triangle vertex indices. + * @param data Flat vertex coordinates (x0,y0,x1,y1,…). + * @param holeIndices Ring start indices for holes (unused for exterior-only rings). + * @param dim Coordinates per vertex (default 2). + */ + function earcut( + data: ArrayLike, + holeIndices?: ArrayLike | null, + dim?: number + ): number[]; + export default earcut; +} diff --git a/packages/core/src/engine/ShapesResolver.ts b/packages/core/src/engine/ShapesResolver.ts index 54534cf0..ec1fa1fa 100644 --- a/packages/core/src/engine/ShapesResolver.ts +++ b/packages/core/src/engine/ShapesResolver.ts @@ -3,6 +3,7 @@ import type { ShapesRenderData } from '../shapes.js'; import { type AxisAlignedBounds, boundsFromCircles, + boundsFromFlatPolygonPositions, boundsFromPolygons, } from '../spatialViewFit.js'; import type { SpatialData } from '../store/index.js'; @@ -24,7 +25,8 @@ import { SnapshotCache } from './snapshotCache.js'; * rather than per-entry — they fail independently. A shapes entry whose tooltip * column is broken must still draw its geometry. * - * - `geometry` — `element.loadRenderData()`. The only one that blocks first paint. + * - `geometry` — `element.loadRenderData()`. Refines the canvas; blocks nothing + * (see `blockingResources`). The layer draws once it settles. * - `tooltip` — `loadShapesTooltipMetadata()`. Keyed by the tooltip-fields signature. * - `fillColor` — `loadAssociatedTableFeatureRows()`. Keyed by the column name. * @@ -83,11 +85,18 @@ const tooltipSignature = (fields: string[] | undefined): string => (fields ?? [] export class ShapesResolver implements ResourceResolver { readonly kind = 'shapes' as const; /** - * Geometry only. Tooltip and fill colour have never blocked a first paint and - * must not start — which is precisely why `isBlocking` already treats shapes - * differently from points today. That asymmetry was a kind-switch; here it is data. + * **Nothing blocks.** Shapes load non-blocking: geometry refines an + * already-painted canvas rather than gating first paint, so a shapes-only view + * shows immediately and the geometry pops in when it settles. (Tooltip and fill + * colour never blocked either.) This is the whole point of the non-blocking + * pass — `blockingResources` is empty *data*, not a kind-switch, so + * `store.isBlocking` reports shapes as never-blocking with no special case. + * + * The wait is still visible: the resolver reports `geometry: 'loading'` via + * `onStatus`, which drives the host's non-modal `isLoading` indicator — just + * not the modal overlay. */ - readonly blockingResources = ['geometry'] as const; + readonly blockingResources = [] as const; private readonly entries = new Map(); private readonly listeners = new Set<() => void>(); @@ -293,9 +302,11 @@ export class ShapesResolver implements ResourceResolver>; //nb, not totally happy with this type. export type ZarrNumericArray = ZarrTypedArray | BigInt64Array | Array; -export interface ShapesInBoundsOptions { - bounds: SpatialBounds; - zoom?: number; - signal?: AbortSignal; - columns?: string[]; -} - -export type ShapesInBoundsResult = ShapesRenderData & { - bounds: SpatialBounds; - loadMode: 'full-filter'; -}; - // If the array path starts with table/something/rest // capture table/something. @@ -78,12 +71,6 @@ function getParquetPath(arrPath?: string) { throw new Error(`Cannot determine parquet path for shapes array path: ${arrPath}`); } -function checkAbort(signal?: AbortSignal) { - if (signal?.aborted) { - throw new DOMException('The operation was aborted.', 'AbortError'); - } -} - /** * Converts BigInt64Array or Float64Array to Float32Array if needed. * TODO: remove this and support BigInts/Float64s in downstream code. @@ -453,18 +440,22 @@ export default class SpatialDataShapesSource extends SpatialDataTableSource { } const parquetPath = getParquetPath(elementPath); - // loadParquetTable caches the parsed Arrow table, so the three calls below - // (here, inside loadShapesIndex, inside loadPolygonShapes / loadCircleShapes) - // share one WASM decode. - const geometryTable = await this.loadParquetTable(parquetPath); - const geometryKind = inferShapesGeometryKindFromParquet(geometryTable); + const geometryColumnName = 'geometry'; + const geometryKind = await this.inferShapesGeometryKindCheap(parquetPath); + + // The WKB decode is the expensive part; run it off the main thread when the + // worker is enabled. The feature index rides alongside (a cheap column read), + // so both land together. + const [flat, featureIdsRaw] = await Promise.all([ + this.loadFlatShapeGeometry(parquetPath, geometryColumnName, geometryKind), + this.loadShapesIndex(elementPath), + ]); if (geometryKind === 'circle' || geometryKind === 'point') { - const [featureIdsRaw, circleResult] = await Promise.all([ - this.loadShapesIndex(elementPath), - this.loadCircleShapes(`${elementPath}/geometry`), - ]); - const [xs, ys] = circleResult.data; + if (flat.kind !== 'point') { + throw new Error(`Expected point geometry for ${elementPath} but decoded ${flat.kind}`); + } + const { xs, ys } = flat; const featureIds = featureIdsFromIndex(featureIdsRaw, xs.length); if (featureIds.length !== xs.length || featureIds.length !== ys.length) { throw new Error( @@ -483,56 +474,84 @@ export default class SpatialDataShapesSource extends SpatialDataTableSource { } } - // geometryTable is not retained in the return value for wkb-parquet paths: - // all geometry has been decoded into typed arrays above, and keeping a - // second copy of the full Arrow table would waste heap memory. return { kind: 'wkb-parquet', geometryKind, elementKey, featureIds, circles: { positions: [xs, ys], radii }, - geometryColumnName: 'geometry', + geometryColumnName, rowIndexByFeatureIndex: new Int32Array(featureIds.length).fill(-1), }; } - const [featureIdsRaw, polygonResult] = await Promise.all([ - this.loadShapesIndex(elementPath), - this.loadPolygonShapes(`${elementPath}/geometry`), - ]); - const polygons = polygonResult.data; - const featureIds = featureIdsFromIndex(featureIdsRaw, polygons.length); - if (featureIds.length !== polygons.length) { + if (flat.kind !== 'polygon') { + throw new Error(`Expected polygon geometry for ${elementPath} but decoded ${flat.kind}`); + } + const featureIds = featureIdsFromIndex(featureIdsRaw, flat.featureCount); + if (featureIds.length !== flat.featureCount) { throw new Error( - `Feature id count (${featureIds.length}) did not match polygon count (${polygons.length}) for ${elementPath}` + `Feature id count (${featureIds.length}) did not match polygon count (${flat.featureCount}) for ${elementPath}` ); } - // geometryTable is not retained for the same reason as the circle path above. return { - kind: 'wkb-parquet', + kind: 'flat-polygons', geometryKind: 'polygon', elementKey, featureIds, - polygons, - geometryColumnName: 'geometry', + polygonBinary: { + positions: flat.positions, + startIndices: flat.startIndices, + tessellation: flat.tessellation, + }, + geometryColumnName, rowIndexByFeatureIndex: new Int32Array(featureIds.length).fill(-1), }; } - async loadShapesInBounds( - elementPath: string, - options: ShapesInBoundsOptions - ): Promise { - checkAbort(options.signal); - const renderData = await this.loadShapesRenderData(elementPath); - checkAbort(options.signal); - return { - ...renderData, - bounds: options.bounds, - loadMode: 'full-filter', - }; + /** + * Infer polygon-vs-circle-vs-point from the parquet **schema** alone (field + * names + geopandas `geo` metadata), so the worker decode path never triggers a + * full main-thread table decode just to classify. Falls back to a full decode + * only when the store cannot serve schema-only reads. + */ + private async inferShapesGeometryKindCheap(parquetPath: string): Promise { + const schemaTable = await this.loadParquetSchemaTable(parquetPath); + if (schemaTable) { + return inferShapesGeometryKindFromParquet(schemaTable); + } + const table = await this.loadParquetTable(parquetPath); + return inferShapesGeometryKindFromParquet(table); + } + + /** + * Decode the geometry column into flat, transferable buffers — off the main + * thread when the points worker is enabled, else via the identical shared + * decode on the main thread. The worker copy is deliberate: `loadParquetBytes` + * caches its result, and transferring the buffer would detach the cache. + */ + private async loadFlatShapeGeometry( + parquetPath: string, + geometryColumnName: string, + geometryKind: ShapesGeometryKind + ): Promise { + ensurePointsWorker(); + if (isPointsWorkerEnabled()) { + const bytes = await this.loadParquetBytes(parquetPath); + if (bytes) { + const decoded = await decodeShapesGeometryInWorker({ + parts: [bytes.slice()], + geometryColumnName, + geometryKind, + }); + if (decoded) { + return decoded; + } + } + } + const table = await this.loadParquetTable(parquetPath); + return decodeShapesGeometryFlat(table, geometryColumnName, geometryKind); } /** diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 53e1d4eb..06fcfee9 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -495,10 +495,6 @@ export class ShapesElement extends AbstractSpatialElement<'shapes', ShapesAttrs> }); return renderData; } - - async loadShapesInBounds(options: Parameters[1]) { - return this.vShapes.loadShapesInBounds(`shapes/${this.key}`, options); - } } // ============================================ diff --git a/packages/core/src/shapes.ts b/packages/core/src/shapes.ts index 3e30fee4..a14e9cf6 100644 --- a/packages/core/src/shapes.ts +++ b/packages/core/src/shapes.ts @@ -1,4 +1,5 @@ import type { Table as ArrowTable } from 'apache-arrow'; +import type { TessellatedPolygons } from './shapesPolygonTessellate.js'; export type ShapePolygon = Array>; @@ -10,7 +11,28 @@ export interface ShapeCircleColumnar { radii?: Float32Array; } -export type ShapesGeometryRepresentationKind = 'js-polygons' | 'wkb-parquet' | 'geoarrow-table'; +export type ShapesGeometryRepresentationKind = + | 'js-polygons' + | 'flat-polygons' + | 'wkb-parquet' + | 'geoarrow-table'; + +/** + * Flat GeoArrow-style polygon geometry: interleaved exterior-ring coordinates plus + * per-feature vertex offsets. This is the transferable representation the worker + * produces and a binary deck.gl `PolygonLayer` consumes directly — no per-vertex + * JS object. Preferred over {@link ShapesRenderData.polygons} when present. + */ +export interface FlatPolygonGeometry { + /** Interleaved `[x0, y0, x1, y1, …]` exterior-ring coordinates for all features. */ + positions: Float32Array; + /** Vertex offset where each feature begins; length `featureCount + 1`. */ + startIndices: Int32Array; + /** Vertex-pulling render topology, tessellated off the main thread by the geometry + * worker. Present on the worker path; absent on the main-thread decode path, where + * the renderer tessellates lazily from `positions`/`startIndices`. */ + tessellation?: TessellatedPolygons; +} export interface ShapesRenderData { kind: ShapesGeometryRepresentationKind; @@ -18,7 +40,10 @@ export interface ShapesRenderData { geometryKind: ShapesGeometryKind; elementKey: string; featureIds: string[]; + /** Legacy nested polygons (main-thread decode / compat). Prefer `polygonBinary`. */ polygons?: ShapePolygon[]; + /** Transferable flat polygon geometry. Set by the off-thread decode path. */ + polygonBinary?: FlatPolygonGeometry; circles?: ShapeCircleColumnar; geometryTable?: ArrowTable; geometryColumnName?: string; diff --git a/packages/core/src/shapesGeometryDecode.ts b/packages/core/src/shapesGeometryDecode.ts new file mode 100644 index 00000000..be9c69c5 --- /dev/null +++ b/packages/core/src/shapesGeometryDecode.ts @@ -0,0 +1,177 @@ +// Worker-safe WKB → flat-buffer shape geometry decode. +// +// This is the CPU-heavy half of shapes loading — the WKB parse — factored out of +// `VShapesSource` so it can run *either* on the main thread (fallback) or inside +// the points worker. It depends only on `apache-arrow` + `ol/format/WKB`, never on +// zarrita/the store, so it is safe to import into the worker bundle. +// +// It produces **flat GeoArrow-style typed arrays** (interleaved coordinates + +// vertex offsets) rather than the historical `Array>`. +// Flat buffers are transferable across the worker boundary zero-copy and feed a +// binary deck.gl `PolygonLayer` directly — no per-vertex JS object ever exists. + +import type { Table as ArrowTable } from 'apache-arrow'; +import type { Vector } from 'apache-arrow/vector'; +import WKB from 'ol/format/WKB.js'; +import type { TessellatedPolygons } from './shapesPolygonTessellate.js'; + +export interface FlatPolygonGeometry { + kind: 'polygon'; + /** Interleaved exterior-ring coordinates `[x0, y0, x1, y1, …]` for all features. */ + positions: Float32Array; + /** + * Vertex offset where each feature's ring begins; length `featureCount + 1`, + * last entry = total vertex count. This is deck.gl's binary `startIndices`. + */ + startIndices: Int32Array; + featureCount: number; + /** Vertex-pulling render topology, tessellated in the worker. Absent on the + * main-thread decode path (the renderer tessellates lazily there). */ + tessellation?: TessellatedPolygons; +} + +export interface FlatPointGeometry { + kind: 'point'; + xs: Float32Array; + ys: Float32Array; + featureCount: number; +} + +export type FlatShapeGeometry = FlatPolygonGeometry | FlatPointGeometry; + +/** Extract the geometry column and assert it is Arrow `Binary` (WKB bytes). */ +export function getBinaryGeometryColumn(table: ArrowTable, columnName: string): Vector { + const geometryColumn = table.getChild(columnName); + if (!geometryColumn) { + throw new Error(`Column ${columnName} not found in parquet table`); + } + if (geometryColumn.type.toString() !== 'Binary') { + throw new Error( + `Expected geometry column to have Binary type but got ${geometryColumn.type.toString()}` + ); + } + return geometryColumn; +} + +/** + * Whether the geometry column is WKB-encoded. Mirrors GeoPandas' convention: the + * `ARROW:extension:name` field metadata is `geoarrow.wkb` when present, and absent + * metadata (pre-1.0.0 geopandas) is treated as WKB. GeoArrow-native encodings are + * not yet supported here (that is the tiled-artifact path — see ADR 0002). + */ +export function isWkbColumn(table: ArrowTable, columnName: string): boolean { + const encoding = table.schema.fields + .find((field) => field.name === columnName) + ?.metadata?.get('ARROW:extension:name'); + if (!encoding) { + return true; + } + return encoding === 'geoarrow.wkb'; +} + +/** + * The exterior ring of a decoded WKB geometry's coordinate tree, matching the + * historical `getCoordinates()[0]` behaviour exactly: + * + * - Polygon → `coords[0]` IS the exterior ring (`[[x,y], …]`). Holes dropped. + * - MultiPolygon → `coords[0]` is the first sub-polygon's rings; take its + * exterior (`coords[0][0]`). Remaining sub-polygons + holes dropped. + * + * Returns `null` for anything that doesn't shape like a ring, so the caller can + * emit an empty feature rather than throw on stray geometry. + */ +function exteriorRing(coordinateTree: unknown): ReadonlyArray | null { + if (!Array.isArray(coordinateTree) || coordinateTree.length === 0) { + return null; + } + const first = coordinateTree[0]; + if (Array.isArray(first) && typeof first[0] === 'number') { + // coordinateTree is already a ring: [[x, y], …] + return coordinateTree as ReadonlyArray; + } + if (Array.isArray(first) && Array.isArray(first[0])) { + // coordinateTree is an array of rings (MultiPolygon's first sub-polygon). + return first as ReadonlyArray; + } + return null; +} + +/** + * Decode a WKB polygon column into flat exterior-ring positions + `startIndices`. + * + * Two passes so the output buffers are exactly sized (no oversized allocation, + * no per-feature array growth): count vertices first, then fill. The nested + * `getCoordinates()` allocation is transient and — on the worker path — off the + * main thread. + */ +export function decodeWkbPolygonColumnFlat(geometryColumn: Vector): FlatPolygonGeometry { + const wkb = new WKB(); + const raw = geometryColumn.toArray() as ArrayLike; + const featureCount = raw.length; + + const rings: Array | null> = new Array(featureCount); + const startIndices = new Int32Array(featureCount + 1); + let totalVertices = 0; + for (let i = 0; i < featureCount; i += 1) { + const geometry = wkb.readGeometry(raw[i]) as unknown as { getCoordinates: () => unknown }; + const ring = exteriorRing(geometry.getCoordinates()); + rings[i] = ring; + startIndices[i] = totalVertices; + totalVertices += ring ? ring.length : 0; + } + startIndices[featureCount] = totalVertices; + + const positions = new Float32Array(totalVertices * 2); + let cursor = 0; + for (let i = 0; i < featureCount; i += 1) { + const ring = rings[i]; + if (!ring) { + continue; + } + for (let v = 0; v < ring.length; v += 1) { + positions[cursor++] = Number(ring[v][0]); + positions[cursor++] = Number(ring[v][1]); + } + } + + return { kind: 'polygon', positions, startIndices, featureCount }; +} + +/** + * Decode a WKB point column into flat `xs`/`ys` (one coordinate per feature). + * Mirrors the historical `_decodeWkbColumnFlat`: the first flat coordinate pair. + */ +export function decodeWkbPointColumnFlat(geometryColumn: Vector): FlatPointGeometry { + const wkb = new WKB(); + const raw = geometryColumn.toArray() as ArrayLike; + const featureCount = raw.length; + const xs = new Float32Array(featureCount); + const ys = new Float32Array(featureCount); + for (let i = 0; i < featureCount; i += 1) { + const flat = ( + wkb.readGeometry(raw[i]) as unknown as { getFlatCoordinates: () => Array } + ).getFlatCoordinates(); + xs[i] = Number(flat[0]); + ys[i] = Number(flat[1]); + } + return { kind: 'point', xs, ys, featureCount }; +} + +/** + * Decode a WKB geometry column into flat buffers, dispatched by geometry kind. + * `circle` and `point` both decode to point coordinates (a circle is a point plus + * a separately-loaded radius column). + */ +export function decodeShapesGeometryFlat( + table: ArrowTable, + columnName: string, + geometryKind: 'polygon' | 'circle' | 'point' +): FlatShapeGeometry { + const column = getBinaryGeometryColumn(table, columnName); + if (!isWkbColumn(table, columnName)) { + throw new Error('Unexpected encoding type for shapes geometry; only WKB is supported'); + } + return geometryKind === 'polygon' + ? decodeWkbPolygonColumnFlat(column) + : decodeWkbPointColumnFlat(column); +} diff --git a/packages/core/src/shapesLoader.ts b/packages/core/src/shapesLoader.ts new file mode 100644 index 00000000..ffa7669c --- /dev/null +++ b/packages/core/src/shapesLoader.ts @@ -0,0 +1,134 @@ +import type { ShapesElement } from './models/index.js'; +import type { SpatialBounds } from './pointsTiling.js'; +import type { ShapesRenderData } from './shapes.js'; + +/** + * The shapes loader seam (ADR 0003 "Render Resource", mirrored from + * {@link ./pointsLoader.ts}). It is the deck-free half of the shapes render + * resource: a strategy that turns a bounds request into a batch, dispatched on + * {@link ShapesLoaderCapabilities.kind}. + * + * ## Deliberately stateless — this is the concurrency contract + * + * A loader holds **no mutable per-request state** and keeps **no cache**. It + * either wraps an already-immutable batch (nothing to load) or delegates each + * call straight to the element. All lifecycle — dedup, supersession, + * cancellation, the resident cache — lives in `ShapesResolver`, exactly as it + * does for points. That split is the whole reason the points races were keying + * bugs in *one* place (the resolver) and never in the loader: there is no + * mid-flight field here to flip, no second cache to fall out of sync. Keep it + * that way. A batch is always built fresh and never mutated after return, so its + * object identity is a sound invalidation key. + * + * ## Encodings + * + * - `wkb-full` — today's honest full-element load. `supportsViewportTiles: + * false`; `loadInBounds` ignores `bounds` and returns the whole element, + * which is truthful given the capability flag. The only loader that exists in + * Phase 0. + * - `geoparquet-tiled` / `geoarrow-tiled` — reserved. The viewport-pruning + * loader over a Hilbert-sorted GeoParquet artifact, and its GeoArrow-native + * variant. Phase 2. See `docs/plans/shapes-nonblocking-tiled-loading.md`. + */ + +export type ShapesEncodingKind = 'wkb-full' | 'geoparquet-tiled' | 'geoarrow-tiled'; + +/** + * How a {@link ShapesBatch} carries its geometry. + * + * - `decoded-render-data` — today's {@link ShapesRenderData} (JS polygon arrays + * / columnar circles), produced on the main thread. The Phase 0 format. + * - `geoarrow-buffers` — transferable GeoArrow coordinate + offset buffers from + * the worker decode. Reserved for Phase 1; the union grows then. + */ +export type ShapesBatchFormat = 'decoded-render-data' | 'geoarrow-buffers'; + +export interface ShapesLoaderCapabilities { + kind: ShapesEncodingKind; + batchFormat: ShapesBatchFormat; + /** Known world bounds, if the loader has them at construction. `wkb-full` does + * not (nothing is loaded yet), so the resolver computes bounds from the loaded + * geometry instead. A tiled loader reads them from the artifact metadata. */ + bounds?: SpatialBounds; + supportsViewportTiles: boolean; +} + +/** + * A loaded unit of shapes geometry. Tagged by {@link ShapesBatchFormat} so Phase + * 1 can add a `geoarrow-buffers` variant without touching {@link CoreShapesLoader}. + * Immutable once built. + */ +export interface DecodedShapesBatch { + format: 'decoded-render-data'; + renderData: ShapesRenderData; + /** Bounds this batch was requested for, echoed for a tiled consumer. Absent on + * a full load — the batch IS the whole element. */ + bounds?: SpatialBounds; +} + +export type ShapesBatch = DecodedShapesBatch; + +export interface ShapesLoadInBoundsOptions { + bounds: SpatialBounds; + signal?: AbortSignal; +} + +export interface CoreShapesLoader { + readonly capabilities: ShapesLoaderCapabilities; + /** Load the geometry overlapping `bounds`. A non-tiling loader + * (`supportsViewportTiles: false`) honestly returns the whole element. */ + loadInBounds(options: ShapesLoadInBoundsOptions): Promise; + /** Load the whole element. Present on non-tiling loaders. */ + loadAll?(options?: { signal?: AbortSignal }): Promise; +} + +/** + * The full-element WKB loader — honest about not tiling. Stateless: it delegates + * every call to `element.loadRenderData()`, whose decode cache (and the + * resolver's resident `geometry` resolution) are the *only* caches. It adds none + * of its own. + */ +export function createFullShapesLoader(element: ShapesElement): CoreShapesLoader { + const capabilities: ShapesLoaderCapabilities = { + kind: 'wkb-full', + batchFormat: 'decoded-render-data', + supportsViewportTiles: false, + }; + + const load = async (signal?: AbortSignal): Promise => { + signal?.throwIfAborted?.(); + const renderData = await element.loadRenderData(); + signal?.throwIfAborted?.(); + return { format: 'decoded-render-data', renderData }; + }; + + return { + capabilities, + // `bounds` is ignored on purpose: a full loader has one batch, the whole + // element. `supportsViewportTiles: false` is what tells a consumer this. + async loadInBounds(options: ShapesLoadInBoundsOptions): Promise { + return load(options.signal); + }, + async loadAll(options?: { signal?: AbortSignal }): Promise { + return load(options?.signal); + }, + }; +} + +/** + * Pick the encoding for an element. Phase 0 has one answer; the `wantsOptimized` + * / tiling-metadata branch is reserved for Phase 2 and intentionally not wired + * yet, so there is no half-built tiled path to reason about. + */ +export function resolveShapesEncoding(): ShapesEncodingKind { + return 'wkb-full'; +} + +/** + * Build the loader for an element. Phase 0: always the full WKB loader. Phase 2 + * adds the `geoparquet-tiled` branch, gated on a `ShapesTilingMetadata` that does + * not exist yet. + */ +export function createShapesLoaderForElement(element: ShapesElement): CoreShapesLoader { + return createFullShapesLoader(element); +} diff --git a/packages/core/src/shapesPolygonTessellate.ts b/packages/core/src/shapesPolygonTessellate.ts new file mode 100644 index 00000000..4ae40f87 --- /dev/null +++ b/packages/core/src/shapesPolygonTessellate.ts @@ -0,0 +1,158 @@ +/** + * Tessellate flat polygon rings into **compact topology** for a vertex-pulling + * renderer (`FlatPolygonLayer`): a shared ring-position array plus a per-triangle + * record. The vertex shader reconstructs each vertex's position and its boundary + * edge-distance from `gl_VertexID` + these two buffers (uploaded as textures), so + * nothing is stored per de-indexed vertex — positions are shared, and the + * edge-distance is *computed*, not stored. + * + * THE OUTLINE ENCODING. A filled polygon is earcut into triangles; a fragment has no + * idea where the real polygon boundary is, and a naive wireframe would light up the + * internal earcut edges (diagonals). We record, per triangle, which of its three + * edges are true polygon-boundary edges (an edge is a boundary edge iff its two + * endpoints are consecutive in the ring, mod ring length). The shader turns those + * flags into a `vec3` of edge-distances — the triangle height at the opposite vertex + * for a boundary edge, a large sentinel for an internal one — so `min(vec3)` in the + * fragment is the distance to the nearest *boundary* edge, and only the outline draws. + * + * Rings are exterior-only and simple (no holes) — matching the WKB decode, which + * extracts `getCoordinates()[0]`. Lives in core so the geometry worker can run it + * right after decoding and transfer the buffers back (off the main thread); the + * renderer keeps a main-thread fallback for the no-worker path. + */ + +import earcutModule from 'earcut'; + +// earcut v3 ships as an ES module; tolerate both default and namespace interop. +const earcut = + (earcutModule as unknown as { default?: typeof earcutModule }).default ?? earcutModule; + +export interface TessellatedPolygons { + /** Shared ring vertices, interleaved XY (closing duplicates dropped). Length + * `2 * ringVertexCount`. Indexed by the triangle records. */ + ringPositions: Float32Array; + /** Per-triangle record `[g0, g1, g2, feature*8 + boundaryFlags]`, where `g*` are + * ring-vertex indices and the low 3 bits flag which edges are boundary edges + * (bit0: edge BC = g1→g2, bit1: edge CA = g2→g0, bit2: edge AB = g0→g1). Length + * `4 * triangleCount`. */ + triangleData: Uint32Array; + triangleCount: number; + ringVertexCount: number; + /** Per-feature characteristic size (√area, world units), one per ring/feature. The + * shader uses it to keep the outline from dominating when a shape is small on + * screen. Length `ringCount`. */ + featureScale: Float32Array; +} + +/** True if ring vertices `p` and `q` are adjacent in a ring of `n` (mod n). */ +function isBoundaryEdge(p: number, q: number, n: number): boolean { + const d = Math.abs(p - q); + return d === 1 || d === n - 1; +} + +/** + * Tessellate flat polygon rings (interleaved positions + per-ring `startIndices`) + * into shared ring positions + per-triangle topology. Non-polygon / degenerate rings + * (< 3 distinct vertices) are skipped. + */ +export function tessellateFlatPolygons( + positions: Float32Array, + startIndices: Int32Array +): TessellatedPolygons { + const ringCount = Math.max(0, startIndices.length - 1); + + // Pass 1: per-ring unique length + earcut; count triangles and ring vertices. + let triangleCount = 0; + let ringVertexCount = 0; + const ringTris: (number[] | Uint32Array)[] = new Array(ringCount); + const ringLen = new Int32Array(ringCount); + const ringVertStart = new Int32Array(ringCount); + for (let r = 0; r < ringCount; r += 1) { + const start = startIndices[r]; + const end = startIndices[r + 1]; + let n = end - start; + // Drop a closing duplicate vertex (WKB rings are closed: last == first). + if (n >= 2) { + const fx = positions[start * 2]; + const fy = positions[start * 2 + 1]; + const lx = positions[(end - 1) * 2]; + const ly = positions[(end - 1) * 2 + 1]; + if (fx === lx && fy === ly) { + n -= 1; + } + } + ringVertStart[r] = ringVertexCount; + ringLen[r] = n; + if (n < 3) { + ringTris[r] = []; + continue; + } + ringVertexCount += n; + const coords = new Float64Array(n * 2); + for (let i = 0; i < n; i += 1) { + coords[i * 2] = positions[(start + i) * 2]; + coords[i * 2 + 1] = positions[(start + i) * 2 + 1]; + } + const tris = earcut(coords); + ringTris[r] = tris; + triangleCount += tris.length / 3; + } + + const ringPositions = new Float32Array(ringVertexCount * 2); + const triangleData = new Uint32Array(triangleCount * 4); + + // Fill the shared ring positions. + for (let r = 0; r < ringCount; r += 1) { + const n = ringLen[r]; + if (n < 3) { + continue; + } + const start = startIndices[r]; + const base = ringVertStart[r]; + for (let i = 0; i < n; i += 1) { + ringPositions[(base + i) * 2] = positions[(start + i) * 2]; + ringPositions[(base + i) * 2 + 1] = positions[(start + i) * 2 + 1]; + } + } + + // Fill the per-triangle topology and accumulate each feature's area. + const featureScale = new Float32Array(ringCount); + let t = 0; + for (let r = 0; r < ringCount; r += 1) { + const tris = ringTris[r]; + if (!tris || tris.length === 0) { + continue; + } + const n = ringLen[r]; + const base = ringVertStart[r]; + const start = startIndices[r]; + let area = 0; + for (let k = 0; k < tris.length; k += 3) { + const ia = tris[k]; + const ib = tris[k + 1]; + const ic = tris[k + 2]; + const bd0 = isBoundaryEdge(ib, ic, n) ? 1 : 0; // BC (opposite A) + const bd1 = isBoundaryEdge(ic, ia, n) ? 1 : 0; // CA (opposite B) + const bd2 = isBoundaryEdge(ia, ib, n) ? 1 : 0; // AB (opposite C) + const flags = bd0 | (bd1 << 1) | (bd2 << 2); + triangleData[t * 4] = base + ia; + triangleData[t * 4 + 1] = base + ib; + triangleData[t * 4 + 2] = base + ic; + // feature index is the ring index; pack it above the 3 flag bits. + triangleData[t * 4 + 3] = r * 8 + flags; + t += 1; + + const ax = positions[(start + ia) * 2]; + const ay = positions[(start + ia) * 2 + 1]; + const bx = positions[(start + ib) * 2]; + const by = positions[(start + ib) * 2 + 1]; + const cx = positions[(start + ic) * 2]; + const cy = positions[(start + ic) * 2 + 1]; + area += Math.abs((bx - ax) * (cy - ay) - (by - ay) * (cx - ax)) * 0.5; + } + // √area ≈ the shape's characteristic side/diameter in world units. + featureScale[r] = Math.sqrt(area); + } + + return { ringPositions, triangleData, triangleCount, ringVertexCount, featureScale }; +} diff --git a/packages/core/src/spatialViewFit.ts b/packages/core/src/spatialViewFit.ts index 6919a599..b544698e 100644 --- a/packages/core/src/spatialViewFit.ts +++ b/packages/core/src/spatialViewFit.ts @@ -149,6 +149,44 @@ export function boundsFromPolygons( return accumulatePolygonBounds(polygons, modelMatrix, 0); } +/** + * Axis-aligned bounds for flat interleaved polygon positions (`[x0, y0, x1, y1, + * …]`), the transferable geometry representation. A single pass over the buffer — + * no per-vertex JS objects to walk. + */ +export function boundsFromFlatPolygonPositions( + positions: ArrayLike, + modelMatrix: Matrix4 +): AxisAlignedBounds | null { + const vertexCount = Math.floor(positions.length / 2); + if (vertexCount === 0) return null; + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let any = false; + + for (let i = 0; i < vertexCount; i += 1) { + const x = positions[i * 2]; + const y = positions[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + try { + const p = modelMatrix.transformPoint([x, y, 0]); + if (!Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue; + any = true; + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } catch { + // ignore bad transform + } + } + + return any ? { minX, minY, maxX, maxY } : null; +} + /** * Axis-aligned bounds for circle shapes (center + radius in store coordinates). */ diff --git a/packages/core/src/workers/index.ts b/packages/core/src/workers/index.ts index 07a5a2fb..938de99c 100644 --- a/packages/core/src/workers/index.ts +++ b/packages/core/src/workers/index.ts @@ -5,6 +5,7 @@ export { decodeParquetGeometryCappedInWorker, decodeParquetPartsInWorker, decodeParquetRowFeatureCodesInWorker, + decodeShapesGeometryInWorker, disablePointsWorker, enablePointsWorker, ensurePointsWorker, diff --git a/packages/core/src/workers/points-worker.ts b/packages/core/src/workers/points-worker.ts index 5594eec9..3fa7b7ac 100644 --- a/packages/core/src/workers/points-worker.ts +++ b/packages/core/src/workers/points-worker.ts @@ -2,6 +2,8 @@ import { tableFromIPC, tableToIPC } from 'apache-arrow'; import { getParquetModule, type ParquetModule } from '../parquetWasmLoader.js'; import { buildFeatureCatalogFromColumns } from '../pointsFeatures.js'; import { filterColumnarByFeatureCodes } from '../pointsTiling.js'; +import { decodeShapesGeometryFlat } from '../shapesGeometryDecode.js'; +import { tessellateFlatPolygons } from '../shapesPolygonTessellate.js'; import type { PointsWorkerMessage, PointsWorkerRequest, @@ -444,6 +446,46 @@ function handleBuildFeatureCatalog( return { ok: true, result: { kind: 'catalog', catalog } }; } +async function handleDecodeShapesGeometry( + request: Extract +): Promise { + const { readParquet } = await getParquetModule(); + // Project only the geometry column: the feature index / row-index columns are + // cheap and stay on the main thread; the WKB parse is the expensive part. + const table = await decodeParquetPartsToTable(readParquet, request.parts, [ + request.geometryColumnName, + ]); + const geometry = decodeShapesGeometryFlat( + table, + request.geometryColumnName, + request.geometryKind + ); + if (geometry.kind === 'polygon') { + // Tessellate here, off the main thread, so the render topology transfers back + // ready to upload — no ~seconds-long main-thread tessellation on first paint. + const tessellation = tessellateFlatPolygons(geometry.positions, geometry.startIndices); + return { + ok: true, + result: { + kind: 'shapesGeometryPolygon', + positions: geometry.positions, + startIndices: geometry.startIndices, + featureCount: geometry.featureCount, + tessellation, + }, + }; + } + return { + ok: true, + result: { + kind: 'shapesGeometryPoint', + xs: geometry.xs, + ys: geometry.ys, + featureCount: geometry.featureCount, + }, + }; +} + async function handleRequest(request: PointsWorkerRequest): Promise { switch (request.type) { case 'filterColumnarByFeatureCodes': @@ -468,6 +510,8 @@ async function handleRequest(request: PointsWorkerRequest): Promise) => { transferables.push(response.result.codes.buffer); } else if (response.result.kind === 'featureCounts') { transferables.push(response.result.codes.buffer, response.result.counts.buffer); + } else if (response.result.kind === 'shapesGeometryPolygon') { + transferables.push(response.result.positions.buffer, response.result.startIndices.buffer); + const tess = response.result.tessellation; + if (tess) { + transferables.push( + tess.ringPositions.buffer, + tess.triangleData.buffer, + tess.featureScale.buffer + ); + } + } else if (response.result.kind === 'shapesGeometryPoint') { + transferables.push(response.result.xs.buffer, response.result.ys.buffer); } } self.postMessage(reply, transferables); diff --git a/packages/core/src/workers/pointsWorkerClient.ts b/packages/core/src/workers/pointsWorkerClient.ts index e694303a..8a60e79c 100644 --- a/packages/core/src/workers/pointsWorkerClient.ts +++ b/packages/core/src/workers/pointsWorkerClient.ts @@ -1,5 +1,6 @@ import { tableFromIPC } from 'apache-arrow'; import type { PointsFeatureCatalog } from '../pointsTiling.js'; +import type { FlatShapeGeometry } from '../shapesGeometryDecode.js'; import type { PointsColumnarData } from '../spatialViewFit.js'; import { columnarDataFromWorkerResult, @@ -147,6 +148,8 @@ function transferablesForRequest(request: PointsWorkerRequest): Transferable[] { return transferablesForParquetPayload(request.parts, request.rowGroups); case 'scanMortonRowGroupsInBounds': return transferablesForParquetPayload(undefined, request.rowGroups); + case 'decodeShapesGeometry': + return transferablesForParquetPayload(request.parts); } return []; } @@ -401,6 +404,48 @@ export async function decodeGeometryWithFeaturesInWorker( }; } +export type DecodeShapesGeometryInput = { + parts: Uint8Array[]; + geometryColumnName: string; + geometryKind: 'polygon' | 'circle' | 'point'; +}; + +/** + * Off-thread shapes geometry decode: parse the WKB geometry column into flat + * transferable buffers in the worker, so the CPU-heavy WKB parse never blocks the + * main thread. Returns `null` when the worker is disabled or there are no bytes — + * the caller falls back to the identical main-thread decode. + */ +export async function decodeShapesGeometryInWorker( + input: DecodeShapesGeometryInput +): Promise { + ensurePointsWorker(); + if (!isPointsWorkerEnabled() || input.parts.length === 0) { + return null; + } + const request: Extract = { + type: 'decodeShapesGeometry', + ...input, + }; + const result = await postRequest['result']>( + request, + transferablesForRequest(request) + ); + if (result.kind === 'shapesGeometryPolygon') { + return { + kind: 'polygon', + positions: result.positions, + startIndices: result.startIndices, + featureCount: result.featureCount, + tessellation: result.tessellation, + }; + } + if (result.kind === 'shapesGeometryPoint') { + return { kind: 'point', xs: result.xs, ys: result.ys, featureCount: result.featureCount }; + } + throw new Error('Unexpected points worker response for decodeShapesGeometry'); +} + export async function countFeatureCodesInWorker( sourceFeatureCodes: ArrayLike ): Promise> { diff --git a/packages/core/src/workers/pointsWorkerProtocol.ts b/packages/core/src/workers/pointsWorkerProtocol.ts index 0dde7825..bd761425 100644 --- a/packages/core/src/workers/pointsWorkerProtocol.ts +++ b/packages/core/src/workers/pointsWorkerProtocol.ts @@ -1,4 +1,5 @@ import type { PointsFeatureCatalog } from '../pointsTiling.js'; +import type { TessellatedPolygons } from '../shapesPolygonTessellate.js'; import type { PointsColumnarData } from '../spatialViewFit.js'; export type ParquetRowGroupBytesChunk = { @@ -118,6 +119,16 @@ export type PointsWorkerRequest = mortonCodeColumnName: string; featureCodeColumnName?: string; featureCodes?: readonly number[]; + } + | { + // Shapes geometry decode. The points worker is host to this too — see + // `shapesGeometryDecode.ts`. If this generality holds the worker should be + // renamed to a `parquet-worker`; deferred to avoid churning the points + // worktree twice. + type: 'decodeShapesGeometry'; + parts: Uint8Array[]; + geometryColumnName: string; + geometryKind: 'polygon' | 'circle' | 'point'; }; export type PointsWorkerColumnarResult = { @@ -153,7 +164,16 @@ export type PointsWorkerResponse = | { kind: 'parquetTable'; tableIpc: Uint8Array } | { kind: 'catalog'; catalog: PointsFeatureCatalog } | { kind: 'rowFeatureCodes'; codes: Int32Array; numRows: number } - | { kind: 'featureCounts'; codes: Int32Array; counts: Uint32Array }; + | { kind: 'featureCounts'; codes: Int32Array; counts: Uint32Array } + | { + kind: 'shapesGeometryPolygon'; + positions: Float32Array; + startIndices: Int32Array; + featureCount: number; + /** Vertex-pulling render topology, tessellated in the worker. */ + tessellation?: TessellatedPolygons; + } + | { kind: 'shapesGeometryPoint'; xs: Float32Array; ys: Float32Array; featureCount: number }; } | { ok: false; error: string }; diff --git a/packages/core/tests/shapesGeometryDecode.spec.ts b/packages/core/tests/shapesGeometryDecode.spec.ts new file mode 100644 index 00000000..9f944cdc --- /dev/null +++ b/packages/core/tests/shapesGeometryDecode.spec.ts @@ -0,0 +1,87 @@ +import type { Vector } from 'apache-arrow/vector'; +import WKB from 'ol/format/WKB.js'; +import Point from 'ol/geom/Point.js'; +import Polygon from 'ol/geom/Polygon.js'; +import { describe, expect, it } from 'vitest'; +import { + decodeWkbPointColumnFlat, + decodeWkbPolygonColumnFlat, +} from '../src/shapesGeometryDecode.js'; + +/** + * The WKB→flat-buffer decode, the CPU-heavy half of shapes loading that now runs + * off-thread. The claims under test are the flattening ones: exterior-ring + * coordinates land interleaved in `positions`, and `startIndices` slices them + * back into features (deck.gl's binary polygon contract). Round-tripped through + * ol's own WKB writer so it exercises the real parse, not a hand-rolled fixture. + */ + +function toBytes(written: string | Uint8Array): Uint8Array { + if (written instanceof Uint8Array) { + return written; + } + // ol writes WKB as a hex string; parquet delivers bytes, so decode to match. + const bytes = new Uint8Array(written.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(written.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function wkbColumn(geometries: Array): Vector { + const wkb = new WKB(); + const buffers = geometries.map((g) => toBytes(wkb.writeGeometry(g))); + return { toArray: () => buffers } as unknown as Vector; +} + +describe('decodeWkbPolygonColumnFlat', () => { + it('flattens exterior rings and indexes them per feature', () => { + // A triangle (3 verts) and a square (4 verts). Rings are closed in WKB but ol + // returns the ring as written; assert against what the writer round-trips. + const triangle = new Polygon([ + [ + [0, 0], + [2, 0], + [1, 2], + [0, 0], + ], + ]); + const square = new Polygon([ + [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + [10, 10], + ], + ]); + + const result = decodeWkbPolygonColumnFlat(wkbColumn([triangle, square])); + + expect(result.kind).toBe('polygon'); + expect(result.featureCount).toBe(2); + // startIndices are vertex offsets: feature 0 starts at 0, feature 1 after the + // triangle's ring, and the terminal entry is the total vertex count. + const triangleVerts = result.startIndices[1]; + expect(result.startIndices[0]).toBe(0); + expect(result.startIndices[2]).toBe(result.positions.length / 2); + // Each vertex is two floats. + expect(result.positions.length).toBe(result.startIndices[2] * 2); + // Feature 0's first vertex is the triangle's first coordinate. + expect(result.positions[0]).toBe(0); + expect(result.positions[1]).toBe(0); + // Feature 1's first vertex is the square's first coordinate. + expect(result.positions[triangleVerts * 2]).toBe(10); + expect(result.positions[triangleVerts * 2 + 1]).toBe(10); + }); +}); + +describe('decodeWkbPointColumnFlat', () => { + it('decodes one coordinate per feature into xs/ys', () => { + const result = decodeWkbPointColumnFlat(wkbColumn([new Point([3, 4]), new Point([5, 6])])); + expect(result.kind).toBe('point'); + expect(result.featureCount).toBe(2); + expect(Array.from(result.xs)).toEqual([3, 5]); + expect(Array.from(result.ys)).toEqual([4, 6]); + }); +}); diff --git a/packages/core/tests/shapesLoader.spec.ts b/packages/core/tests/shapesLoader.spec.ts new file mode 100644 index 00000000..d2bf23d8 --- /dev/null +++ b/packages/core/tests/shapesLoader.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ShapesElement } from '../src/models/index.js'; +import type { SpatialBounds } from '../src/pointsTiling.js'; +import type { ShapesRenderData } from '../src/shapes.js'; +import { + createFullShapesLoader, + createShapesLoaderForElement, + resolveShapesEncoding, +} from '../src/shapesLoader.js'; + +/** + * The shapes loader seam, Phase 0. The claims that matter are the *honesty* ones: + * the `wkb-full` loader must report that it does not tile, must return the whole + * element rather than pretend to filter by bounds, and must hold no state of its + * own — the mutable lifecycle lives in the resolver, not here. These are the + * exact properties that keep the points-style concurrency bugs out of the loader. + */ + +const renderData = (): ShapesRenderData => ({ + kind: 'js-polygons', + geometryKind: 'circle', + elementKey: 'cells', + featureIds: ['c1', 'c2'], + circles: { + positions: [new Float32Array([0, 10]), new Float32Array([0, 10])], + radii: new Float32Array([1, 1]), + }, + rowIndexByFeatureIndex: new Int32Array([0, 1]), +}); + +function element(over: Record = {}) { + return { + key: 'cells', + loadRenderData: vi.fn(async () => renderData()), + ...over, + } as unknown as ShapesElement; +} + +const bounds: SpatialBounds = { minX: 0, minY: 0, maxX: 5, maxY: 5 }; + +describe('createFullShapesLoader', () => { + it('advertises that it does not tile', () => { + const loader = createFullShapesLoader(element()); + expect(loader.capabilities.kind).toBe('wkb-full'); + expect(loader.capabilities.batchFormat).toBe('decoded-render-data'); + expect(loader.capabilities.supportsViewportTiles).toBe(false); + // Nothing is loaded at construction, so bounds are unknown — the resolver + // computes them from the geometry, not the loader. + expect(loader.capabilities.bounds).toBeUndefined(); + }); + + it('returns the whole element from loadInBounds, ignoring bounds honestly', async () => { + const el = element(); + const loader = createFullShapesLoader(el); + + const batch = await loader.loadInBounds({ bounds }); + expect(batch).not.toBeNull(); + expect(batch?.format).toBe('decoded-render-data'); + expect(batch?.renderData.featureIds).toEqual(['c1', 'c2']); + // A full loader carries no per-request bounds — the batch IS the element. + expect(batch?.bounds).toBeUndefined(); + }); + + it('loadAll returns the same batch shape as loadInBounds', async () => { + const loader = createFullShapesLoader(element()); + const all = await loader.loadAll?.(); + expect(all?.format).toBe('decoded-render-data'); + expect(all?.renderData.featureIds).toEqual(['c1', 'c2']); + }); + + it('is stateless: it caches nothing and delegates every call to the element', async () => { + const el = element(); + const loader = createFullShapesLoader(el); + + await loader.loadInBounds({ bounds }); + await loader.loadInBounds({ bounds }); + await loader.loadAll?.(); + + // No memoisation in the loader — three calls, three delegations. Caching is + // the resolver's / element's job; a second cache here is exactly the kind of + // duplicated mutable state the points passes had to untangle. + expect(el.loadRenderData).toHaveBeenCalledTimes(3); + }); + + it('surfaces an already-aborted signal as a rejection', async () => { + const el = element(); + const loader = createFullShapesLoader(el); + + await expect(loader.loadInBounds({ bounds, signal: AbortSignal.abort() })).rejects.toThrow(); + // Aborted before it started — the element is never touched. + expect(el.loadRenderData).not.toHaveBeenCalled(); + }); +}); + +describe('createShapesLoaderForElement / resolveShapesEncoding', () => { + it('resolves to the full WKB loader in Phase 0', () => { + expect(resolveShapesEncoding()).toBe('wkb-full'); + const loader = createShapesLoaderForElement(element()); + expect(loader.capabilities.kind).toBe('wkb-full'); + }); +}); diff --git a/packages/core/tests/shapesPolygonTessellate.spec.ts b/packages/core/tests/shapesPolygonTessellate.spec.ts new file mode 100644 index 00000000..f905f16a --- /dev/null +++ b/packages/core/tests/shapesPolygonTessellate.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { tessellateFlatPolygons } from '../src/shapesPolygonTessellate'; + +/** + * The vertex-pulling topology + boundary-edge encoding. The vertex shader will, per + * triangle corner, turn the boundary flags into a `vec3` of edge-distances; the + * fragment draws the outline where `min(vec3)` is ~0. This test replicates that + * shader computation in JS from `triangleData` + `ringPositions`, and asserts the + * invariant: at an edge midpoint the nearest-boundary distance is ~0 for a real + * polygon boundary edge and strictly positive for an internal (earcut) edge. + */ + +/** Replicate the shader's per-corner edge-distance `vec3` (matches the VS logic). */ +function cornerEdgeDist( + A: [number, number], + B: [number, number], + C: [number, number], + flags: number, + corner: number +): [number, number, number] { + const crossMag = Math.abs((B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0])); + const lenBC = Math.hypot(B[0] - C[0], B[1] - C[1]); + const lenCA = Math.hypot(C[0] - A[0], C[1] - A[1]); + const lenAB = Math.hypot(A[0] - B[0], A[1] - B[1]); + const hA = lenBC > 0 ? crossMag / lenBC : 0; + const hB = lenCA > 0 ? crossMag / lenCA : 0; + const hC = lenAB > 0 ? crossMag / lenAB : 0; + const large = Math.max(hA, hB, hC) * 8 + 1; + const bd0 = (flags & 1) !== 0; // BC + const bd1 = (flags & 2) !== 0; // CA + const bd2 = (flags & 4) !== 0; // AB + if (corner === 0) return [bd0 ? hA : large, bd1 ? 0 : large, bd2 ? 0 : large]; + if (corner === 1) return [bd0 ? 0 : large, bd1 ? hB : large, bd2 ? 0 : large]; + return [bd0 ? 0 : large, bd1 ? 0 : large, bd2 ? hC : large]; +} + +/** Barycentric interpolation of the three corners' vec3, then min — what the GPU does. */ +function minEdgeDistanceAt( + A: [number, number], + B: [number, number], + C: [number, number], + eA: [number, number, number], + eB: [number, number, number], + eC: [number, number, number], + px: number, + py: number +): number { + const det = (B[1] - C[1]) * (A[0] - C[0]) + (C[0] - B[0]) * (A[1] - C[1]); + const wA = ((B[1] - C[1]) * (px - C[0]) + (C[0] - B[0]) * (py - C[1])) / det; + const wB = ((C[1] - A[1]) * (px - C[0]) + (A[0] - C[0]) * (py - C[1])) / det; + const wC = 1 - wA - wB; + return Math.min( + wA * eA[0] + wB * eB[0] + wC * eC[0], + wA * eA[1] + wB * eB[1] + wC * eC[1], + wA * eA[2] + wB * eB[2] + wC * eC[2] + ); +} + +describe('tessellateFlatPolygons', () => { + it('emits shared ring positions + topology, drawing boundary edges only', () => { + // A closed unit square (last vertex repeats the first). + const positions = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1, 0, 0]); + const startIndices = new Int32Array([0, 5]); + + const { ringPositions, triangleData, triangleCount, ringVertexCount, featureScale } = + tessellateFlatPolygons(positions, startIndices); + + // 4-vertex square (closing dropped) ⇒ 4 shared ring vertices, 2 triangles. + expect(ringVertexCount).toBe(4); + expect(triangleCount).toBe(2); + expect(Array.from(ringPositions)).toEqual([0, 0, 1, 0, 1, 1, 0, 1]); + // Unit square → area 1 → √area = 1 (the shape's characteristic size). + expect(featureScale.length).toBe(1); + expect(featureScale[0]).toBeCloseTo(1, 6); + + const isSquareBoundary = (p: [number, number], q: [number, number]): boolean => { + const onVert = (p[0] === 0 && q[0] === 0) || (p[0] === 1 && q[0] === 1); + const onHorz = (p[1] === 0 && q[1] === 0) || (p[1] === 1 && q[1] === 1); + return onVert || onHorz; + }; + const pos = (g: number): [number, number] => [ringPositions[g * 2], ringPositions[g * 2 + 1]]; + + for (let t = 0; t < triangleCount; t += 1) { + const g0 = triangleData[t * 4]; + const g1 = triangleData[t * 4 + 1]; + const g2 = triangleData[t * 4 + 2]; + const packed = triangleData[t * 4 + 3]; + expect(packed >>> 3).toBe(0); // feature index of ring 0 + const flags = packed & 7; + const A = pos(g0); + const B = pos(g1); + const C = pos(g2); + const eA = cornerEdgeDist(A, B, C, flags, 0); + const eB = cornerEdgeDist(A, B, C, flags, 1); + const eC = cornerEdgeDist(A, B, C, flags, 2); + + const corners: [number, number][] = [A, B, C]; + for (let i = 0; i < 3; i += 1) { + const p = corners[i]; + const q = corners[(i + 1) % 3]; + const d = minEdgeDistanceAt(A, B, C, eA, eB, eC, (p[0] + q[0]) / 2, (p[1] + q[1]) / 2); + if (isSquareBoundary(p, q)) { + expect(d).toBeCloseTo(0, 5); // real boundary edge → drawn + } else { + expect(d).toBeGreaterThan(0.4); // diagonal midpoint is 0.5 from a side → suppressed + } + } + } + }); + + it('skips degenerate rings (< 3 distinct vertices)', () => { + const positions = new Float32Array([0, 0, 1, 1, 0, 0]); + const startIndices = new Int32Array([0, 3]); + const { triangleCount, ringVertexCount } = tessellateFlatPolygons(positions, startIndices); + expect(triangleCount).toBe(0); + expect(ringVertexCount).toBe(0); + }); +}); diff --git a/packages/core/tests/shapesResolver.spec.ts b/packages/core/tests/shapesResolver.spec.ts index 6910ae22..f8bbc320 100644 --- a/packages/core/tests/shapesResolver.spec.ts +++ b/packages/core/tests/shapesResolver.spec.ts @@ -216,9 +216,11 @@ describe('failure is PER-RESOURCE', () => { expect(resolver.getRenderData('cells')).toBeDefined(); }); - it('only geometry blocks a first paint — tooltip and fill colour never have', () => { - // Today this asymmetry is a kind-switch inside isBlocking. Here it is data. - expect(new ShapesResolver().blockingResources).toEqual(['geometry']); + it('nothing blocks a first paint — shapes load non-blocking', () => { + // Non-blocking pass: geometry refines an already-painted canvas rather than + // gating it, so `blockingResources` is empty *data* and `store.isBlocking` + // reports shapes as never-blocking with no special case. + expect(new ShapesResolver().blockingResources).toEqual([]); }); }); diff --git a/packages/core/tests/vshapes.spec.ts b/packages/core/tests/vshapes.spec.ts index bf00b8d2..28ab7a4b 100644 --- a/packages/core/tests/vshapes.spec.ts +++ b/packages/core/tests/vshapes.spec.ts @@ -85,33 +85,22 @@ describe('SpatialDataShapesSource', () => { schema: { fields: [{ name: 'geometry' }], metadata: new Map() }, getChild: () => undefined, } as any); - vi.spyOn(source, 'loadPolygonShapes').mockResolvedValue({ - shape: [2, null], - data: [ - [ - [ - [0, 0], - [1, 0], - [1, 1], - [0, 0], - ], - ], - [ - [ - [2, 2], - [3, 2], - [3, 3], - [2, 2], - ], - ], - ], + // The WKB decode is now a separable boundary (`loadFlatShapeGeometry`), + // exercised directly in shapesGeometryDecode.spec. Here we mock it to test the + // orchestration: feature-id alignment and the flat-polygon render shape. + vi.spyOn(source as any, 'loadFlatShapeGeometry').mockResolvedValue({ + kind: 'polygon', + positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 2, 2, 3, 2, 3, 3, 2, 2]), + startIndices: new Int32Array([0, 4, 8]), + featureCount: 2, }); await expect(source.loadShapesRenderData('shapes/cells')).resolves.toMatchObject({ - kind: 'wkb-parquet', + kind: 'flat-polygons', geometryKind: 'polygon', elementKey: 'cells', featureIds: ['cell-1', 'cell-2'], + polygonBinary: { startIndices: new Int32Array([0, 4, 8]) }, }); }); @@ -123,9 +112,11 @@ describe('SpatialDataShapesSource', () => { vi.spyOn(source, 'getShapesFormatVersion').mockResolvedValue('0.2'); vi.spyOn(source, 'loadShapesIndex').mockResolvedValue(['cell-1', 'cell-2']); - vi.spyOn(source, 'loadCircleShapes').mockResolvedValue({ - shape: [2, 2], - data: [new Float32Array([0, 2]), new Float32Array([0, 2])], + vi.spyOn(source as any, 'loadFlatShapeGeometry').mockResolvedValue({ + kind: 'point', + xs: new Float32Array([0, 2]), + ys: new Float32Array([0, 2]), + featureCount: 2, }); vi.spyOn(source, 'loadNumeric').mockResolvedValue({ shape: [2], @@ -245,9 +236,11 @@ describe('SpatialDataShapesSource', () => { vi.spyOn(source, 'getShapesFormatVersion').mockResolvedValue('0.2'); vi.spyOn(source, 'loadShapesIndex').mockResolvedValue(['landmark-a', 'landmark-b']); - vi.spyOn(source, 'loadCircleShapes').mockResolvedValue({ - shape: [2, 2], - data: [new Float32Array([100, 200]), new Float32Array([50, 60])], + vi.spyOn(source as any, 'loadFlatShapeGeometry').mockResolvedValue({ + kind: 'point', + xs: new Float32Array([100, 200]), + ys: new Float32Array([50, 60]), + featureCount: 2, }); vi.spyOn(source, 'loadParquetTable').mockResolvedValue({ numRows: 2, @@ -298,26 +291,11 @@ describe('SpatialDataShapesSource', () => { schema: { fields: [{ name: 'geometry' }], metadata: new Map() }, getChild: () => undefined, } as any); - vi.spyOn(source, 'loadPolygonShapes').mockResolvedValue({ - shape: [2, null], - data: [ - [ - [ - [0, 0], - [1, 0], - [1, 1], - [0, 0], - ], - ], - [ - [ - [2, 2], - [3, 2], - [3, 3], - [2, 2], - ], - ], - ], + vi.spyOn(source as any, 'loadFlatShapeGeometry').mockResolvedValue({ + kind: 'polygon', + positions: new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 2, 2, 3, 2, 3, 3, 2, 2]), + startIndices: new Int32Array([0, 4, 8]), + featureCount: 2, }); await expect(source.loadShapesRenderData('shapes/cells')).rejects.toThrow( diff --git a/packages/layers/package.json b/packages/layers/package.json index 94e72e75..ee8d6c9b 100644 --- a/packages/layers/package.json +++ b/packages/layers/package.json @@ -31,7 +31,8 @@ "@hms-dbmi/viv": "catalog:", "@math.gl/core": "catalog:", "@spatialdata/core": "workspace:*", - "zod": "catalog:" + "zod": "catalog:", + "@luma.gl/engine": "^9.3.5" }, "devDependencies": { "@deck.gl/core": "catalog:", diff --git a/packages/layers/src/FlatPolygonLayer.ts b/packages/layers/src/FlatPolygonLayer.ts new file mode 100644 index 00000000..8bc040a6 --- /dev/null +++ b/packages/layers/src/FlatPolygonLayer.ts @@ -0,0 +1,233 @@ +/** + * A vertex-pulling filled-polygon layer that draws its own outline in the fragment + * shader and colours each polygon by sampling a per-feature colour texture. + * + * The pan/zoom regression on large shape sets (Visium HD `square_002um`, ~2.7M shapes) + * came from outlining polygons with a `PathLayer`, which tessellates every ring into + * width-quads. This layer draws the fill triangles with an **attribute-less** shader: + * `gl_VertexID` selects the triangle + corner, and the vertex shader pulls the + * topology and shared ring positions from two textures, computing position and the + * boundary edge-distance on the fly (see `shapesPolygonTessellate` + + * `flatPolygonLayerShaders`). Nothing is stored per de-indexed vertex — geometry is + * two shared textures instead of large attribute buffers. + * + * Feature-state (colour-by-column, hide, fade) is a per-feature colour texture indexed + * by feature (the "table column → buffer" primitive): a feature-state change reuploads + * only that small texture, never the geometry textures. Highlight stays on deck's + * picking module. Picking colours are computed in-shader from the feature index. + * + * Hand-rolled luma `Model` — the lowest-level deck extension surface, kept to this + * file plus its shaders. The `Model`/texture/uniform-block API is backend-agnostic; + * a WebGPU port needs only a WGSL variant of the shaders (and can use storage buffers + * instead of texture-packing). + */ + +import { Layer, type LayerProps, picking, project32 } from '@deck.gl/core'; +import { Model } from '@luma.gl/engine'; +import { flatPolygonUniforms, fs, vs } from './flatPolygonLayerShaders'; + +/** Data-texture width (texel columns). 2048 keeps heights well under the WebGL2 max + * texture size for our largest elements. The shader computes texel coords from this. */ +const TEX_WIDTH = 2048; + +/** deck's default picking-colour encoding (index → RGB), matched so `pickingInfo.index` + * decodes back to the feature index. Used by the pick handler, not the shader. */ +export function encodeFeaturePickingColors(featureIndex: Uint32Array): Uint8Array { + const out = new Uint8Array(featureIndex.length * 3); + for (let v = 0; v < featureIndex.length; v += 1) { + const i = featureIndex[v] + 1; + out[v * 3] = i & 255; + out[v * 3 + 1] = (i >> 8) & 255; + out[v * 3 + 2] = (i >> 16) & 255; + } + return out; +} + +export interface FlatPolygonLayerProps extends LayerProps { + /** Shared ring vertices, interleaved XY (`2 * ringVertexCount`). */ + ringPositions: Float32Array; + ringVertexCount: number; + /** Per-triangle record `[g0, g1, g2, feature*8 + flags]` (`4 * triangleCount`). */ + triangleData: Uint32Array; + triangleCount: number; + /** Per-feature RGBA colours (`4 * featureCount`); new identity only on a + * feature-state change. */ + featureColors: Uint8Array; + featureCount: number; + /** Per-feature characteristic size (√area, world units), `featureCount` long. Static + * with the geometry; used to keep the outline from dominating small shapes. */ + featureScale: Float32Array; + /** Outline width in screen pixels (upper bound; thinned for small shapes). */ + strokeWidthPixels?: number; +} + +const DEFAULT_PROPS = { + strokeWidthPixels: { type: 'number', value: 1.5 }, +}; + +// deck's Layer generics don't model a fully hand-rolled Model layer; the repo's other +// custom-shader layers use the same `any` widening (see LabelsBitmaskTileLayer). +// biome-ignore lint/suspicious/noExplicitAny: hand-rolled deck Layer subclass. +export class FlatPolygonLayer extends (Layer as any) { + static layerName = 'FlatPolygonLayer'; + static defaultProps = DEFAULT_PROPS; + + // biome-ignore lint/complexity/noUselessConstructor: widens the `any` base constructor. + // biome-ignore lint/suspicious/noExplicitAny: base widened to `any`. + constructor(...args: any[]) { + super(...args); + } + + // biome-ignore lint/suspicious/noExplicitAny: matches base getShaders shape. + getShaders(): any { + return super.getShaders({ + vs, + fs, + modules: [project32, picking, flatPolygonUniforms], + // The draw is a non-instanced triangle list with no vertex attributes; the + // shader reads everything from textures via gl_VertexID. + defines: { NON_INSTANCED_MODEL: 1 }, + }); + } + + initializeState(): void { + // Attribute-less: no AttributeManager attributes. Geometry lives in textures. + this.state.model = this._getModel(); + this._updateGeometryTextures(); + this._updateFeatureTexture(); + } + + updateState(params: { + props: FlatPolygonLayerProps; + oldProps: Partial; + changeFlags: { extensionsChanged?: boolean }; + }): void { + super.updateState(params); + const { props, oldProps, changeFlags } = params; + if (changeFlags.extensionsChanged) { + this.state.model?.destroy(); + this.state.model = this._getModel(); + } + if ( + props.ringPositions !== oldProps.ringPositions || + props.triangleData !== oldProps.triangleData + ) { + this._updateGeometryTextures(); + } + if (props.featureColors !== oldProps.featureColors) { + this._updateFeatureTexture(); + } + } + + finalizeState(context: unknown): void { + this.state.ringPosTexture?.destroy(); + this.state.triDataTexture?.destroy(); + this.state.featureScaleTexture?.destroy(); + this.state.featureTexture?.destroy(); + // biome-ignore lint/suspicious/noExplicitAny: base Layer is widened to `any`. + (super.finalizeState as any)?.(context); + } + + /** Upload the shared ring positions (`rg32float`) and per-triangle topology + * (`rgba32uint`). Static — rebuilt only when the geometry changes. */ + _updateGeometryTextures(): void { + const { ringPositions, ringVertexCount, triangleData, triangleCount } = this.props; + const device = this.context.device; + + const ringHeight = Math.max(1, Math.ceil(ringVertexCount / TEX_WIDTH)); + const ringData = new Float32Array(TEX_WIDTH * ringHeight * 2); + ringData.set(ringPositions.subarray(0, Math.min(ringPositions.length, ringData.length))); + this.state.ringPosTexture?.destroy(); + this.state.ringPosTexture = device.createTexture({ + width: TEX_WIDTH, + height: ringHeight, + format: 'rg32float', + data: ringData, + mipmaps: false, + sampler: { minFilter: 'nearest', magFilter: 'nearest' }, + }); + + const triHeight = Math.max(1, Math.ceil(triangleCount / TEX_WIDTH)); + const triData = new Uint32Array(TEX_WIDTH * triHeight * 4); + triData.set(triangleData.subarray(0, Math.min(triangleData.length, triData.length))); + this.state.triDataTexture?.destroy(); + this.state.triDataTexture = device.createTexture({ + width: TEX_WIDTH, + height: triHeight, + format: 'rgba32uint', + data: triData, + mipmaps: false, + sampler: { minFilter: 'nearest', magFilter: 'nearest' }, + }); + + // Per-feature characteristic size (√area), sampled by the outline to avoid + // dominating small shapes. Same feature indexing as the colour texture. + const scaleHeight = Math.max(1, Math.ceil(this.props.featureCount / TEX_WIDTH)); + const scaleData = new Float32Array(TEX_WIDTH * scaleHeight); + const scaleSrc = this.props.featureScale; + scaleData.set(scaleSrc.subarray(0, Math.min(scaleSrc.length, scaleData.length))); + this.state.featureScaleTexture?.destroy(); + this.state.featureScaleTexture = device.createTexture({ + width: TEX_WIDTH, + height: scaleHeight, + format: 'r32float', + data: scaleData, + mipmaps: false, + sampler: { minFilter: 'nearest', magFilter: 'nearest' }, + }); + + this.state.triDataTexWidth = TEX_WIDTH; + this.state.ringPosTexWidth = TEX_WIDTH; + this.state.vertexCount = triangleCount * 3; + } + + /** (Re)build the per-feature colour texture from `props.featureColors`. */ + _updateFeatureTexture(): void { + const height = Math.max(1, Math.ceil(this.props.featureCount / TEX_WIDTH)); + const data = new Uint8Array(TEX_WIDTH * height * 4); + const src = this.props.featureColors; + data.set(src.subarray(0, Math.min(src.length, data.length))); + this.state.featureTexture?.destroy(); + this.state.featureTexture = this.context.device.createTexture({ + width: TEX_WIDTH, + height, + format: 'rgba8unorm', + data, + mipmaps: false, + sampler: { minFilter: 'nearest', magFilter: 'nearest' }, + }); + this.state.featureTexWidth = TEX_WIDTH; + } + + draw(): void { + const model = this.state.model; + if (!model || !this.state.ringPosTexture) { + return; + } + model.setVertexCount(this.state.vertexCount); + model.shaderInputs.setProps({ + flatPolygon: { + strokeWidthPixels: this.props.strokeWidthPixels ?? 1.5, + opacity: this.props.opacity ?? 1, + ringPosTexWidth: this.state.ringPosTexWidth, + triDataTexWidth: this.state.triDataTexWidth, + featureTexWidth: this.state.featureTexWidth, + ringPositions: this.state.ringPosTexture, + triangleData: this.state.triDataTexture, + featureColorTexture: this.state.featureTexture, + featureScaleTexture: this.state.featureScaleTexture, + }, + }); + model.draw(this.context.renderPass); + } + + _getModel(): Model { + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + topology: 'triangle-list', + bufferLayout: [], + isInstanced: false, + }); + } +} diff --git a/packages/layers/src/flatPolygonLayerShaders.ts b/packages/layers/src/flatPolygonLayerShaders.ts new file mode 100644 index 00000000..9c2d3e29 --- /dev/null +++ b/packages/layers/src/flatPolygonLayerShaders.ts @@ -0,0 +1,173 @@ +/** + * Shaders for `FlatPolygonLayer` — a **vertex-pulling** filled-polygon renderer that + * imputes its own outline in the fragment shader. + * + * The draw is attribute-less: `gl_VertexID` selects the triangle (`id/3`) and the + * corner (`id%3`); the vertex shader then fetches the triangle's record from + * `triangleData` (three ring-vertex indices + boundary flags + feature index) and the + * three corner positions from `ringPositions`. It picks this corner's position, + * **computes** the boundary edge-distance `vec3` (nothing stored per vertex), and + * samples the per-feature colour texture. This keeps geometry memory to two shared + * textures instead of large de-indexed attribute buffers. + * + * OUTLINE. `min(vEdgeDistance)` is the distance to the nearest boundary edge (internal + * earcut edges are the large sentinel). `fwidth(d) * strokeWidthPixels` is the + * world-space width of a constant pixel-width band; `smoothstep` anti-aliases it. + */ + +const flatPolygonUniformBlock = `\ +uniform flatPolygonUniforms { + float strokeWidthPixels; + float opacity; + float ringPosTexWidth; + float triDataTexWidth; + float featureTexWidth; +} flatPolygon; +`; + +export const flatPolygonUniforms = { + name: 'flatPolygon', + vs: flatPolygonUniformBlock, + fs: flatPolygonUniformBlock, + uniformTypes: { + strokeWidthPixels: 'f32', + opacity: 'f32', + ringPosTexWidth: 'f32', + triDataTexWidth: 'f32', + featureTexWidth: 'f32', + }, +} as const; + +export const vs = `#version 300 es +#define SHADER_NAME flat-polygon-layer-vertex-shader +precision highp float; +precision highp int; + +uniform sampler2D ringPositions; +uniform highp usampler2D triangleData; +uniform sampler2D featureColorTexture; +uniform sampler2D featureScaleTexture; + +out vec3 vEdgeDistance; +out vec4 vFillColor; +out float vShapeScale; + +ivec2 flatPolygon_texCoord(uint index, float width) { + uint w = uint(width); + return ivec2(int(index % w), int(index / w)); +} + +void main(void) { + int vid = gl_VertexID; + int tri = vid / 3; + int corner = vid - tri * 3; + + uvec4 td = texelFetch(triangleData, flatPolygon_texCoord(uint(tri), flatPolygon.triDataTexWidth), 0); + uint feature = td.w >> 3u; + uint flags = td.w & 7u; + + vec2 A = texelFetch(ringPositions, flatPolygon_texCoord(td.x, flatPolygon.ringPosTexWidth), 0).xy; + vec2 B = texelFetch(ringPositions, flatPolygon_texCoord(td.y, flatPolygon.ringPosTexWidth), 0).xy; + vec2 C = texelFetch(ringPositions, flatPolygon_texCoord(td.z, flatPolygon.ringPosTexWidth), 0).xy; + vec2 p = corner == 0 ? A : (corner == 1 ? B : C); + + // Boundary edge-distance vec3 (matches shapesPolygonTessellate's CPU reference): + // component k is the distance to edge k (0=BC, 1=CA, 2=AB); internal edges get a + // large sentinel so they never win the fragment's min. + float crossMag = abs((B.x - A.x) * (C.y - A.y) - (B.y - A.y) * (C.x - A.x)); + float lenBC = distance(B, C); + float lenCA = distance(C, A); + float lenAB = distance(A, B); + float hA = lenBC > 0.0 ? crossMag / lenBC : 0.0; + float hB = lenCA > 0.0 ? crossMag / lenCA : 0.0; + float hC = lenAB > 0.0 ? crossMag / lenAB : 0.0; + float large = max(hA, max(hB, hC)) * 8.0 + 1.0; + bool bd0 = (flags & 1u) != 0u; + bool bd1 = (flags & 2u) != 0u; + bool bd2 = (flags & 4u) != 0u; + if (corner == 0) { + vEdgeDistance = vec3(bd0 ? hA : large, bd1 ? 0.0 : large, bd2 ? 0.0 : large); + } else if (corner == 1) { + vEdgeDistance = vec3(bd0 ? 0.0 : large, bd1 ? hB : large, bd2 ? 0.0 : large); + } else { + vEdgeDistance = vec3(bd0 ? 0.0 : large, bd1 ? 0.0 : large, bd2 ? hC : large); + } + + vec3 pos = vec3(p, 0.0); + geometry.worldPosition = pos; + + // Picking colour from the feature index — deck's encoding (index + 1 → RGB bytes). + float fp = float(feature + 1u); + geometry.pickingColor = vec3( + mod(fp, 256.0), + mod(floor(fp / 256.0), 256.0), + mod(floor(fp / 65536.0), 256.0) + ); + + gl_Position = project_position_to_clipspace(pos, vec3(0.0), vec3(0.0), geometry.position); + DECKGL_FILTER_GL_POSITION(gl_Position, geometry); + + vFillColor = texelFetch(featureColorTexture, flatPolygon_texCoord(feature, flatPolygon.featureTexWidth), 0); + vShapeScale = texelFetch(featureScaleTexture, flatPolygon_texCoord(feature, flatPolygon.featureTexWidth), 0).r; + DECKGL_FILTER_COLOR(vFillColor, geometry); +} +`; + +export const fs = `#version 300 es +#define SHADER_NAME flat-polygon-layer-fragment-shader +precision highp float; + +// Outline appearance. Lightened toward white for contrast against the fill; capped to +// a fraction of the shape's on-screen size so it never dominates a small (zoomed-out) +// shape. +#define STROKE_LIGHTEN 0.55 +#define STROKE_ALPHA_LIFT 0.35 +#define STROKE_MAX_FRACTION 0.28 +// Below OUTLINE_FADE_LO on-screen pixels the outline is fully faded out; above +// OUTLINE_FADE_HI it is at full strength. This stops the thin edge from aliasing into +// moiré on a regular grid when shapes go sub-pixel (zoomed out). +#define OUTLINE_FADE_LO 1.5 +#define OUTLINE_FADE_HI 4.0 + +in vec3 vEdgeDistance; +in vec4 vFillColor; +in float vShapeScale; + +out vec4 fragColor; + +void main(void) { + // Hidden features arrive as fully transparent fill — drop them entirely (both fill + // and would-be outline), rather than letting the lightened stroke reappear. + if (vFillColor.a == 0.0) { + discard; + } + + float d = min(vEdgeDistance.x, min(vEdgeDistance.y, vEdgeDistance.z)); + float worldPerPx = max(fwidth(d), 1e-20); + + // The shape's size in screen pixels (√area is world units; worldPerPx converts). + // Cap the outline to a fraction of it so a tiny shape isn't all outline; when the + // shape is large the requested pixel width wins. + float shapePx = vShapeScale / worldPerPx; + float strokePx = min(flatPolygon.strokeWidthPixels, shapePx * STROKE_MAX_FRACTION); + float aa = max(worldPerPx * strokePx, 1e-20); + float edge = 1.0 - smoothstep(0.0, aa, d); + // Fade the outline out entirely for sub-pixel shapes so it doesn't alias into moiré. + edge *= smoothstep(OUTLINE_FADE_LO, OUTLINE_FADE_HI, shapePx); + + // Outline = a lighter derivation of the fill (fill is the specified colour), so + // adjacent shapes read as distinct. Matches the object path's deriveStrokeColor. + vec3 strokeRgb = mix(vFillColor.rgb, vec3(1.0), STROKE_LIGHTEN); + float strokeA = min(1.0, vFillColor.a + STROKE_ALPHA_LIFT); + vec4 color = mix(vFillColor, vec4(strokeRgb, strokeA), edge); + + color.a *= flatPolygon.opacity; + if (color.a == 0.0) { + discard; + } + fragColor = color; + + fragColor = picking_filterHighlightColor(fragColor); + fragColor = picking_filterPickingColor(fragColor); +} +`; diff --git a/packages/layers/src/index.ts b/packages/layers/src/index.ts index 47f26440..3252fee9 100644 --- a/packages/layers/src/index.ts +++ b/packages/layers/src/index.ts @@ -115,6 +115,7 @@ export { buildShapeFeatureStateRuntime, buildShapesPrebuiltData, createShapesDeckLayer, + DEFAULT_SHAPE_FILL_COLOR, DEFAULT_SHAPE_STROKE_WIDTH, DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, diff --git a/packages/layers/src/shapesLayer.ts b/packages/layers/src/shapesLayer.ts index 8fdd6507..0a22a9a7 100644 --- a/packages/layers/src/shapesLayer.ts +++ b/packages/layers/src/shapesLayer.ts @@ -1,11 +1,25 @@ import type { Matrix4 } from '@math.gl/core'; -import type { SpatialShapesSublayer } from '@spatialdata/core'; +import { + type SpatialShapesSublayer, + type TessellatedPolygons, + tessellateFlatPolygons, +} from '@spatialdata/core'; import { type Layer, type PickingInfo, PolygonLayer, ScatterplotLayer } from 'deck.gl'; +import { FlatPolygonLayer } from './FlatPolygonLayer'; export type ShapePolygon = Array>; export type ShapesGeometryKind = 'polygon' | 'circle' | 'point'; +/** + * Default shape fill colour. A **single stable reference** on purpose: it feeds + * `updateTriggers.getFillColor`, which deck compares shallowly. A fresh + * `[100,100,200,180]` literal per render (e.g. a default parameter) reads as a + * changed trigger and forces deck to rebuild the entire per-feature colour + * attribute every frame — an O(vertices) main-thread stall on every hover/pan. + */ +export const DEFAULT_SHAPE_FILL_COLOR: [number, number, number, number] = [100, 100, 200, 180]; + /** Default marker radius for point landmarks (pixels). */ export const DEFAULT_SHAPE_POINT_RADIUS_PX = 8; export const DEFAULT_SHAPE_STROKE_WIDTH = 1; @@ -13,7 +27,11 @@ export const DEFAULT_SHAPE_STROKE_WIDTH_UNITS = 'common' as const; export const DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS = 0; export const DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS = 1; -export type ShapesGeometryRepresentationKind = 'js-polygons' | 'wkb-parquet' | 'geoarrow-table'; +export type ShapesGeometryRepresentationKind = + | 'js-polygons' + | 'flat-polygons' + | 'wkb-parquet' + | 'geoarrow-table'; export type ShapeStrokeWidthUnits = 'common' | 'pixels'; export interface ShapeCircleColumnarLike { @@ -21,12 +39,22 @@ export interface ShapeCircleColumnarLike { radii?: Float32Array; } +/** Flat GeoArrow-style polygon geometry (interleaved positions + vertex offsets). */ +export interface FlatPolygonGeometryLike { + positions: Float32Array; + startIndices: Int32Array; + /** Off-thread-tessellated render topology (worker path); absent → tessellate lazily. */ + tessellation?: TessellatedPolygons; +} + export interface ShapesRenderDataLike { kind: ShapesGeometryRepresentationKind; geometryKind?: ShapesGeometryKind; elementKey: string; featureIds: string[]; polygons?: ShapePolygon[]; + /** Transferable flat polygon geometry. Rendered via a binary `PolygonLayer`. */ + polygonBinary?: FlatPolygonGeometryLike; circles?: ShapeCircleColumnarLike; rowIndexByFeatureIndex: Int32Array; geometryTable?: GeoarrowTableLike; @@ -98,9 +126,31 @@ export interface ShapeTooltipRuntimeData { * and stored in the load cache. `createShapesDeckLayer` uses them directly when provided, * avoiding the O(n-features) allocation on every `getLayers()` call. */ +/** + * Binary polygon geometry ready for a deck.gl binary `PolygonLayer`: the flat + * coordinate + offset buffers plus the per-index feature identity needed to drive + * colour and picking without a per-feature JS object. `featureIds[i]` / + * `rowIndexByFeatureIndex[i]` name feature `i`, which is exactly the binary + * polygon at `startIndices[i]`. + */ +export interface ShapesBinaryPolygonData { + positions: Float32Array; + startIndices: Int32Array; + featureIds: string[]; + rowIndexByFeatureIndex?: Int32Array; + /** Off-thread-tessellated render topology; when absent the layer tessellates lazily. */ + tessellation?: TessellatedPolygons; +} + export interface ShapesPrebuiltData { geometryKind: ShapesGeometryKind; + /** Per-feature descriptors (circle/point, or the legacy nested-polygon path). */ data: ShapePolygonRenderDatum[] | ShapeCircleRenderDatum[]; + /** + * Present for `flat-polygons` geometry. When set, `data` is empty and the layer + * renders from these binary buffers, resolving features by index. + */ + binary?: ShapesBinaryPolygonData; } /** Cache normalised featureState runtimes by plain-object identity. */ @@ -190,6 +240,48 @@ function shapeFeatureColorUpdateTriggers( ]; } +/** + * The fill-colour `updateTrigger` array with a **stable identity** while its + * contents are unchanged, memoised on the (stable) feature-state runtime. + * + * This is the fix for the interaction/hover buffer thrash: `createShapesDeckLayer` + * runs on every `getLayers()` (a hover re-renders for the tooltip), and a fresh + * trigger array each time reads to deck as "the colours changed", so it rebuilds + * the whole per-feature colour attribute — millions of `getFillColor` calls, a + * multi-hundred-ms main-thread stall — on every render. Feeding a stable identity + * lets deck skip the rebuild unless feature-state actually changes. + */ +// Two levels so fill and stroke (same runtime, different default colours) each get +// a stable trigger. Both keys are stable references — the runtime from the vis-side +// feature-state cache, the default colour a module constant / config value — so a +// steady state yields a cache hit and deck skips the rebuild. +const colorTriggerCache = new WeakMap< + ShapeFeatureStateRuntime, + WeakMap +>(); + +function stableColorUpdateTrigger( + featureState: ShapeFeatureStateRuntime, + defaultColor: [number, number, number, number] +): readonly unknown[] { + let byColor = colorTriggerCache.get(featureState); + if (!byColor) { + byColor = new WeakMap(); + colorTriggerCache.set(featureState, byColor); + } + const key = defaultColor as unknown as object; + let trigger = byColor.get(key); + if (!trigger) { + // The runtime already changes identity on ANY feature-state change — the + // vis-side cache keys it by a signature that includes hidden/faded/colours — + // so `featureState` (the memo key) is a complete invalidation signal; the + // trigger contents need only be stable-per-runtime. + trigger = shapeFeatureColorUpdateTriggers(featureState, defaultColor); + byColor.set(key, trigger); + } + return trigger; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -270,6 +362,156 @@ function resolveFeatureColor( : base; } +/** + * How far the outline is lifted toward white, and how much its alpha is raised, + * relative to the fill it is derived from. + */ +const STROKE_LIGHTEN = 0.45; +const STROKE_ALPHA_LIFT = 55; + +/** + * Memoise the derivation on the **input array identity**. A stable fill (a module + * default, or the caller's stable per-feature colour array) must yield a stable + * outline array, so the outline's `updateTrigger` keeps a stable identity and deck + * does not rebuild the outline colour buffer on every hover/pan — the same + * buffer-thrash fix the fill relies on. + */ +const derivedStrokeCache = new WeakMap(); + +/** + * Derive a shape's outline colour from its **fill**. The fill is the *specified* + * colour (a layer default or a data-column encoding); the outline is a lighter, + * slightly more opaque accent of it, so adjacent shapes read as distinct without + * the edge competing with the fill. A ≤1px outline the *same* colour as the fill + * is invisible — the reason shapes previously did not read as shapes. A genuine + * per-feature stroke override still wins over this derivation (see + * `resolveStrokeColor`). + */ +export function deriveStrokeColor( + fill: [number, number, number, number] +): [number, number, number, number] { + const key = fill as unknown as object; + const cached = derivedStrokeCache.get(key); + if (cached) { + return cached; + } + const lift = (channel: number): number => Math.round(channel + (255 - channel) * STROKE_LIGHTEN); + const result: [number, number, number, number] = [ + lift(fill[0]), + lift(fill[1]), + lift(fill[2]), + Math.min(255, fill[3] + STROKE_ALPHA_LIFT), + ]; + derivedStrokeCache.set(key, result); + return result; +} + +/** + * Resolve a feature's outline colour. Precedence: an explicit per-feature stroke + * override, else a lighter derivation of the feature's resolved fill (per-feature + * fill colour when present, otherwise the caller's default outline). Fade is + * applied last, mirroring `resolveFeatureColor`. + */ +function resolveStrokeColor( + featureId: string, + featureState: ShapeFeatureStateRuntime, + resolvedDefaultStroke: [number, number, number, number] +): [number, number, number, number] { + const explicit = featureState.strokeColorByFeatureId.get(featureId); + const fill = featureState.fillColorByFeatureId.get(featureId); + const base = explicit ?? (fill ? deriveStrokeColor(fill) : resolvedDefaultStroke); + return featureState.fadedFeatureIds.has(featureId) + ? multiplyAlpha(base, featureState.filteredOpacityMultiplier) + : base; +} + +/** Hidden features are not excluded from a binary buffer (that would misalign the + * index); they render fully transparent instead. */ +const TRANSPARENT_RGBA: [number, number, number, number] = [0, 0, 0, 0]; + +/** + * The tessellation pipeline for the binary polygon path. Each stage is memoised on + * its stable upstream input so `createShapesDeckLayer` (which runs every + * `getLayers()`) does no per-frame work and hands deck stable buffer identities — + * the same discipline the old binary descriptors used, extended to the fill + + * fwidth-outline geometry. + */ + +/** Shared ring positions + per-triangle topology for vertex pulling, memoised on the + * positions buffer (stable, resolver-owned) so tessellation runs once. */ +const tessellationCache = new WeakMap(); + +function getTessellation(positions: Float32Array, startIndices: Int32Array): TessellatedPolygons { + const cached = tessellationCache.get(positions); + if (cached) { + return cached; + } + const tess = tessellateFlatPolygons(positions, startIndices); + tessellationCache.set(positions, tess); + return tess; +} + +/** + * Per-**feature** RGBA colours (the "table column → buffer" primitive), rebuilt only + * when feature-state changes. Keyed on the feature-state runtime identity (which + * changes exactly on a real feature-state change), so the returned buffer has a + * **stable identity** across bare re-renders. This is `featureCount` texels — the + * layer uploads it to a small texture and the shader samples it by feature index, so + * the (large) geometry textures never re-upload. Hide/fade are folded into alpha. + */ +const featureColorsCache = new WeakMap(); + +function getFeatureColors( + featureState: ShapeFeatureStateRuntime, + featureCount: number, + colorForFeatureIndex: (index: number) => [number, number, number, number] +): Uint8Array { + const cached = featureColorsCache.get(featureState); + if (cached && cached.length === featureCount * 4) { + return cached; + } + const colors = new Uint8Array(featureCount * 4); + for (let f = 0; f < featureCount; f += 1) { + const c = colorForFeatureIndex(f); + colors[f * 4] = c[0]; + colors[f * 4 + 1] = c[1]; + colors[f * 4 + 2] = c[2]; + colors[f * 4 + 3] = c[3]; + } + featureColorsCache.set(featureState, colors); + return colors; +} + +/** + * Reconstruct a single feature's descriptor (including its polygon ring) from the + * binary buffers. Called only at pick/tooltip time — one feature, so the nested + * allocation the binary path avoids at load is negligible here. + */ +export function featureFromBinary( + binary: ShapesBinaryPolygonData, + index: number +): ShapePolygonRenderDatum | undefined { + const featureId = binary.featureIds[index]; + if (!Number.isInteger(index) || index < 0 || !featureId) { + return undefined; + } + const start = binary.startIndices[index]; + const end = binary.startIndices[index + 1]; + const ring: Array<[number, number]> = []; + if (Number.isFinite(start) && Number.isFinite(end)) { + for (let v = start; v < end; v += 1) { + ring.push([binary.positions[v * 2], binary.positions[v * 2 + 1]]); + } + } + const rowIndex = binary.rowIndexByFeatureIndex?.[index]; + return { + featureId, + featureIndex: index, + polygon: [ring], + rowIndex: rowIndex !== undefined && rowIndex >= 0 ? rowIndex : undefined, + }; +} + function resolveGeometryKind(renderData: ShapesRenderDataLike): ShapesGeometryKind { if (renderData.geometryKind) { return renderData.geometryKind; @@ -404,6 +646,24 @@ export function buildShapesPrebuiltData( return { geometryKind, data: buildCircleRenderedFeatures(renderData, hiddenSet) }; } + if (renderData.polygonBinary) { + // Binary path: no per-feature array is built at all — features are resolved by + // index. `hiddenFeatureIds` is deliberately NOT applied here: hidden features + // stay in the buffer (index alignment) and render transparent at colour time, + // so this prebuilt is independent of the hidden set. + return { + geometryKind: 'polygon', + data: [], + binary: { + positions: renderData.polygonBinary.positions, + startIndices: renderData.polygonBinary.startIndices, + featureIds: renderData.featureIds, + rowIndexByFeatureIndex: renderData.rowIndexByFeatureIndex, + tessellation: renderData.polygonBinary.tessellation, + }, + }; + } + const data = renderData.kind === 'geoarrow-table' ? buildGeoarrowRenderedFeatures(renderData, hiddenSet) @@ -440,6 +700,36 @@ function createPickHandler( }; } +function createBinaryPickHandler( + layerId: string, + elementKey: string, + coordinateSystem: string | null | undefined, + binary: ShapesBinaryPolygonData, + callback: ((event: ShapesLayerPickEvent) => void) | undefined +) { + if (!callback) { + return undefined; + } + return (pickInfo: PickingInfo) => { + // Binary layers surface only a picked `index`; reconstruct the feature from it. + const feature = + typeof pickInfo.index === 'number' ? featureFromBinary(binary, pickInfo.index) : undefined; + if (!feature) { + return; + } + callback({ + layerId, + elementKey, + featureId: feature.featureId, + featureIndex: feature.featureIndex, + coordinateSystem, + rowIndex: feature.rowIndex, + object: feature, + pickInfo, + }); + }; +} + export function resolveShapeFeatureFromPickInfo( pickInfo: Pick<{ object?: unknown }, 'object'> ): ShapeFeatureRenderDatum | undefined { @@ -496,6 +786,9 @@ export function resolveShapeFeatureFromPick( if (!prebuilt || typeof pickInfo.index !== 'number' || pickInfo.index < 0) { return undefined; } + if (prebuilt.binary) { + return featureFromBinary(prebuilt.binary, pickInfo.index); + } const datum = prebuilt.data[pickInfo.index]; return isShapeFeatureRenderDatum(datum) ? datum : undefined; } @@ -566,8 +859,8 @@ function createPolygonDeckLayer( options: CreateShapesDeckLayerOptions ): Layer { const featureState = normalizeShapeFeatureState(sublayer.featureState); - const defaultFillColor = sublayer.defaultFillColor ?? [100, 100, 200, 180]; - const defaultStrokeColor = sublayer.defaultStrokeColor ?? defaultFillColor; + const defaultFillColor = sublayer.defaultFillColor ?? DEFAULT_SHAPE_FILL_COLOR; + const resolvedDefaultStroke = sublayer.defaultStrokeColor ?? deriveStrokeColor(defaultFillColor); const defaultStrokeWidth = sublayer.defaultStrokeWidth ?? DEFAULT_SHAPE_STROKE_WIDTH; const defaultStrokeWidthUnits = sublayer.defaultStrokeWidthUnits ?? DEFAULT_SHAPE_STROKE_WIDTH_UNITS; @@ -588,21 +881,14 @@ function createPolygonDeckLayer( defaultFillColor, featureState ), - getLineColor: (d) => - resolveFeatureColor( - d.featureId, - featureState.strokeColorByFeatureId, - featureState.fillColorByFeatureId, - defaultStrokeColor, - featureState - ), + getLineColor: (d) => resolveStrokeColor(d.featureId, featureState, resolvedDefaultStroke), getLineWidth: defaultStrokeWidth, lineWidthUnits: defaultStrokeWidthUnits, lineWidthMinPixels: defaultStrokeWidthMinPixels, lineWidthMaxPixels: defaultStrokeWidthMaxPixels, updateTriggers: { - getFillColor: shapeFeatureColorUpdateTriggers(featureState, defaultFillColor), - getLineColor: shapeFeatureColorUpdateTriggers(featureState, defaultStrokeColor), + getFillColor: stableColorUpdateTrigger(featureState, defaultFillColor), + getLineColor: stableColorUpdateTrigger(featureState, resolvedDefaultStroke), getLineWidth: [defaultStrokeWidth], }, filled: true, @@ -627,6 +913,99 @@ function createPolygonDeckLayer( }); } +/** + * Render flat polygon geometry through a deck.gl `SolidPolygonLayer` in **binary + * mode** — the coordinates never leave their typed-array form, so there is no + * per-feature JS object and no per-vertex allocation. + * + * A single `FlatPolygonLayer` draws fill and outline together: the ring geometry is + * tessellated into triangles carrying a boundary edge-distance, and the fragment + * shader imputes an anti-aliased outline with `fwidth` — no separate outline layer + * (the `PathLayer` outline was the pan/zoom regression at ~2.7M shapes; it + * tessellated every ring into width-quads). The outline colour is a lighter + * derivation of the fill, computed in the shader, so adjacent shapes read as + * distinct. + * + * Feature-state is index-driven: colour is resolved once per feature and expanded to + * the tessellated vertices; hidden features become transparent (dropped in the + * shader), faded features get their alpha scaled. The per-vertex fill-colour buffer + * is memoised on the feature-state runtime, whose identity changes on any + * feature-state change — so deck re-uploads it exactly when it must, and never on a + * bare re-render (the hover/pan buffer-thrash fix). Static geometry (positions, + * edge-distances, picking colours) keeps a stable identity across all renders. + * + * NOTE: an explicit per-feature stroke override (`strokeColorByFeatureId`) is not yet + * honoured on this path — the outline is always the lightened fill. The object path + * still honours it; wiring it here is a follow-up. + */ +function createBinaryPolygonDeckLayers( + binary: ShapesBinaryPolygonData, + sublayer: SpatialShapesRuntimeSublayer, + options: CreateShapesDeckLayerOptions +): Layer[] { + const featureState = normalizeShapeFeatureState(sublayer.featureState); + const defaultFillColor = sublayer.defaultFillColor ?? DEFAULT_SHAPE_FILL_COLOR; + + const { positions, startIndices, featureIds } = binary; + + // Prefer the topology tessellated off the main thread by the geometry worker; fall + // back to lazy main-thread tessellation only when the worker path didn't provide it. + const tess = binary.tessellation ?? getTessellation(positions, startIndices); + if (tess.triangleCount === 0) { + return []; + } + + const fillColorAt = (index: number): [number, number, number, number] => { + const featureId = featureIds[index]; + if (!featureId || featureState.hiddenFeatureIds.has(featureId)) { + return TRANSPARENT_RGBA; + } + return resolveFeatureColor( + featureId, + featureState.fillColorByFeatureId, + EMPTY_SHAPE_FEATURE_STATE_RUNTIME.fillColorByFeatureId, + defaultFillColor, + featureState + ); + }; + + const featureColors = getFeatureColors(featureState, featureIds.length, fillColorAt); + + const layer = new FlatPolygonLayer({ + id: options.id, + ringPositions: tess.ringPositions, + ringVertexCount: tess.ringVertexCount, + triangleData: tess.triangleData, + triangleCount: tess.triangleCount, + featureScale: tess.featureScale, + featureColors, + featureCount: featureIds.length, + strokeWidthPixels: + sublayer.defaultStrokeWidthMaxPixels ?? DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, + opacity: options.opacity ?? 1, + modelMatrix: options.modelMatrix, + pickable: options.pickingEnabled ?? true, + autoHighlight: options.pickingEnabled ?? true, + highlightColor: [255, 255, 0, 128], + onHover: createBinaryPickHandler( + options.id, + sublayer.elementKey, + options.spatialCoordinateSystem, + binary, + options.onShapeHover + ), + onClick: createBinaryPickHandler( + options.id, + sublayer.elementKey, + options.spatialCoordinateSystem, + binary, + options.onShapeClick + ), + } as unknown as ConstructorParameters[0]); + + return [layer as unknown as Layer]; +} + function createCircleDeckLayer( data: ShapeCircleRenderDatum[], geometryKind: 'circle' | 'point', @@ -634,7 +1013,7 @@ function createCircleDeckLayer( options: CreateShapesDeckLayerOptions ): Layer { const featureState = normalizeShapeFeatureState(sublayer.featureState); - const defaultFillColor = sublayer.defaultFillColor ?? [100, 100, 200, 180]; + const defaultFillColor = sublayer.defaultFillColor ?? DEFAULT_SHAPE_FILL_COLOR; const radiusUnits = geometryKind === 'point' ? 'pixels' : 'common'; return new ScatterplotLayer({ @@ -650,7 +1029,7 @@ function createCircleDeckLayer( : base; }, updateTriggers: { - getFillColor: shapeFeatureColorUpdateTriggers(featureState, defaultFillColor), + getFillColor: stableColorUpdateTrigger(featureState, defaultFillColor), }, opacity: options.opacity ?? 1, modelMatrix: options.modelMatrix, @@ -683,18 +1062,29 @@ function createCircleDeckLayer( * * Without `prebuilt` the function falls back to building the data inline, which * preserves backward compatibility for external callers and tests. + * + * The binary polygon path returns **two** layers (fill + outline); every other + * path returns one. Callers must accept `Layer | Layer[]` — deck's `LayersList` + * flattens nested arrays, so an array can be passed through unchanged. */ export function createShapesDeckLayer( renderData: ShapesRenderDataLike, sublayer: SpatialShapesRuntimeSublayer, options: CreateShapesDeckLayerOptions, prebuilt?: ShapesPrebuiltData -): Layer | null { +): Layer | Layer[] | null { if ((options.visible ?? sublayer.visible ?? true) === false) { return null; } if (prebuilt) { + // Binary polygons first: their `data` is intentionally empty (features by + // index), so the length check below must not short-circuit them. + if (prebuilt.binary) { + return prebuilt.binary.featureIds.length === 0 + ? null + : createBinaryPolygonDeckLayers(prebuilt.binary, sublayer, options); + } if (prebuilt.data.length === 0) return null; if (prebuilt.geometryKind === 'circle' || prebuilt.geometryKind === 'point') { return createCircleDeckLayer( @@ -719,6 +1109,22 @@ export function createShapesDeckLayer( return createCircleDeckLayer(data, geometryKind, sublayer, options); } + if (renderData.polygonBinary) { + return renderData.featureIds.length === 0 + ? null + : createBinaryPolygonDeckLayers( + { + positions: renderData.polygonBinary.positions, + startIndices: renderData.polygonBinary.startIndices, + featureIds: renderData.featureIds, + rowIndexByFeatureIndex: renderData.rowIndexByFeatureIndex, + tessellation: renderData.polygonBinary.tessellation, + }, + sublayer, + options + ); + } + const data = renderData.kind === 'geoarrow-table' ? buildGeoarrowRenderedFeatures(renderData, featureState.hiddenFeatureIds) diff --git a/packages/layers/tests/shapesLayer.spec.ts b/packages/layers/tests/shapesLayer.spec.ts index cfcde655..5572c887 100644 --- a/packages/layers/tests/shapesLayer.spec.ts +++ b/packages/layers/tests/shapesLayer.spec.ts @@ -4,6 +4,8 @@ import { buildShapeFeatureStateRuntime, buildShapesPrebuiltData, createShapesDeckLayer, + deriveStrokeColor, + featureFromBinary, DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, DEFAULT_SHAPE_STROKE_WIDTH_UNITS, @@ -142,7 +144,7 @@ describe('createShapesDeckLayer', () => { ]); }); - it('uses fill colours for outlines by default with zoom-scaled stroke defaults', () => { + it('derives a lighter outline from the fill by default with zoom-scaled stroke defaults', () => { const layer = createShapesDeckLayer( renderData, { @@ -157,8 +159,8 @@ describe('createShapesDeckLayer', () => { { id: 'shapes-fill-stroke' } ); - if (!layer) { - throw new Error('Expected shapes layer to render'); + if (!layer || Array.isArray(layer)) { + throw new Error('Expected a single object-path shapes layer'); } const props = layer.props as unknown as { data: Array<{ featureId: string }>; @@ -179,8 +181,12 @@ describe('createShapesDeckLayer', () => { expect(props.updateTriggers.getLineColor).toHaveLength(5); expect(props.updateTriggers.getFillColor[0]).toBeInstanceOf(Map); expect(props.updateTriggers.getLineWidth).toEqual([1]); - expect(props.getLineColor(props.data[0])).toEqual([1, 2, 3, 180]); - expect(props.getLineColor(props.data[1])).toEqual([10, 20, 30, 180]); + // Outline = a lighter derivation of the fill, not the fill itself, so the + // boundary is visible. cell-1 has a per-feature fill; cell-2 uses the default. + expect(props.getLineColor(props.data[0])).toEqual(deriveStrokeColor([1, 2, 3, 180])); + expect(props.getLineColor(props.data[1])).toEqual(deriveStrokeColor([10, 20, 30, 180])); + // The derived outline is genuinely distinct from (lighter than) the fill. + expect(props.getLineColor(props.data[0])).not.toEqual([1, 2, 3, 180]); }); it('allows callers to configure polygon stroke width behavior', () => { @@ -454,6 +460,115 @@ describe('createShapesDeckLayer', () => { }); }); +describe('binary (flat-polygons) render path', () => { + const binaryRenderData: ShapesRenderDataLike = { + kind: 'flat-polygons', + geometryKind: 'polygon', + elementKey: 'cells', + featureIds: ['cell-1', 'cell-2'], + polygonBinary: { + // Two triangles: feature 0 at the origin, feature 1 near (10,10). + positions: new Float32Array([0, 0, 1, 0, 0, 1, 10, 10, 11, 10, 10, 11]), + startIndices: new Int32Array([0, 3, 6]), + }, + rowIndexByFeatureIndex: new Int32Array([5, 7]), + }; + + it('builds a binary prebuilt with no per-feature array', () => { + const prebuilt = buildShapesPrebuiltData(binaryRenderData, ['cell-2']); + expect(prebuilt.geometryKind).toBe('polygon'); + // Hidden features are NOT excluded from the binary buffer (index alignment). + expect(prebuilt.data).toEqual([]); + expect(prebuilt.binary?.featureIds).toEqual(['cell-1', 'cell-2']); + expect(prebuilt.binary?.startIndices).toBe(binaryRenderData.polygonBinary?.startIndices); + }); + + it('renders a single vertex-pulling FlatPolygonLayer with topology + per-feature colour', () => { + const prebuilt = buildShapesPrebuiltData(binaryRenderData); + const layer = createShapesDeckLayer( + binaryRenderData, + { + kind: 'shapes', + elementKey: 'cells', + visible: true, + defaultFillColor: [10, 20, 30, 255], + featureState: { + fillColorByFeatureId: { 'cell-1': [1, 2, 3, 255] }, + hiddenFeatureIds: ['cell-2'], + }, + }, + { id: 'shapes-binary' }, + prebuilt + ); + + // The binary path returns a single fill+outline FlatPolygonLayer; the shader pulls + // position + edge-distance from the topology, so there are no vertex attributes. + if (!Array.isArray(layer)) { + throw new Error('Expected the binary path to return a single-layer array'); + } + expect(layer).toHaveLength(1); + const props = layer[0].props as any; + + // Two triangular rings → 2 triangles, 6 shared ring vertices. Topology packs the + // feature index above the 3 boundary-flag bits; both triangles are all-boundary + // (each ring is itself a triangle), so flags = 0b111 = 7. + expect(props.triangleCount).toBe(2); + expect(props.ringVertexCount).toBe(6); + const td = props.triangleData as Uint32Array; + expect(td[3] >>> 3).toBe(0); // triangle 0 → feature 0 + expect(td[3] & 7).toBe(7); + expect(td[7] >>> 3).toBe(1); // triangle 1 → feature 1 + expect(td[7] & 7).toBe(7); + + // Colour is a per-FEATURE buffer (2 features → 8 bytes), sampled by index in the + // shader — not expanded to vertices. + expect(props.featureCount).toBe(2); + const featureColors = props.featureColors as Uint8Array; + expect(featureColors).toHaveLength(2 * 4); + // Feature 0 (cell-1) → chosen fill colour; feature 1 (cell-2) hidden → transparent + // (kept, not dropped; the shader discards it). + expect(Array.from(featureColors.slice(0, 4))).toEqual([1, 2, 3, 255]); + expect(Array.from(featureColors.slice(4, 8))).toEqual([0, 0, 0, 0]); + }); + + it('reconstructs a picked feature (and its ring) by index', () => { + const prebuilt = buildShapesPrebuiltData(binaryRenderData); + const feature = resolveShapeFeatureFromPick({ index: 1 }, prebuilt); + expect(feature).toMatchObject({ featureId: 'cell-2', featureIndex: 1, rowIndex: 7 }); + expect((feature as { polygon: number[][][] }).polygon).toEqual([ + [ + [10, 10], + [11, 10], + [10, 11], + ], + ]); + // Out-of-range index yields nothing rather than a bogus feature. + expect(featureFromBinary(prebuilt.binary!, 99)).toBeUndefined(); + }); + + it('keeps geometry topology + per-feature colour buffers stable across renders', () => { + // The geometry textures are built from `triangleData`/`ringPositions`, whose + // identities NEVER change with feature-state — they upload once. The per-feature + // colour buffer is stable across bare re-renders (same runtime), so its texture is + // rebuilt only on a real feature-state change, never on hover/pan. + const featureState = normalizeShapeFeatureState(undefined); // EMPTY singleton + const sublayer = { kind: 'shapes', elementKey: 'cells', visible: true, featureState } as const; + const prebuilt = buildShapesPrebuiltData(binaryRenderData); + + const first = createShapesDeckLayer(binaryRenderData, sublayer, { id: 'x' }, prebuilt); + const second = createShapesDeckLayer(binaryRenderData, sublayer, { id: 'x' }, prebuilt); + if (!Array.isArray(first) || !Array.isArray(second)) { + throw new Error('Expected the binary path to return a single-layer array'); + } + + // Stable topology buffer identities → geometry textures are not rebuilt. + expect((second[0].props as any).triangleData).toBe((first[0].props as any).triangleData); + expect((second[0].props as any).ringPositions).toBe((first[0].props as any).ringPositions); + // Stable per-feature colour buffer identity → no colour texture rebuild. + expect((second[0].props as any).featureColors).toBe((first[0].props as any).featureColors); + }); +}); + describe('SpatialLayer', () => { it('builds a shapes layer from runtime render data', () => { const layer = new SpatialLayer({ diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index 44aa797e..a65c75bd 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -278,6 +278,12 @@ export function useSpatialCanvasRendererFromLayerInputs({ const hasRenderableInputs = hasEnabledLayers || hasExternalDeckLayers; const hasLayersDrawn = deckLayers.length > 0 || vivLayerProps.length > 0; + // Latches once a visible layer has been observed loading. It lets the auto-fit + // distinguish "no world bounds *yet*" (data still arriving — stay armed) from "no + // world bounds *ever*" (everything settled without any — default the view). See + // the effect below. + const autoFitLoadingObservedRef = useRef(false); + useEffect(() => { // Skip auto-fit when viewState is not managed by this hook (caller handles it). if (viewState === undefined || !onViewStateChange) return; @@ -293,12 +299,24 @@ export function useSpatialCanvasRendererFromLayerInputs({ ) { return; } + if (layerData.isLoading) { + autoFitLoadingObservedRef.current = true; + } const bounds = layerData.getWorldBoundsForVisibleLayers(); - onViewStateChange( - bounds ? viewStateFromBounds(bounds, width, height) : { target: [0, 0], zoom: 0 } - ); + if (bounds) { + onViewStateChange(viewStateFromBounds(bounds, width, height)); + return; + } + // No world bounds. Non-blocking layers (shapes) can settle their geometry — and + // thus their bounds — *after* this effect first fires, so defaulting the view + // here unconditionally is what left a shapes-only view un-framed. Only default + // once loading has actually run and finished without producing bounds; otherwise + // stay armed (`viewState` stays null) and re-fit when `isLoading` next flips. + if (autoFitLoadingObservedRef.current && !layerData.isLoading) { + onViewStateChange({ target: [0, 0], zoom: 0 }); + } // useLayerData returns a fresh object every render, so we intentionally depend - // on its stable members (memoized flag + useCallback'd bounds getter) rather + // on its stable members (memoized flags + useCallback'd bounds getter) rather // than `layerData` itself, which would re-run this effect on every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -306,6 +324,7 @@ export function useSpatialCanvasRendererFromLayerInputs({ hasEnabledLayers, height, layerData.isBlocking, + layerData.isLoading, layerData.getWorldBoundsForVisibleLayers, onViewStateChange, viewState, diff --git a/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts b/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts index 3089bcf9..f3f2d264 100644 --- a/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts +++ b/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts @@ -10,6 +10,7 @@ import type { Matrix4 } from '@math.gl/core'; import type { ShapesElement, ShapesRenderData, SpatialFeatureTooltipData } from '@spatialdata/core'; import { createShapesDeckLayer, + DEFAULT_SHAPE_FILL_COLOR, DEFAULT_SHAPE_STROKE_WIDTH, DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, @@ -78,13 +79,13 @@ export interface ShapesLayerRenderConfig { * Note: This requires the polygon data to be pre-loaded since deck.gl layers * are synchronous. The data loading should happen at a higher level. */ -export function renderShapesLayer(config: ShapesLayerRenderConfig): Layer | null { +export function renderShapesLayer(config: ShapesLayerRenderConfig): Layer | Layer[] | null { const { id, modelMatrix, opacity, visible, - fillColor = [100, 100, 200, 180], + fillColor = DEFAULT_SHAPE_FILL_COLOR, strokeColor, strokeWidth = DEFAULT_SHAPE_STROKE_WIDTH, strokeWidthUnits = DEFAULT_SHAPE_STROKE_WIDTH_UNITS, diff --git a/packages/vis/src/SpatialCanvas/shapesProjection.ts b/packages/vis/src/SpatialCanvas/shapesProjection.ts index 92ecd1ec..29d5c554 100644 --- a/packages/vis/src/SpatialCanvas/shapesProjection.ts +++ b/packages/vis/src/SpatialCanvas/shapesProjection.ts @@ -79,10 +79,11 @@ function mergeShapeFeatureStateForRender( return { ...config.featureState, fillColorByFeatureId, - // Outlines mirror the fill-colour encoding by default (a fill encoding normally - // drives both fill and outline — see docs/vis/layer-prop-flow), but an explicit - // per-feature stroke override from the caller wins when one is present. - strokeColorByFeatureId: config.featureState?.strokeColorByFeatureId ?? fillColorByFeatureId, + // The outline is NOT pre-mirrored from the fill here. `@spatialdata/layers` + // derives a lighter outline from each feature's resolved fill at render time + // (see `deriveStrokeColor`), so a same-colour outline would be invisible. A + // genuine per-feature stroke override from the caller (carried by the spread + // above) still wins over that derivation. }; } @@ -107,11 +108,26 @@ export function getStableShapeFeatureStateRuntime( layerId: string, config: ShapesLayerConfig, fillColorEntry: ShapeFillColorEntry | undefined, - cache: Map + cache: Map< + string, + { + signature: string; + runtime: ShapeFeatureStateRuntime; + fillColorEntry: ShapeFillColorEntry | undefined; + } + > ): ShapeFeatureStateRuntime { const signature = getShapeFeatureStateSignature(config, fillColorEntry); const cached = cache.get(layerId); - if (cached?.signature === signature) { + // The entry's identity is part of the key, not just its signature string. The + // signature is column-based (name/mode/alpha), but a column change serves the + // PREVIOUS column's rows until the new ones load — so the entry's *data* can + // change while its signature does not. `getShapeFillColorEntry` returns a fresh + // entry object whenever the rows change, making identity the exact "the colours + // are now different" signal; keying on it alone would leave the runtime one + // column behind. It stays stable across bare re-renders (same cached entry), so + // the fill-colour buffer is not thrashed. + if (cached?.signature === signature && cached.fillColorEntry === fillColorEntry) { return cached.runtime; } @@ -119,6 +135,6 @@ export function getStableShapeFeatureStateRuntime( const runtime = merged ? buildShapeFeatureStateRuntime(merged) : EMPTY_SHAPE_FEATURE_STATE_RUNTIME; - cache.set(layerId, { signature, runtime }); + cache.set(layerId, { signature, runtime, fillColorEntry }); return runtime; } diff --git a/packages/vis/src/SpatialCanvas/useLayerData.ts b/packages/vis/src/SpatialCanvas/useLayerData.ts index a2f0648c..0f51adc3 100644 --- a/packages/vis/src/SpatialCanvas/useLayerData.ts +++ b/packages/vis/src/SpatialCanvas/useLayerData.ts @@ -13,6 +13,7 @@ import { type AxisAlignedBounds, attachTooltipElementContext, boundsFromCircles, + boundsFromFlatPolygonPositions, boundsFromImagePixelExtents, boundsFromPoints, boundsFromPolygons, @@ -352,7 +353,14 @@ export function useLayerData( Map >(new Map()); const stableShapeFeatureStateRef = useRef< - Map + Map< + string, + { + signature: string; + runtime: ShapeFeatureStateRuntime; + fillColorEntry: ShapeFillColorEntry | undefined; + } + > >(new Map()); // Mirror the latest `layers` into a ref. This is read both by async loaders @@ -780,6 +788,12 @@ export function useLayerData( ) { return boundsFromCircles(renderData.circles, elem.transform); } + if (renderData.polygonBinary) { + return boundsFromFlatPolygonPositions( + renderData.polygonBinary.positions, + elem.transform + ); + } if (!renderData.polygons?.length) return null; return boundsFromPolygons(renderData.polygons, elem.transform); } @@ -898,7 +912,10 @@ export function useLayerData( ), pickingEnabled, }); - if (layer) deckLayers.push(layer); + // The binary polygon path returns [fill, outline]; flatten so both + // reach deck as siblings (the outline draws over the fill). + if (Array.isArray(layer)) deckLayers.push(...layer); + else if (layer) deckLayers.push(layer); } } else if (config.type === 'points') { const element = elem.element as PointsElement; @@ -1421,9 +1438,14 @@ export function useLayerData( if (config.type === 'labels') { return state.image === 'loading' && !hasRenderableLayerData(layerId); } - if (config.type === 'shapes' || config.type === 'points') { + if (config.type === 'points') { return state.geometry === 'loading' && !hasRenderableLayerData(layerId); } + // Shapes load non-blocking: geometry refines an already-painted canvas and + // never gates first paint (`ShapesResolver.blockingResources` is empty). + // `getLayers` skips a shapes layer until its geometry is ready, and the + // non-modal `isLoading` indicator covers the wait. + if (config.type === 'shapes') return false; return false; }), [layerLoadStates, layerOrder, layers, hasRenderableLayerData] diff --git a/packages/vis/tests/shapesProjection.spec.ts b/packages/vis/tests/shapesProjection.spec.ts new file mode 100644 index 00000000..3b0e5d62 --- /dev/null +++ b/packages/vis/tests/shapesProjection.spec.ts @@ -0,0 +1,67 @@ +import type { ShapeFeatureStateRuntime } from '@spatialdata/layers'; +import { describe, expect, it } from 'vitest'; +import { + getStableShapeFeatureStateRuntime, + type ShapeFillColorEntry, +} from '../src/SpatialCanvas/shapesProjection'; +import type { ShapesLayerConfig } from '../src/SpatialCanvas/types'; + +/** + * The fill-colour feature-state runtime cache. + * + * Regression for the "one column behind" bug: when the fill column changes, the + * resolver keeps serving the *previous* column's rows until the new ones settle, + * so the entry is first built from stale rows and cached under the new column's + * signature. When the real rows arrive the entry's DATA updates but its + * column-based signature does not — so the runtime cache must invalidate on the + * entry's *identity*, not just its signature string, or it stays one behind. + */ + +const config = { + type: 'shapes', + fillColorByColumn: { columnName: 'colY', mode: 'category' }, +} as unknown as ShapesLayerConfig; + +const entry = ( + fillColorByFeatureId: Record +): ShapeFillColorEntry => ({ + // Same signature string across both entries — the column name/mode/alpha did not + // change, only the async-loaded row data (and thus the entry identity) did. + signature: 'colYcategory180', + fillColorByFeatureId, + rowsSource: {}, + renderSource: undefined, +}); + +describe('getStableShapeFeatureStateRuntime', () => { + it('picks up new fill-colour data when the entry changes under an unchanged signature', () => { + const cache = new Map(); + + // First: entry built from the PREVIOUS column's still-loaded rows. + const stale = getStableShapeFeatureStateRuntime( + 'layer-1', + config, + entry({ f1: [10, 20, 30, 255] }), + cache + ); + expect(stale.fillColorByFeatureId.get('f1')).toEqual([10, 20, 30, 255]); + + // Then: the newly-selected column's rows arrive — a NEW entry, same signature. + const fresh = getStableShapeFeatureStateRuntime( + 'layer-1', + config, + entry({ f1: [200, 100, 50, 255] }), + cache + ); + // Must reflect the latest data, not the previous column's. + expect(fresh.fillColorByFeatureId.get('f1')).toEqual([200, 100, 50, 255]); + }); + + it('returns a stable runtime identity when the entry is unchanged (no buffer thrash)', () => { + const cache = new Map(); + const sameEntry = entry({ f1: [1, 2, 3, 255] }); + const a = getStableShapeFeatureStateRuntime('layer-1', config, sameEntry, cache); + const b = getStableShapeFeatureStateRuntime('layer-1', config, sameEntry, cache); + expect(b).toBe(a); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5f69b38..9b76fe92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: apache-arrow: specifier: 'catalog:' version: 17.0.0 + earcut: + specifier: ^3.0.2 + version: 3.0.2 ol: specifier: ^10.6.1 version: 10.6.1 @@ -298,6 +301,9 @@ importers: '@hms-dbmi/viv': specifier: 'catalog:' version: 0.22.0(c47650f6ff60bbdb0774de859880a368) + '@luma.gl/engine': + specifier: ^9.3.5 + version: 9.3.5(@luma.gl/core@9.3.5)(@luma.gl/shadertools@9.3.5(@luma.gl/core@9.3.5)) '@math.gl/core': specifier: 'catalog:' version: 4.1.0 From 2c258ecd7212da2b2c40b10a8a0218a9186e0c7d Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 16:24:59 +0100 Subject: [PATCH 02/10] Shapes: key per-feature colour cache by layer id, not shared runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two shapes layers with no explicit featureState both resolve to the same EMPTY_SHAPE_FEATURE_STATE_RUNTIME singleton, so a runtime-keyed colour cache held one slot the layers evicted each other in — a per-frame rebuild of both (million-element) buffers on every getLayers() (pan/hover). Pan start surfaced it as a createShapesLayer block dominated by getFeatureColors. Key the cache by layer id, reusing the entry only while (runtime, featureIds, defaultColor) keep identity — the exact inputs the buffer depends on. Two layers get two slots; the buffer stays stable across bare re-renders and rebuilds precisely on a real feature-state/element/default-colour change. Co-Authored-By: Claude Opus 4.8 --- packages/layers/src/shapesLayer.ts | 52 +++++++++++++++++------ packages/layers/tests/shapesLayer.spec.ts | 49 ++++++++++++++++++++- 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/packages/layers/src/shapesLayer.ts b/packages/layers/src/shapesLayer.ts index 0a22a9a7..52e27adb 100644 --- a/packages/layers/src/shapesLayer.ts +++ b/packages/layers/src/shapesLayer.ts @@ -453,23 +453,45 @@ function getTessellation(positions: Float32Array, startIndices: Int32Array): Tes /** * Per-**feature** RGBA colours (the "table column → buffer" primitive), rebuilt only - * when feature-state changes. Keyed on the feature-state runtime identity (which - * changes exactly on a real feature-state change), so the returned buffer has a - * **stable identity** across bare re-renders. This is `featureCount` texels — the - * layer uploads it to a small texture and the shader samples it by feature index, so - * the (large) geometry textures never re-upload. Hide/fade are folded into alpha. + * when feature-state changes. This is `featureCount` texels — the layer uploads it to + * a small texture and the shader samples it by feature index, so the (large) geometry + * textures never re-upload. Hide/fade are folded into alpha. + * + * Keyed by **layer id**, not the feature-state runtime: two layers with no explicit + * feature-state both resolve to the SAME `EMPTY_SHAPE_FEATURE_STATE_RUNTIME` singleton + * (as do two layers with identical feature-state), so a runtime-keyed cache holds one + * slot the layers overwrite each other in — a per-frame rebuild of both (million- + * element) buffers on every `getLayers()` (pan/hover). The layer id is the stable + * per-layer discriminator; the entry is reused only while all of (runtime, feature-id + * array, default colour) keep their identity — the exact set the buffer's contents + * depend on — so it stays stable across bare re-renders and rebuilds precisely when a + * real change (feature-state, element swap, default-colour change) occurs. */ -const featureColorsCache = new WeakMap(); +interface FeatureColorsCacheEntry { + runtime: ShapeFeatureStateRuntime; + featureIds: readonly string[]; + defaultColor: readonly number[]; + colors: Uint8Array; +} +const featureColorsCache = new Map(); function getFeatureColors( + layerId: string, featureState: ShapeFeatureStateRuntime, - featureCount: number, + featureIds: readonly string[], + defaultColor: readonly number[], colorForFeatureIndex: (index: number) => [number, number, number, number] ): Uint8Array { - const cached = featureColorsCache.get(featureState); - if (cached && cached.length === featureCount * 4) { - return cached; + const cached = featureColorsCache.get(layerId); + if ( + cached && + cached.runtime === featureState && + cached.featureIds === featureIds && + cached.defaultColor === defaultColor + ) { + return cached.colors; } + const featureCount = featureIds.length; const colors = new Uint8Array(featureCount * 4); for (let f = 0; f < featureCount; f += 1) { const c = colorForFeatureIndex(f); @@ -478,7 +500,7 @@ function getFeatureColors( colors[f * 4 + 2] = c[2]; colors[f * 4 + 3] = c[3]; } - featureColorsCache.set(featureState, colors); + featureColorsCache.set(layerId, { runtime: featureState, featureIds, defaultColor, colors }); return colors; } @@ -969,7 +991,13 @@ function createBinaryPolygonDeckLayers( ); }; - const featureColors = getFeatureColors(featureState, featureIds.length, fillColorAt); + const featureColors = getFeatureColors( + options.id, + featureState, + featureIds, + defaultFillColor, + fillColorAt + ); const layer = new FlatPolygonLayer({ id: options.id, diff --git a/packages/layers/tests/shapesLayer.spec.ts b/packages/layers/tests/shapesLayer.spec.ts index 5572c887..5e119a05 100644 --- a/packages/layers/tests/shapesLayer.spec.ts +++ b/packages/layers/tests/shapesLayer.spec.ts @@ -4,11 +4,11 @@ import { buildShapeFeatureStateRuntime, buildShapesPrebuiltData, createShapesDeckLayer, - deriveStrokeColor, - featureFromBinary, DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, DEFAULT_SHAPE_STROKE_WIDTH_UNITS, + deriveStrokeColor, + featureFromBinary, type GeoarrowTableLike, isShapeFeatureStateRuntime, normalizeShapeFeatureState, @@ -567,6 +567,51 @@ describe('binary (flat-polygons) render path', () => { // Stable per-feature colour buffer identity → no colour texture rebuild. expect((second[0].props as any).featureColors).toBe((first[0].props as any).featureColors); }); + + it('does not thrash the per-feature colour buffer between two layers sharing a runtime', () => { + // Two shapes layers, neither with an explicit featureState, both resolve to the + // SAME `EMPTY_SHAPE_FEATURE_STATE_RUNTIME` singleton. With different feature + // counts, a colour cache keyed only on the runtime holds one buffer and the two + // layers overwrite each other every `getLayers()` call — a per-frame rebuild of + // both million-element buffers during pan/hover. The buffer must instead stay + // stable per layer across bare re-renders. + const dataA = binaryRenderData; // 2 features + const dataB: ShapesRenderDataLike = { + kind: 'flat-polygons', + geometryKind: 'polygon', + elementKey: 'cellsB', + featureIds: ['b-1', 'b-2', 'b-3'], + polygonBinary: { + // Three triangular rings → 3 features. + // biome-ignore format: coordinate triples read clearer grouped + positions: new Float32Array([ + 0, 0, 1, 0, 0, 1, + 10, 10, 11, 10, 10, 11, + 20, 20, 21, 20, 20, 21, + ]), + startIndices: new Int32Array([0, 3, 6, 9]), + }, + rowIndexByFeatureIndex: new Int32Array([1, 2, 3]), + }; + const sublayerA = { kind: 'shapes', elementKey: 'cells', visible: true } as const; + const sublayerB = { kind: 'shapes', elementKey: 'cellsB', visible: true } as const; + const prebuiltA = buildShapesPrebuiltData(dataA); + const prebuiltB = buildShapesPrebuiltData(dataB); + + // Interleaved renders mimic two shapes layers built in one `getLayers()` pass, + // repeated each frame. + const a1 = createShapesDeckLayer(dataA, sublayerA, { id: 'A' }, prebuiltA) as any[]; + const b1 = createShapesDeckLayer(dataB, sublayerB, { id: 'B' }, prebuiltB) as any[]; + const a2 = createShapesDeckLayer(dataA, sublayerA, { id: 'A' }, prebuiltA) as any[]; + const b2 = createShapesDeckLayer(dataB, sublayerB, { id: 'B' }, prebuiltB) as any[]; + + expect((a1[0].props as any).featureColors).toHaveLength(2 * 4); + expect((b1[0].props as any).featureColors).toHaveLength(3 * 4); + // Each layer keeps a stable colour buffer identity across re-renders — the other + // layer's render must not evict it. + expect((a2[0].props as any).featureColors).toBe((a1[0].props as any).featureColors); + expect((b2[0].props as any).featureColors).toBe((b1[0].props as any).featureColors); + }); }); describe('SpatialLayer', () => { From 971cf2ba8bb748806d1e5c37bcd790c7866b9e69 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 16:31:00 +0100 Subject: [PATCH 03/10] Shapes: keep picking live during pan/zoom (drop the interaction gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `pickingEnabled = … && !interacting` guard disabled shape picking + autoHighlight during camera moves to avoid deck's full-geometry picking-buffer draw on the old SolidPolygonLayer + PathLayer path. The FlatPolygonLayer picking pass is a single vertex-pulled draw, so the guard is no longer needed; picking now stays live through pan/zoom (verified smooth on Visium HD square_002um with two shapes layers). Pulled at both call sites — SpatialCanvasInner (full-UI) and SpatialCanvasViewer (headless). The interaction gate is still driven via onInteractionStateChange, so restoring the guard is a one-line change per site. Co-Authored-By: Claude Opus 4.8 --- .../vis/src/SpatialCanvas/SpatialCanvasViewer.tsx | 12 +++++++++--- packages/vis/src/SpatialCanvas/index.tsx | 14 ++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index a65c75bd..c33693a3 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -455,9 +455,15 @@ function SpatialCanvasViewerInner({ const [measureRef, { width, height }] = useMeasure(); const viewerContainerRef = useRef(null); const deckRef = useRef(null); - const { interacting, onInteractionStateChange } = useViewInteractionGate(); - // Shapes are pickable unless tooltips are off or the camera is being moved. - const pickingEnabled = hoverTooltipMode !== 'off' && !interacting; + const { onInteractionStateChange } = useViewInteractionGate(); + // Shapes are pickable whenever hover tooltips are on. The camera-move suppression + // (`&& !interacting`) that used to gate this off during pan/zoom is intentionally + // removed: it guarded against deck's full-geometry picking-buffer draw on the old + // PolygonLayer + PathLayer path, but the FlatPolygonLayer picking pass is a single + // vertex-pulled draw. Keeping picking live during interaction is under evaluation; + // `interacting` from the gate is still driven (`onInteractionStateChange` below) so + // restoring the guard is a one-line change. + const pickingEnabled = hoverTooltipMode !== 'off'; const [hoverTooltip, setHoverTooltip] = useState< (SpatialFeatureTooltipData & { x: number; y: number; clientX: number; clientY: number }) | null >(null); diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index 8ba6e5ed..c3abeaf3 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -425,12 +425,14 @@ function SpatialCanvasInner({ const vw = width ?? 0; const vh = height ?? 0; - // Disable shape picking while the camera is being panned/zoomed. `interacting` - // flips at most twice per gesture (start/settle), so this hook re-runs to - // rebuild layers with toggled pickability — not on every pan frame. - const { interacting, onInteractionStateChange } = useViewInteractionGate(); - // Shapes are pickable unless tooltips are off or the camera is being moved. - const pickingEnabled = tooltipMode !== 'off' && !interacting; + // Shapes are pickable whenever hover tooltips are on. The camera-move suppression + // (`&& !interacting`) that used to gate this off during pan/zoom is intentionally + // removed: it guarded against deck's full-geometry picking-buffer draw on the old + // PolygonLayer + PathLayer path, but the FlatPolygonLayer picking pass is a single + // vertex-pulled draw. `interacting` from the gate is still driven + // (`onInteractionStateChange` below) so restoring the guard is a one-line change. + const { onInteractionStateChange } = useViewInteractionGate(); + const pickingEnabled = tooltipMode !== 'off'; // keeping a big bundle of these to pass into child components // (may not be a good idea). From 104031e7e02237839dabdccb7c7245378fde544e Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 16:32:51 +0100 Subject: [PATCH 04/10] vis: default hover tooltips to 'aggregate' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that shape picking stays live through pan/zoom (single vertex-pulled pick pass), the more informative aggregate tooltip — every feature under the cursor across layers — is affordable as the default. Changed at both entry points (SpatialCanvasInner, SpatialCanvasViewer) and their docstrings; the UI selector still switches modes live and callers can still pass an explicit mode. Co-Authored-By: Claude Opus 4.8 --- packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx | 9 +++++---- packages/vis/src/SpatialCanvas/index.tsx | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index c33693a3..2ce57969 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -116,9 +116,10 @@ export interface SpatialCanvasViewerProps { autoFit?: boolean; style?: CSSProperties; /** - * Hover tooltip / picking behaviour: `'off'` | `'simple'` (default) | - * `'aggregate'`. See {@link HoverTooltipMode}. `'aggregate'` is more expensive - * (extra GPU pick passes per move); `'off'` disables shape picking entirely. + * Hover tooltip / picking behaviour: `'off'` | `'simple'` | `'aggregate'` + * (default). See {@link HoverTooltipMode}. `'aggregate'` reports every feature + * under the cursor across layers (extra GPU pick passes per move); `'off'` + * disables shape picking entirely. */ hoverTooltipMode?: HoverTooltipMode; /** Global fallback Viv LayerExtension instances for image layers. */ @@ -447,7 +448,7 @@ function SpatialCanvasViewerInner({ showLoadingOverlay = true, autoFit = true, style, - hoverTooltipMode = 'simple', + hoverTooltipMode = 'aggregate', vivImageExtensions, vivImageExtensionResolver, vivImagePropsResolver, diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index c3abeaf3..7d43ba92 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -384,7 +384,7 @@ interface SpatialCanvasInnerProps { function SpatialCanvasInner({ tooltipContainer, renderTooltip, - hoverTooltipMode = 'simple', + hoverTooltipMode = 'aggregate', }: SpatialCanvasInnerProps) { // Points reactivity now lives in (the panel // subscribes to the engine via useSyncExternalStore), so this component no @@ -990,7 +990,7 @@ export interface SpatialCanvasProps { */ renderTooltip?: (props: SpatialCanvasTooltipRenderProps) => ReactNode; /** - * Initial hover tooltip mode: `'off'` | `'simple'` (default) | `'aggregate'`. + * Initial hover tooltip mode: `'off'` | `'simple'` | `'aggregate'` (default). * The canvas UI exposes a selector to change it live. See {@link HoverTooltipMode}. */ hoverTooltipMode?: HoverTooltipMode; From 26080ecdc1ff405a0a3c9a1aa01be156a05cf31d Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 16:45:02 +0100 Subject: [PATCH 05/10] vis: remove the interaction-gate machinery entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pan/zoom picking gate is gone (picking stays live on the FlatPolygonLayer path), so nothing consumes the interaction state anymore. Delete useViewInteractionGate and its wiring at both call sites: - SpatialCanvasViewer: drop the hook and the mergedDeckProps merge; the caller's deckProps (incl. their own onInteractionStateChange) now passes straight through. - SpatialCanvasInner/ViewerSection: drop the hook, the onInteractionStateChange prop, and the deckProps memo that only carried it. Removes the duplicated gate logic across the two components — the source of the earlier "second gate got missed" hazard. Co-Authored-By: Claude Opus 4.8 --- .../src/SpatialCanvas/SpatialCanvasViewer.tsx | 30 ++------- packages/vis/src/SpatialCanvas/index.tsx | 16 +---- .../SpatialCanvas/useViewInteractionGate.ts | 61 ------------------- 3 files changed, 8 insertions(+), 99 deletions(-) delete mode 100644 packages/vis/src/SpatialCanvas/useViewInteractionGate.ts diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index 2ce57969..7c301dd3 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -41,7 +41,6 @@ import { type ShapeFeaturePickEventData, useLayerData, } from './useLayerData'; -import { useViewInteractionGate } from './useViewInteractionGate'; import { getAvailableElements } from './utils'; import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; import type { @@ -456,14 +455,10 @@ function SpatialCanvasViewerInner({ const [measureRef, { width, height }] = useMeasure(); const viewerContainerRef = useRef(null); const deckRef = useRef(null); - const { onInteractionStateChange } = useViewInteractionGate(); - // Shapes are pickable whenever hover tooltips are on. The camera-move suppression - // (`&& !interacting`) that used to gate this off during pan/zoom is intentionally - // removed: it guarded against deck's full-geometry picking-buffer draw on the old - // PolygonLayer + PathLayer path, but the FlatPolygonLayer picking pass is a single - // vertex-pulled draw. Keeping picking live during interaction is under evaluation; - // `interacting` from the gate is still driven (`onInteractionStateChange` below) so - // restoring the guard is a one-line change. + // Shapes are pickable whenever hover tooltips are on. Picking stays live through + // pan/zoom: the FlatPolygonLayer picking pass is a single vertex-pulled draw, so + // the old "suppress picking during camera moves" gate (a full-geometry pick draw + // on the SolidPolygonLayer + PathLayer path) is no longer needed. const pickingEnabled = hoverTooltipMode !== 'off'; const [hoverTooltip, setHoverTooltip] = useState< (SpatialFeatureTooltipData & { x: number; y: number; clientX: number; clientY: number }) | null @@ -513,21 +508,6 @@ function SpatialCanvasViewerInner({ () => Array.from(renderer.enabledLayerIds), [renderer.enabledLayerIds] ); - // Feed deck's interaction state into the gate (via deckProps so the existing - // spread reaches both DeckGL branches) while still invoking any caller-supplied - // onInteractionStateChange handler. - const mergedDeckProps = useMemo(() => { - const callerOnInteractionStateChange = deckProps?.onInteractionStateChange; - return { - ...deckProps, - onInteractionStateChange: ( - state: Parameters>[0] - ) => { - callerOnInteractionStateChange?.(state); - onInteractionStateChange(state); - }, - }; - }, [deckProps, onInteractionStateChange]); // The expensive part of hovering: aggregate-pick the feature(s) under the // cursor and position the tooltip. Throttled to one run per animation frame. @@ -717,7 +697,7 @@ function SpatialCanvasViewerInner({ vivLayerProps={renderer.vivLayerProps.length > 0 ? renderer.vivLayerProps : undefined} onHover={hoverTooltipMode === 'off' ? undefined : handleHover} onClick={handleClick} - deckProps={mergedDeckProps} + deckProps={deckProps} deckRef={deckRef} /> {showLoadingOverlay && renderer.isBlocking && ( diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index 7d43ba92..96bccab1 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -50,7 +50,6 @@ import type { SpatialCanvasStoreApi } from './stores'; import { TooltipFieldsPanel } from './TooltipFieldsPanel'; import type { AvailableElement, ElementsByType, ViewState } from './types'; import type { ImageLayerConfig } from './useLayerData'; -import { useViewInteractionGate, type ViewInteractionState } from './useViewInteractionGate'; import { generateLayerId, getAllCoordinateSystems } from './utils'; import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; @@ -227,7 +226,6 @@ interface ViewerSectionProps { vw: number; vh: number; onHover?: (info: PickingInfo, event?: HoverPointerEvent) => void; - onInteractionStateChange: (state: ViewInteractionState) => void; coordinateSystem: string | null; deckRef: React.RefObject; } @@ -244,11 +242,9 @@ function ViewerSection({ vw, vh, onHover, - onInteractionStateChange, coordinateSystem, deckRef, }: ViewerSectionProps) { - const deckProps = useMemo(() => ({ onInteractionStateChange }), [onInteractionStateChange]); const viewState = useSpatialCanvasStore((s) => s.viewState); const actions = useSpatialCanvasActions(); @@ -312,7 +308,6 @@ function ViewerSection({ layerOrder={layerOrder} vivLayerProps={vivLayerProps.length > 0 ? vivLayerProps : undefined} onHover={onHover} - deckProps={deckProps} deckRef={deckRef} /> {isBlocking && ( @@ -425,13 +420,9 @@ function SpatialCanvasInner({ const vw = width ?? 0; const vh = height ?? 0; - // Shapes are pickable whenever hover tooltips are on. The camera-move suppression - // (`&& !interacting`) that used to gate this off during pan/zoom is intentionally - // removed: it guarded against deck's full-geometry picking-buffer draw on the old - // PolygonLayer + PathLayer path, but the FlatPolygonLayer picking pass is a single - // vertex-pulled draw. `interacting` from the gate is still driven - // (`onInteractionStateChange` below) so restoring the guard is a one-line change. - const { onInteractionStateChange } = useViewInteractionGate(); + // Shapes are pickable whenever hover tooltips are on. Picking stays live through + // pan/zoom: the FlatPolygonLayer picking pass is a single vertex-pulled draw, so + // the old "suppress picking during camera moves" gate is no longer needed. const pickingEnabled = tooltipMode !== 'off'; // keeping a big bundle of these to pass into child components @@ -770,7 +761,6 @@ function SpatialCanvasInner({ vw={vw} vh={vh} onHover={tooltipMode === 'off' ? undefined : handleHover} - onInteractionStateChange={onInteractionStateChange} coordinateSystem={coordinateSystem} deckRef={deckRef} /> diff --git a/packages/vis/src/SpatialCanvas/useViewInteractionGate.ts b/packages/vis/src/SpatialCanvas/useViewInteractionGate.ts deleted file mode 100644 index b30d767c..00000000 --- a/packages/vis/src/SpatialCanvas/useViewInteractionGate.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -/** Minimal shape of deck.gl's interaction state (a subset of `InteractionState`). */ -export interface ViewInteractionState { - isDragging?: boolean; - isPanning?: boolean; - isZooming?: boolean; - inTransition?: boolean; -} - -export interface ViewInteractionGate { - /** True while the camera is being panned/zoomed (or settling within the idle delay). */ - interacting: boolean; - /** Wire to deck.gl's `onInteractionStateChange` (e.g. via `deckProps`). */ - onInteractionStateChange: (state: ViewInteractionState) => void; -} - -/** - * Tracks whether the deck.gl view is being actively manipulated, with the - * transition back to idle debounced by `idleDelayMs`. - * - * Picking large shape layers is expensive: deck re-renders the full geometry - * into the picking framebuffer on every pointer move to service hover / - * autoHighlight. Disabling shape picking while `interacting` is true removes that - * per-move cost during pan/zoom; the debounce avoids re-enabling (and paying a - * picking pass) in the gaps between rapid drag steps. - */ -export function useViewInteractionGate(idleDelayMs = 150): ViewInteractionGate { - const [interacting, setInteracting] = useState(false); - const timerRef = useRef | null>(null); - - const onInteractionStateChange = useCallback( - (state: ViewInteractionState) => { - const active = Boolean( - state && (state.isDragging || state.isPanning || state.isZooming || state.inTransition) - ); - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (active) { - setInteracting(true); - } else { - timerRef.current = setTimeout(() => { - timerRef.current = null; - setInteracting(false); - }, idleDelayMs); - } - }, - [idleDelayMs] - ); - - useEffect( - () => () => { - if (timerRef.current !== null) clearTimeout(timerRef.current); - }, - [] - ); - - return { interacting, onInteractionStateChange }; -} From 700da7f9504f29c0791f9b03557aedf02448c490 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 17:01:44 +0100 Subject: [PATCH 06/10] vis: extract shared hover-tooltip machinery into useHoverFeatureTooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SpatialCanvas surfaces (headless SpatialCanvasViewer and full-UI SpatialCanvasInner) carried their own copy of the hover-tooltip logic — the hoverTooltip state, the pick→tooltip resolution, and the document.body portal. That copy is now a single hook; each component keeps only its own thin handleHover (the drag guard, plus the viewer's extra onFeatureHover/onShapeHover pick-event callbacks). The hook takes already-derived `enabled`/`aggregate` booleans (not HoverTooltipMode) and expresses the render-tooltip union structurally, so it imports no types from SpatialCanvasViewer — no import cycle. A single DEFAULT_HOVER_TOOLTIP_MODE constant now backs both entry points' default so they can't drift. New useHoverFeatureTooltip.spec covers the hook's wiring (resolve → portal, the enabled / renderTooltip===false / clear / unpicked gates); the pick→tooltip core stays covered by featureTooltipHover.spec. Co-Authored-By: Claude Opus 4.8 --- .../src/SpatialCanvas/SpatialCanvasViewer.tsx | 133 +++++---------- packages/vis/src/SpatialCanvas/index.tsx | 103 +++--------- .../SpatialCanvas/useHoverFeatureTooltip.tsx | 155 ++++++++++++++++++ .../vis/tests/useHoverFeatureTooltip.spec.tsx | 98 +++++++++++ 4 files changed, 312 insertions(+), 177 deletions(-) create mode 100644 packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx create mode 100644 packages/vis/tests/useHoverFeatureTooltip.spec.tsx diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index 7c301dd3..85fe6d99 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -2,23 +2,9 @@ import { type SpatialData, viewStateFromBounds } from '@spatialdata/core'; import type { RenderStack } from '@spatialdata/layers'; import { useMeasure } from '@uidotdev/usehooks'; import type { DeckGLProps, DeckGLRef, Layer, PickingInfo } from 'deck.gl'; -import { - type CSSProperties, - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { createPortal } from 'react-dom'; +import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; import { ensureCodecWorkers } from '../codecWorkers'; -import { - getDeckFromDeckGlRef, - type HoverPointerEvent, - isHoverDuringDrag, - resolveHoverFeatureTooltip, -} from './featureTooltipHover'; +import { type HoverPointerEvent, isHoverDuringDrag } from './featureTooltipHover'; import { ImageLayerContextProvider } from './ImageLayerContext'; import { type RenderStackHostLayerResolver, @@ -29,13 +15,10 @@ import { sortLayersByRenderStackOrder, type UnknownRenderStackHostLayerHandler, } from './renderStackAdapters'; -import { - type SpatialCanvasTooltipRenderProps, - SpatialFeatureTooltip, - type SpatialFeatureTooltipData, -} from './SpatialFeatureTooltip'; +import type { SpatialCanvasTooltipRenderProps } from './SpatialFeatureTooltip'; import { SpatialViewer } from './SpatialViewer'; import type { ElementsByType, LayerConfig, ShapesLayerPickEvent, ViewState } from './types'; +import { useHoverFeatureTooltip } from './useHoverFeatureTooltip'; import { type LabelFeaturePickEventData, type ShapeFeaturePickEventData, @@ -63,9 +46,9 @@ export type SpatialCanvasViewerRenderTooltip = * Hover tooltip / picking behaviour: * - `'off'`: no hover tooltip, and shape layers are made non-pickable (no * autoHighlight, no picking buffer render) — the cheapest mode. - * - `'simple'` (default): tooltip from the single top-most pick under the cursor. - * - `'aggregate'`: tooltip aggregated across all layers under the cursor, which - * issues extra `pickMultipleObjects` GPU passes per pointer move. + * - `'simple'`: tooltip from the single top-most pick under the cursor. + * - `'aggregate'` (default): tooltip aggregated across all layers under the cursor, + * which issues extra `pickMultipleObjects` GPU passes per pointer move. */ export type HoverTooltipMode = 'off' | 'simple' | 'aggregate'; @@ -74,6 +57,12 @@ export function isHoverTooltipMode(value: string): value is HoverTooltipMode { return value === 'off' || value === 'simple' || value === 'aggregate'; } +/** + * The default hover tooltip mode for both SpatialCanvas surfaces. A single source of + * truth so the headless viewer and the full-UI canvas can't drift apart. + */ +export const DEFAULT_HOVER_TOOLTIP_MODE: HoverTooltipMode = 'aggregate'; + type SpatialFeaturePickEventRuntimeFields = { coordinateSystem: string | null; spatialData?: SpatialData | null; @@ -447,22 +436,18 @@ function SpatialCanvasViewerInner({ showLoadingOverlay = true, autoFit = true, style, - hoverTooltipMode = 'aggregate', + hoverTooltipMode = DEFAULT_HOVER_TOOLTIP_MODE, vivImageExtensions, vivImageExtensionResolver, vivImagePropsResolver, }: SpatialCanvasViewerProps) { const [measureRef, { width, height }] = useMeasure(); - const viewerContainerRef = useRef(null); const deckRef = useRef(null); // Shapes are pickable whenever hover tooltips are on. Picking stays live through // pan/zoom: the FlatPolygonLayer picking pass is a single vertex-pulled draw, so // the old "suppress picking during camera moves" gate (a full-geometry pick draw // on the SolidPolygonLayer + PathLayer path) is no longer needed. const pickingEnabled = hoverTooltipMode !== 'off'; - const [hoverTooltip, setHoverTooltip] = useState< - (SpatialFeatureTooltipData & { x: number; y: number; clientX: number; clientY: number }) | null - >(null); const vw = width ?? 0; const vh = height ?? 0; @@ -509,37 +494,17 @@ function SpatialCanvasViewerInner({ [renderer.enabledLayerIds] ); - // The expensive part of hovering: aggregate-pick the feature(s) under the - // cursor and position the tooltip. Throttled to one run per animation frame. - const resolveTooltip = useCallback( - (info: PickingInfo) => { - if (hoverTooltipMode === 'off' || !shouldRenderInternalTooltip(renderTooltip)) { - setHoverTooltip(null); - return; - } - const tooltip = - info.picked && typeof info.x === 'number' && typeof info.y === 'number' - ? resolveHoverFeatureTooltip(info, renderer.getFeatureTooltip, { - aggregate: hoverTooltipMode === 'aggregate', - deck: getDeckFromDeckGlRef(deckRef), - pickLayerIds: hoverPickLayerIds, - }) - : null; - // Resolve viewport coordinates here (in the event handler) rather than - // reading the container ref during render. - if (!tooltip) { - setHoverTooltip(null); - return; - } - const rect = viewerContainerRef.current?.getBoundingClientRect(); - setHoverTooltip({ - ...tooltip, - clientX: (rect?.left ?? 0) + tooltip.x, - clientY: (rect?.top ?? 0) + tooltip.y, - }); - }, - [hoverTooltipMode, hoverPickLayerIds, renderTooltip, renderer.getFeatureTooltip] - ); + // Shared hover-tooltip machinery (pick → tooltip → portal). See + // useHoverFeatureTooltip; the pick-event callbacks below stay component-local. + const { containerRef, resolveTooltip, clearTooltip, tooltipPortal } = useHoverFeatureTooltip({ + enabled: hoverTooltipMode !== 'off', + aggregate: hoverTooltipMode === 'aggregate', + getFeatureTooltip: renderer.getFeatureTooltip, + hoverPickLayerIds, + deckRef, + renderTooltip, + tooltipContainer, + }); const handleHover = useCallback( (info: PickingInfo, event?: HoverPointerEvent) => { @@ -547,7 +512,7 @@ function SpatialCanvasViewerInner({ // While panning/dragging, deck keeps firing hover events; the gesture is // changing the view, not inspecting features, so suppress tooltip work. if (isHoverDuringDrag(event)) { - setHoverTooltip(null); + clearTooltip(); return; } if (info.picked && typeof info.x === 'number' && typeof info.y === 'number') { @@ -579,7 +544,16 @@ function SpatialCanvasViewerInner({ } resolveTooltip(info); }, - [coordinateSystem, onFeatureHover, onHover, onShapeHover, renderer, resolveTooltip, spatialData] + [ + clearTooltip, + coordinateSystem, + onFeatureHover, + onHover, + onShapeHover, + renderer, + resolveTooltip, + spatialData, + ] ); const handleClick = useCallback( @@ -619,43 +593,12 @@ function SpatialCanvasViewerInner({ const handleViewerRef = useCallback( (node: HTMLDivElement | null) => { - viewerContainerRef.current = node; + containerRef.current = node; measureRef(node); }, - [measureRef] + [measureRef, containerRef] ); - const tooltipClientPosition = hoverTooltip - ? { x: hoverTooltip.clientX, y: hoverTooltip.clientY } - : null; - - const tooltipPayload: SpatialFeatureTooltipData | null = hoverTooltip; - - const portalTarget = typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; - const tooltipPortal = - hoverTooltipMode !== 'off' && - shouldRenderInternalTooltip(renderTooltip) && - tooltipPayload && - tooltipClientPosition && - portalTarget && - createPortal( - renderTooltip ? ( - renderTooltip({ - clientX: tooltipClientPosition.x, - clientY: tooltipClientPosition.y, - tooltip: tooltipPayload, - }) - ) : ( - - ), - portalTarget - ); - const getLayerLoadStateByElementKey = useCallback( (elementKey: string) => { for (const layerId of layerInputs.layerOrder) { diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index 96bccab1..103a0d28 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -20,14 +20,8 @@ import { useRef, useState, } from 'react'; -import { createPortal } from 'react-dom'; import { SpatialCanvasProvider, useSpatialCanvasActions, useSpatialCanvasStore } from './context'; -import { - getDeckFromDeckGlRef, - type HoverPointerEvent, - isHoverDuringDrag, - resolveHoverFeatureTooltip, -} from './featureTooltipHover'; +import { type HoverPointerEvent, isHoverDuringDrag } from './featureTooltipHover'; import { ImageChannelPanelFromStore } from './ImageChannelPanel'; import { LabelsChannelPanel } from './LabelsChannelPanel'; import { LayerOrderList } from './LayerOrderList'; @@ -35,20 +29,18 @@ import { layerConfig } from './layerConfig'; import PointsLayerPanel from './PointsLayerPanel'; import { ShapeFillColorPanel } from './ShapeFillColorPanel'; import { + DEFAULT_HOVER_TOOLTIP_MODE, type HoverTooltipMode, isHoverTooltipMode, shouldAutoFitSpatialView, useSpatialCanvasRendererFromLayerInputs, } from './SpatialCanvasViewer'; -import { - type SpatialCanvasTooltipRenderProps, - SpatialFeatureTooltip, - type SpatialFeatureTooltipData, -} from './SpatialFeatureTooltip'; +import type { SpatialCanvasTooltipRenderProps } from './SpatialFeatureTooltip'; import { SpatialViewer } from './SpatialViewer'; import type { SpatialCanvasStoreApi } from './stores'; import { TooltipFieldsPanel } from './TooltipFieldsPanel'; import type { AvailableElement, ElementsByType, ViewState } from './types'; +import { useHoverFeatureTooltip } from './useHoverFeatureTooltip'; import type { ImageLayerConfig } from './useLayerData'; import { generateLayerId, getAllCoordinateSystems } from './utils'; import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; @@ -379,7 +371,7 @@ interface SpatialCanvasInnerProps { function SpatialCanvasInner({ tooltipContainer, renderTooltip, - hoverTooltipMode = 'aggregate', + hoverTooltipMode = DEFAULT_HOVER_TOOLTIP_MODE, }: SpatialCanvasInnerProps) { // Points reactivity now lives in (the panel // subscribes to the engine via useSyncExternalStore), so this component no @@ -390,7 +382,6 @@ function SpatialCanvasInner({ const [tooltipMode, setTooltipMode] = useState(hoverTooltipMode); const [measureRef, { width, height }] = useMeasure(); const shellRef = useRef(null); - const viewerContainerRef = useRef(null); const deckRef = useRef(null); const [fullscreen, setFullscreen] = useState(false); // A one-shot request to refit the view after a fullscreen toggle changes the @@ -400,9 +391,6 @@ function SpatialCanvasInner({ width: number; height: number; } | null>(null); - const [hoverTooltip, setHoverTooltip] = useState< - (SpatialFeatureTooltipData & { x: number; y: number; clientX: number; clientY: number }) | null - >(null); const coordinateSystem = useSpatialCanvasStore((s) => s.coordinateSystem); const layers = useSpatialCanvasStore((s) => s.layers); @@ -459,6 +447,18 @@ function SpatialCanvasInner({ } = rendererProps; const hoverPickLayerIds = useMemo(() => Array.from(enabledLayerIds), [enabledLayerIds]); + // Shared hover-tooltip machinery (pick → tooltip → portal); see + // useHoverFeatureTooltip. `handleHover` below stays local (the drag guard). + const { containerRef, resolveTooltip, clearTooltip, tooltipPortal } = useHoverFeatureTooltip({ + enabled: tooltipMode !== 'off', + aggregate: tooltipMode === 'aggregate', + getFeatureTooltip, + hoverPickLayerIds, + deckRef, + renderTooltip, + tooltipContainer, + }); + useEffect(() => { const pending = pendingFullscreenRefitSizeRef.current; if ( @@ -565,55 +565,25 @@ function SpatialCanvasInner({ // The expensive part of hovering: aggregate-pick the feature(s) under the // cursor and position the tooltip. Throttled to one run per animation frame. - const resolveTooltip = useCallback( - (info: PickingInfo) => { - if (tooltipMode === 'off') { - setHoverTooltip(null); - return; - } - const tooltip = - info.picked && typeof info.x === 'number' && typeof info.y === 'number' - ? resolveHoverFeatureTooltip(info, getFeatureTooltip, { - aggregate: tooltipMode === 'aggregate', - deck: getDeckFromDeckGlRef(deckRef), - pickLayerIds: hoverPickLayerIds, - }) - : null; - // Resolve viewport coordinates here (in the event handler) rather than - // reading the container ref during render. - if (!tooltip) { - setHoverTooltip(null); - return; - } - const rect = viewerContainerRef.current?.getBoundingClientRect(); - setHoverTooltip({ - ...tooltip, - clientX: (rect?.left ?? 0) + tooltip.x, - clientY: (rect?.top ?? 0) + tooltip.y, - }); - }, - [tooltipMode, getFeatureTooltip, hoverPickLayerIds] - ); - const handleHover = useCallback( (info: PickingInfo, event?: HoverPointerEvent) => { // While panning/dragging, deck keeps firing hover events; the gesture is // changing the view, not inspecting features, so suppress tooltip work. if (isHoverDuringDrag(event)) { - setHoverTooltip(null); + clearTooltip(); return; } resolveTooltip(info); }, - [resolveTooltip] + [resolveTooltip, clearTooltip] ); const handleViewerRef = useCallback( (node: HTMLDivElement | null) => { - viewerContainerRef.current = node; + containerRef.current = node; measureRef(node); }, - [measureRef] + [measureRef, containerRef] ); const handleCenterOnSelectedLayer = useCallback(() => { @@ -642,42 +612,11 @@ function SpatialCanvasInner({ } const hasElements = Object.values(availableElements).some((arr) => arr.length > 0); - /** Viewport coordinates of the deck.gl pick (for portaled `position: fixed` tooltip). */ - const tooltipClientPosition = hoverTooltip - ? { x: hoverTooltip.clientX, y: hoverTooltip.clientY } - : null; const shellStyle: CSSProperties = fullscreen ? { ...containerStyle, ...fullscreenOverlayStyle, position: 'fixed' } : { ...containerStyle, position: 'relative' }; - const tooltipPayload: SpatialFeatureTooltipData | null = - tooltipMode !== 'off' && hoverTooltip && tooltipClientPosition ? hoverTooltip : null; - - const portalTarget = typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; - - const tooltipPortal = - tooltipPayload && - tooltipClientPosition && - portalTarget && - createPortal( - renderTooltip ? ( - renderTooltip({ - clientX: tooltipClientPosition.x, - clientY: tooltipClientPosition.y, - tooltip: tooltipPayload, - }) - ) : ( - - ), - portalTarget - ); - return ( <>
diff --git a/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx b/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx new file mode 100644 index 00000000..2fc7eaa3 --- /dev/null +++ b/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx @@ -0,0 +1,155 @@ +/** + * Shared hover-tooltip machinery for the two SpatialCanvas surfaces + * (`SpatialCanvasViewer` headless viewer and `SpatialCanvasInner` full UI). + * + * Both resolve the picked feature(s) under the cursor into a tooltip, position it + * in viewport coordinates, and portal it to `document.body` (or a caller node). + * That logic — the `hoverTooltip` state, the pick→tooltip resolution, and the + * portal — is identical between them and lived as a copy in each; this hook is the + * single copy. + * + * It deliberately does NOT import `HoverTooltipMode` / `shouldRenderInternalTooltip` + * from `SpatialCanvasViewer` (that would be a circular import). Callers pass the two + * already-derived booleans — `enabled` (mode ≠ 'off') and `aggregate` (mode === + * 'aggregate') — and the render-tooltip union is expressed structurally here. + * + * The per-component `handleHover`/`handleClick` wrappers stay in each component: + * they differ (the headless viewer also emits `onFeatureHover`/`onShapeHover` + * pick-event callbacks), and the shared part is just `resolveTooltip` + the drag + * guard, which they call. + */ + +import type { DeckGLRef, PickingInfo } from 'deck.gl'; +import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + type FeatureTooltipResolver, + getDeckFromDeckGlRef, + resolveHoverFeatureTooltip, +} from './featureTooltipHover'; +import { + type SpatialCanvasTooltipRenderProps, + SpatialFeatureTooltip, + type SpatialFeatureTooltipData, +} from './SpatialFeatureTooltip'; + +/** + * A caller-supplied tooltip renderer, or `false` to suppress the built-in tooltip + * entirely (the consumer renders its own from `onFeatureHover`). Mirrors + * `SpatialCanvasViewerRenderTooltip` structurally to avoid importing it. + */ +type RenderTooltip = false | ((props: SpatialCanvasTooltipRenderProps) => ReactNode); + +/** Tooltip payload plus the resolved viewport-space client position. */ +type PositionedTooltip = SpatialFeatureTooltipData & { + x: number; + y: number; + clientX: number; + clientY: number; +}; + +export interface UseHoverFeatureTooltipOptions { + /** Hover tooltips active at all (mode ≠ 'off'). */ + enabled: boolean; + /** Aggregate across all layers under the cursor (mode === 'aggregate'). */ + aggregate: boolean; + /** The renderer's `getFeatureTooltip` — resolves a pick to a tooltip payload. */ + getFeatureTooltip: FeatureTooltipResolver; + /** Logical layer ids to cap Deck's aggregate pick passes. */ + hoverPickLayerIds: string[]; + /** Ref to the DeckGL instance (for aggregate `pickMultipleObjects`). */ + deckRef: RefObject; + /** Caller tooltip renderer, or `false` to suppress the built-in tooltip. */ + renderTooltip?: RenderTooltip; + /** Portal mount node; defaults to `document.body`. */ + tooltipContainer?: HTMLElement | null; +} + +export interface UseHoverFeatureTooltip { + /** + * Ref for the viewer container element, read (via `getBoundingClientRect`) to map + * deck-local pick coordinates to viewport coordinates. Assign it to the element + * the deck canvas is measured against — usually alongside `useMeasure`'s ref. + */ + containerRef: RefObject; + /** Resolve + position the tooltip for a pick (no drag guard — callers apply it). */ + resolveTooltip: (info: PickingInfo) => void; + /** Hide the tooltip (e.g. while dragging). */ + clearTooltip: () => void; + /** The portaled tooltip node, or `null` when nothing is shown. */ + tooltipPortal: ReactNode; +} + +export function useHoverFeatureTooltip({ + enabled, + aggregate, + getFeatureTooltip, + hoverPickLayerIds, + deckRef, + renderTooltip, + tooltipContainer, +}: UseHoverFeatureTooltipOptions): UseHoverFeatureTooltip { + const containerRef = useRef(null); + const [hoverTooltip, setHoverTooltip] = useState(null); + + const clearTooltip = useCallback(() => setHoverTooltip(null), []); + + // The expensive part of hovering: pick the feature(s) under the cursor and + // position the tooltip. Callers throttle by only invoking this per hover event. + const resolveTooltip = useCallback( + (info: PickingInfo) => { + if (!enabled || renderTooltip === false) { + setHoverTooltip(null); + return; + } + const tooltip = + info.picked && typeof info.x === 'number' && typeof info.y === 'number' + ? resolveHoverFeatureTooltip(info, getFeatureTooltip, { + aggregate, + deck: getDeckFromDeckGlRef(deckRef), + pickLayerIds: hoverPickLayerIds, + }) + : null; + if (!tooltip) { + setHoverTooltip(null); + return; + } + // Resolve viewport coordinates here (in the event handler) rather than + // reading the container ref during render. + const rect = containerRef.current?.getBoundingClientRect(); + setHoverTooltip({ + ...tooltip, + clientX: (rect?.left ?? 0) + tooltip.x, + clientY: (rect?.top ?? 0) + tooltip.y, + }); + }, + [enabled, aggregate, getFeatureTooltip, hoverPickLayerIds, renderTooltip, deckRef] + ); + + const tooltipPortal = useMemo(() => { + const portalTarget = + typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; + if (!enabled || renderTooltip === false || !hoverTooltip || !portalTarget) { + return null; + } + return createPortal( + renderTooltip ? ( + renderTooltip({ + clientX: hoverTooltip.clientX, + clientY: hoverTooltip.clientY, + tooltip: hoverTooltip, + }) + ) : ( + + ), + portalTarget + ); + }, [enabled, renderTooltip, hoverTooltip, tooltipContainer]); + + return { containerRef, resolveTooltip, clearTooltip, tooltipPortal }; +} diff --git a/packages/vis/tests/useHoverFeatureTooltip.spec.tsx b/packages/vis/tests/useHoverFeatureTooltip.spec.tsx new file mode 100644 index 00000000..20496e3f --- /dev/null +++ b/packages/vis/tests/useHoverFeatureTooltip.spec.tsx @@ -0,0 +1,98 @@ +import { act, renderHook } from '@testing-library/react'; +import type { DeckGLRef, PickingInfo } from 'deck.gl'; +import { describe, expect, it, vi } from 'vitest'; +import type { SpatialFeatureTooltipData } from '../src/SpatialCanvas/SpatialFeatureTooltip.js'; +import { useHoverFeatureTooltip } from '../src/SpatialCanvas/useHoverFeatureTooltip.js'; + +/** + * The hover-tooltip machinery shared by both SpatialCanvas surfaces + * (`SpatialCanvasViewer` and the full-UI `SpatialCanvasInner`). The pick→tooltip + * resolution itself lives in `resolveHoverFeatureTooltip` (covered by + * featureTooltipHover.spec); this guards the hook's own wiring: resolve → state → + * portal, and the `enabled` / `renderTooltip === false` / `clearTooltip` gates. + */ + +const TOOLTIP: SpatialFeatureTooltipData = { + title: 'cell-1', + items: [{ label: 'feature_id', value: 'cell-1' }], +}; + +const deckRef = { current: null } as unknown as React.RefObject; + +/** A picked, single-layer hover (aggregate off ⇒ no Deck instance needed). */ +const PICK = { + picked: true, + x: 10, + y: 20, + index: 0, + object: {}, + layer: { id: 'shapes-1' }, +} as unknown as PickingInfo; + +function baseOptions(getFeatureTooltip = vi.fn(() => TOOLTIP)) { + return { + enabled: true, + aggregate: false, + getFeatureTooltip, + hoverPickLayerIds: ['shapes-1'], + deckRef, + tooltipContainer: null, + }; +} + +describe('useHoverFeatureTooltip', () => { + it('resolves a pick into a portaled tooltip, then clears it', () => { + const getFeatureTooltip = vi.fn(() => TOOLTIP); + const { result } = renderHook(() => useHoverFeatureTooltip(baseOptions(getFeatureTooltip))); + + // Nothing shown before any hover. + expect(result.current.tooltipPortal).toBeNull(); + + // A container is needed to map deck-local coords to viewport coords. + result.current.containerRef.current = document.createElement('div'); + + act(() => result.current.resolveTooltip(PICK)); + expect(getFeatureTooltip).toHaveBeenCalledWith('shapes-1', { index: 0, object: {} }); + expect(result.current.tooltipPortal).not.toBeNull(); + + act(() => result.current.clearTooltip()); + expect(result.current.tooltipPortal).toBeNull(); + }); + + it('does no tooltip work when disabled (mode "off")', () => { + const getFeatureTooltip = vi.fn(() => TOOLTIP); + const { result } = renderHook(() => + useHoverFeatureTooltip({ ...baseOptions(getFeatureTooltip), enabled: false }) + ); + result.current.containerRef.current = document.createElement('div'); + + act(() => result.current.resolveTooltip(PICK)); + expect(getFeatureTooltip).not.toHaveBeenCalled(); + expect(result.current.tooltipPortal).toBeNull(); + }); + + it('suppresses the built-in tooltip when renderTooltip is false', () => { + const getFeatureTooltip = vi.fn(() => TOOLTIP); + const { result } = renderHook(() => + useHoverFeatureTooltip({ ...baseOptions(getFeatureTooltip), renderTooltip: false }) + ); + result.current.containerRef.current = document.createElement('div'); + + act(() => result.current.resolveTooltip(PICK)); + // renderTooltip === false means the consumer draws its own tooltip: the hook + // neither resolves nor portals one. + expect(getFeatureTooltip).not.toHaveBeenCalled(); + expect(result.current.tooltipPortal).toBeNull(); + }); + + it('shows nothing for an unpicked hover', () => { + const getFeatureTooltip = vi.fn(() => TOOLTIP); + const { result } = renderHook(() => useHoverFeatureTooltip(baseOptions(getFeatureTooltip))); + result.current.containerRef.current = document.createElement('div'); + + act(() => + result.current.resolveTooltip({ picked: false, x: 1, y: 2 } as unknown as PickingInfo) + ); + expect(result.current.tooltipPortal).toBeNull(); + }); +}); From fe3d7070f45334bb43d65493e71aab0df64a570d Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 17:22:56 +0100 Subject: [PATCH 07/10] changeset: describe the final shipped canvas behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the two unreleased canvas changesets so the combined release notes match what ships (they otherwise described intermediate behaviour that was reversed within the same cycle): - spatialcanvas-picking: default hover tooltip mode is now 'aggregate' (was 'simple'); picking stays live through pan/zoom (the interaction gate was removed — the FlatPolygonLayer pick pass is cheap); note the shared useHoverFeatureTooltip hook. - shapes-nonblocking: add the per-feature colour-buffer thrash fix (two shapes layers sharing the default runtime rebuilt each other's colour buffer every frame; the cache is now keyed per layer). Co-Authored-By: Claude Opus 4.8 --- .../shapes-nonblocking-flat-polygon-layer.md | 7 ++++-- ...lcanvas-picking-perf-and-rules-of-react.md | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.changeset/shapes-nonblocking-flat-polygon-layer.md b/.changeset/shapes-nonblocking-flat-polygon-layer.md index 01fd8323..20af9ad8 100644 --- a/.changeset/shapes-nonblocking-flat-polygon-layer.md +++ b/.changeset/shapes-nonblocking-flat-polygon-layer.md @@ -36,8 +36,11 @@ one-shot auto-fit from the shapes-blocking transition so a shapes-only view stil correctly. Also fixes a fill-colour "one column behind" bug (the feature-state runtime cache is now -keyed on the fill-colour entry identity, not just its column signature) and the -hover/pan buffer-thrash from unstable deck `updateTrigger` arrays. +keyed on the fill-colour entry identity, not just its column signature); the hover/pan +buffer-thrash from unstable deck `updateTrigger` arrays; and a per-feature colour-buffer +thrash where two shapes layers sharing the default feature-state runtime rebuilt each +other's (million-element) colour buffer on every frame — the `FlatPolygonLayer` colour +cache is now keyed per layer. Known follow-ups: the main-thread GPU texture upload (~seconds on the largest elements) is not yet off the main thread — a WGSL/WebGPU variant (storage buffers instead of diff --git a/.changeset/spatialcanvas-picking-perf-and-rules-of-react.md b/.changeset/spatialcanvas-picking-perf-and-rules-of-react.md index 535c3001..d0575e10 100644 --- a/.changeset/spatialcanvas-picking-perf-and-rules-of-react.md +++ b/.changeset/spatialcanvas-picking-perf-and-rules-of-react.md @@ -9,20 +9,21 @@ SpatialCanvas hover/picking performance and Rules-of-React cleanup. Picking/tooltip performance: - New `hoverTooltipMode` prop (`'off' | 'simple' | 'aggregate'`, default - `'simple'`) on `SpatialCanvas` and `SpatialCanvasViewer`, with a matching - selector in the `SpatialCanvas` UI. `'simple'` resolves the tooltip from the - single top-most pick deck.gl already does for hover/highlight; `'aggregate'` - adds `pickMultipleObjects` GPU passes to include every layer under the cursor - (more expensive); `'off'` makes shape layers non-pickable entirely (no - autoHighlight, no picking-buffer render) — the cheapest mode. Replaces the - earlier boolean `aggregateHoverTooltips`. -- Shape layers are made non-pickable (and `autoHighlight` disabled) while the - camera is being panned/zoomed, so deck.gl does not re-render the shape - geometry into the picking buffer during gestures. New `pickingEnabled` option - on the shapes layer (`@spatialdata/layers`) drives this. + `'aggregate'`) on `SpatialCanvas` and `SpatialCanvasViewer`, with a matching + selector in the `SpatialCanvas` UI. `'aggregate'` reports every feature under + the cursor across layers (`pickMultipleObjects` GPU passes); `'simple'` + resolves the single top-most pick deck.gl already does for hover/highlight; + `'off'` makes shape layers non-pickable entirely (no autoHighlight, no + picking-buffer render) — the cheapest mode. Replaces the earlier boolean + `aggregateHoverTooltips`. +- Picking stays live through pan/zoom. The shapes layer keeps a `pickingEnabled` + option (`@spatialdata/layers`) that `'off'` mode uses to drop picking, but it + is no longer toggled by camera gestures — the `FlatPolygonLayer` pick pass is a + single cheap vertex-pulled draw, so no gesture gate is needed. - Hover tooltip resolution is suppressed while a pointer button is held (drag), and the per-missing-layer supplemental aggregation pick is collapsed into a - single batched pick. + single batched pick. The hover-tooltip machinery (pick → tooltip → portal) is a + single `useHoverFeatureTooltip` hook shared by both canvas surfaces. Rules-of-React fixes (eslint-plugin-react-hooks, `pnpm lint:react` now clean and the `react-lint` CI job is required): removed ref reads/writes during render and From 594a3279ec9b0ee344a132bde90c297aa884614c Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 17 Jul 2026 17:28:33 +0100 Subject: [PATCH 08/10] core: sort index.ts barrel exports Biome's organize-exports flags the export order: `shapesPolygonTessellate.js` must follow `shapesLoader.js` (L < P). Barrel reorder only, no behaviour change; keeps the `biome-lint` gate green. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 49e559d1..3e767a57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,7 +57,6 @@ export * from './pointsTiling.js'; // MDV consumes these as a data contract and no consumer import moves. export * from './renderStack.js'; export * from './shapes.js'; -export * from './shapesPolygonTessellate.js'; export { type CoreShapesLoader, createFullShapesLoader, @@ -70,6 +69,7 @@ export { type ShapesLoaderCapabilities, type ShapesLoadInBoundsOptions, } from './shapesLoader.js'; +export * from './shapesPolygonTessellate.js'; export * from './spatialLayerProps.js'; export * from './spatialViewFit.js'; export * from './store/index.js'; From f603b2a7c3b6ee99fdc13823f56fe91fe056e1a0 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Mon, 20 Jul 2026 07:58:19 +0100 Subject: [PATCH 09/10] Address CodeRabbit review: decode, texture lifecycle, cache keys, tooltip state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - shapesGeometryDecode: `exteriorRing` returned the first sub-polygon's RINGS array for a MultiPolygon (both Polygon and MultiPolygon match the nested-array branch), so the fill applied Number() to coordinate arrays and emitted NaN. Disambiguate on one more level of nesting and descend for MultiPolygon. Adds a round-trip MultiPolygon test (verified red without the fix). - FlatPolygonLayer.finalizeState: destroy the Model, not just the textures — layer removal / dataset switches leaked its GPU resources. - FlatPolygonLayer.updateState: invalidate on every prop the texture builders read (ringVertexCount, triangleCount, featureScale, featureCount), since the counts size the textures; a count change with a reused buffer left stale dimensions. - getTessellation: key on BOTH positions and startIndices — topology depends on the offsets too, so a reused coordinate buffer could serve the wrong triangles and picking indices. - useHoverFeatureTooltip: disabling only hid the portal while retaining the resolved tooltip, so switching the mode back on resurrected a stale tooltip at its old position without a fresh hover. Drop the stored result on deactivation. Adds an off→on regression test (verified red without the fix). Memory: - getFeatureColors: replace the module-level strong Map keyed by layer id (which pinned a million-element Uint8Array per discarded id forever) with nested WeakMaps keyed on the runtime, feature-id array, and default colour — the three identities that actually determine the buffer's contents. Leak-free, and two layers with matching keys correctly share one buffer. Tests/docs: - shapesProjection.spec: cache type now includes `fillColorEntry` (the package tsconfig only includes `src`, so this latent type error was never checked). - flatPolygonLayerShaders: the outline comment claimed parity with the object path's deriveStrokeColor; the constants deliberately differ (tuned against the anti-aliased, width-capped result). Corrected the comment rather than the values. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/shapesGeometryDecode.ts | 14 +++- .../core/tests/shapesGeometryDecode.spec.ts | 35 ++++++++- packages/layers/src/FlatPolygonLayer.ts | 16 +++- .../layers/src/flatPolygonLayerShaders.ts | 6 +- packages/layers/src/shapesLayer.ts | 73 +++++++++++-------- .../SpatialCanvas/useHoverFeatureTooltip.tsx | 29 ++++++-- packages/vis/tests/shapesProjection.spec.ts | 14 +++- .../vis/tests/useHoverFeatureTooltip.spec.tsx | 24 +++++- 8 files changed, 165 insertions(+), 46 deletions(-) diff --git a/packages/core/src/shapesGeometryDecode.ts b/packages/core/src/shapesGeometryDecode.ts index be9c69c5..972fa2bd 100644 --- a/packages/core/src/shapesGeometryDecode.ts +++ b/packages/core/src/shapesGeometryDecode.ts @@ -90,8 +90,18 @@ function exteriorRing(coordinateTree: unknown): ReadonlyArray; } if (Array.isArray(first) && Array.isArray(first[0])) { - // coordinateTree is an array of rings (MultiPolygon's first sub-polygon). - return first as ReadonlyArray; + // `first` is EITHER the exterior ring (Polygon: coords = [ring, …holes]) OR the + // first sub-polygon's rings (MultiPolygon: coords = [[ring, …holes], …polys]). + // Both shapes reach here, so one more level of nesting disambiguates them: a + // ring's first element is a coordinate pair (numbers); a sub-polygon's first + // element is itself a ring (arrays). Returning `first` for a MultiPolygon would + // hand back an array of rings, and the caller's `Number()` fill would emit NaN. + if (typeof first[0][0] === 'number') { + return first as ReadonlyArray; + } + if (Array.isArray(first[0][0])) { + return first[0] as ReadonlyArray; + } } return null; } diff --git a/packages/core/tests/shapesGeometryDecode.spec.ts b/packages/core/tests/shapesGeometryDecode.spec.ts index 9f944cdc..275bc035 100644 --- a/packages/core/tests/shapesGeometryDecode.spec.ts +++ b/packages/core/tests/shapesGeometryDecode.spec.ts @@ -1,5 +1,6 @@ import type { Vector } from 'apache-arrow/vector'; import WKB from 'ol/format/WKB.js'; +import MultiPolygon from 'ol/geom/MultiPolygon.js'; import Point from 'ol/geom/Point.js'; import Polygon from 'ol/geom/Polygon.js'; import { describe, expect, it } from 'vitest'; @@ -28,7 +29,7 @@ function toBytes(written: string | Uint8Array): Uint8Array { return bytes; } -function wkbColumn(geometries: Array): Vector { +function wkbColumn(geometries: Array): Vector { const wkb = new WKB(); const buffers = geometries.map((g) => toBytes(wkb.writeGeometry(g))); return { toArray: () => buffers } as unknown as Vector; @@ -74,6 +75,38 @@ describe('decodeWkbPolygonColumnFlat', () => { expect(result.positions[triangleVerts * 2]).toBe(10); expect(result.positions[triangleVerts * 2 + 1]).toBe(10); }); + + it('takes the first sub-polygon exterior ring of a MultiPolygon', () => { + // A MultiPolygon's coordinates nest one level deeper than a Polygon's: + // [[ring, …holes], …subPolygons]. Returning the first sub-polygon's RINGS array + // (rather than its exterior ring) fed arrays to `Number()` and emitted NaN. + const multi = new MultiPolygon([ + [ + [ + [0, 0], + [2, 0], + [1, 2], + [0, 0], + ], + ], + [ + [ + [10, 10], + [12, 10], + [11, 12], + [10, 10], + ], + ], + ]); + + const result = decodeWkbPolygonColumnFlat(wkbColumn([multi])); + expect(result.featureCount).toBe(1); + // Every coordinate is a real number — the NaN regression. + expect(Array.from(result.positions).every(Number.isFinite)).toBe(true); + // Exactly the FIRST sub-polygon's exterior ring; later sub-polygons are dropped. + expect(Array.from(result.positions)).toEqual([0, 0, 2, 0, 1, 2, 0, 0]); + expect(Array.from(result.startIndices)).toEqual([0, 4]); + }); }); describe('decodeWkbPointColumnFlat', () => { diff --git a/packages/layers/src/FlatPolygonLayer.ts b/packages/layers/src/FlatPolygonLayer.ts index 8bc040a6..63e9d6c6 100644 --- a/packages/layers/src/FlatPolygonLayer.ts +++ b/packages/layers/src/FlatPolygonLayer.ts @@ -108,18 +108,30 @@ export class FlatPolygonLayer extends (Layer as any) { this.state.model?.destroy(); this.state.model = this._getModel(); } + // Invalidate on every prop the texture builders actually read — the counts size + // the textures, so a count change with a reused buffer would otherwise leave a + // stale texture (or stale dimensions) on the GPU. if ( props.ringPositions !== oldProps.ringPositions || - props.triangleData !== oldProps.triangleData + props.ringVertexCount !== oldProps.ringVertexCount || + props.triangleData !== oldProps.triangleData || + props.triangleCount !== oldProps.triangleCount || + props.featureScale !== oldProps.featureScale || + props.featureCount !== oldProps.featureCount ) { this._updateGeometryTextures(); } - if (props.featureColors !== oldProps.featureColors) { + if ( + props.featureColors !== oldProps.featureColors || + props.featureCount !== oldProps.featureCount + ) { this._updateFeatureTexture(); } } finalizeState(context: unknown): void { + // The model owns GPU resources too; textures alone are not the whole footprint. + this.state.model?.destroy(); this.state.ringPosTexture?.destroy(); this.state.triDataTexture?.destroy(); this.state.featureScaleTexture?.destroy(); diff --git a/packages/layers/src/flatPolygonLayerShaders.ts b/packages/layers/src/flatPolygonLayerShaders.ts index 9c2d3e29..9f43091e 100644 --- a/packages/layers/src/flatPolygonLayerShaders.ts +++ b/packages/layers/src/flatPolygonLayerShaders.ts @@ -156,7 +156,11 @@ void main(void) { edge *= smoothstep(OUTLINE_FADE_LO, OUTLINE_FADE_HI, shapePx); // Outline = a lighter derivation of the fill (fill is the specified colour), so - // adjacent shapes read as distinct. Matches the object path's deriveStrokeColor. + // adjacent shapes read as distinct — the same RULE as the object path's + // deriveStrokeColor, but deliberately NOT the same constants (that path uses + // 0.45 / 55-of-255). This outline is anti-aliased and width-capped per shape, so + // it needs more lift to read at the same strength; the values here were tuned + // against the rendered result. Don't "unify" them without re-checking on screen. vec3 strokeRgb = mix(vFillColor.rgb, vec3(1.0), STROKE_LIGHTEN); float strokeA = min(1.0, vFillColor.a + STROKE_ALPHA_LIFT); vec4 color = mix(vFillColor, vec4(strokeRgb, strokeA), edge); diff --git a/packages/layers/src/shapesLayer.ts b/packages/layers/src/shapesLayer.ts index 52e27adb..0307d8ff 100644 --- a/packages/layers/src/shapesLayer.ts +++ b/packages/layers/src/shapesLayer.ts @@ -439,15 +439,24 @@ const TRANSPARENT_RGBA: [number, number, number, number] = [0, 0, 0, 0]; /** Shared ring positions + per-triangle topology for vertex pulling, memoised on the * positions buffer (stable, resolver-owned) so tessellation runs once. */ -const tessellationCache = new WeakMap(); +const tessellationCache = new WeakMap>(); function getTessellation(positions: Float32Array, startIndices: Int32Array): TessellatedPolygons { - const cached = tessellationCache.get(positions); + // Keyed on BOTH buffers: the topology depends on the offsets as much as the + // coordinates, so keying on `positions` alone would serve one element's topology + // for another that reuses the coordinate buffer with different offsets (wrong + // triangles, wrong picking indices). + let byStartIndices = tessellationCache.get(positions); + const cached = byStartIndices?.get(startIndices); if (cached) { return cached; } const tess = tessellateFlatPolygons(positions, startIndices); - tessellationCache.set(positions, tess); + if (!byStartIndices) { + byStartIndices = new WeakMap(); + tessellationCache.set(positions, byStartIndices); + } + byStartIndices.set(startIndices, tess); return tess; } @@ -461,35 +470,41 @@ function getTessellation(positions: Float32Array, startIndices: Int32Array): Tes * feature-state both resolve to the SAME `EMPTY_SHAPE_FEATURE_STATE_RUNTIME` singleton * (as do two layers with identical feature-state), so a runtime-keyed cache holds one * slot the layers overwrite each other in — a per-frame rebuild of both (million- - * element) buffers on every `getLayers()` (pan/hover). The layer id is the stable - * per-layer discriminator; the entry is reused only while all of (runtime, feature-id - * array, default colour) keep their identity — the exact set the buffer's contents - * depend on — so it stays stable across bare re-renders and rebuilds precisely when a - * real change (feature-state, element swap, default-colour change) occurs. + * element) buffers on every `getLayers()` (pan/hover). + * + * Keyed instead on the three identities the buffer's contents actually depend on — + * the feature-state runtime, the feature-id array (index → feature), and the default + * colour — nested as WeakMaps so nothing is retained once a layer's geometry is + * dropped. (A strong `Map` keyed by layer id would pin a million-element `Uint8Array` + * per discarded layer id forever.) Two layers whose three keys all match legitimately + * share one buffer: identical inputs mean identical bytes. */ -interface FeatureColorsCacheEntry { - runtime: ShapeFeatureStateRuntime; - featureIds: readonly string[]; - defaultColor: readonly number[]; - colors: Uint8Array; -} -const featureColorsCache = new Map(); +const featureColorsCache = new WeakMap< + ShapeFeatureStateRuntime, + WeakMap> +>(); function getFeatureColors( - layerId: string, featureState: ShapeFeatureStateRuntime, featureIds: readonly string[], defaultColor: readonly number[], colorForFeatureIndex: (index: number) => [number, number, number, number] ): Uint8Array { - const cached = featureColorsCache.get(layerId); - if ( - cached && - cached.runtime === featureState && - cached.featureIds === featureIds && - cached.defaultColor === defaultColor - ) { - return cached.colors; + const idsKey = featureIds as unknown as object; + const colorKey = defaultColor as unknown as object; + let byIds = featureColorsCache.get(featureState); + if (!byIds) { + byIds = new WeakMap(); + featureColorsCache.set(featureState, byIds); + } + let byColor = byIds.get(idsKey); + if (!byColor) { + byColor = new WeakMap(); + byIds.set(idsKey, byColor); + } + const cached = byColor.get(colorKey); + if (cached) { + return cached; } const featureCount = featureIds.length; const colors = new Uint8Array(featureCount * 4); @@ -500,7 +515,7 @@ function getFeatureColors( colors[f * 4 + 2] = c[2]; colors[f * 4 + 3] = c[3]; } - featureColorsCache.set(layerId, { runtime: featureState, featureIds, defaultColor, colors }); + byColor.set(colorKey, colors); return colors; } @@ -991,13 +1006,7 @@ function createBinaryPolygonDeckLayers( ); }; - const featureColors = getFeatureColors( - options.id, - featureState, - featureIds, - defaultFillColor, - fillColorAt - ); + const featureColors = getFeatureColors(featureState, featureIds, defaultFillColor, fillColorAt); const layer = new FlatPolygonLayer({ id: options.id, diff --git a/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx b/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx index 2fc7eaa3..44558fa9 100644 --- a/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx +++ b/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx @@ -20,7 +20,15 @@ */ import type { DeckGLRef, PickingInfo } from 'deck.gl'; -import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'; +import { + type ReactNode, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { type FeatureTooltipResolver, @@ -94,11 +102,22 @@ export function useHoverFeatureTooltip({ const clearTooltip = useCallback(() => setHoverTooltip(null), []); + // Tooltips active at all? Used both to gate resolution and to DROP any stored + // result on deactivation — hiding the portal alone would keep the last tooltip in + // state, so switching the mode back on would resurrect it at its stale position + // without a fresh hover. + const active = enabled && renderTooltip !== false; + useEffect(() => { + if (!active) { + setHoverTooltip(null); + } + }, [active]); + // The expensive part of hovering: pick the feature(s) under the cursor and // position the tooltip. Callers throttle by only invoking this per hover event. const resolveTooltip = useCallback( (info: PickingInfo) => { - if (!enabled || renderTooltip === false) { + if (!active) { setHoverTooltip(null); return; } @@ -123,13 +142,13 @@ export function useHoverFeatureTooltip({ clientY: (rect?.top ?? 0) + tooltip.y, }); }, - [enabled, aggregate, getFeatureTooltip, hoverPickLayerIds, renderTooltip, deckRef] + [active, aggregate, getFeatureTooltip, hoverPickLayerIds, deckRef] ); const tooltipPortal = useMemo(() => { const portalTarget = typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; - if (!enabled || renderTooltip === false || !hoverTooltip || !portalTarget) { + if (!active || !hoverTooltip || !portalTarget) { return null; } return createPortal( @@ -149,7 +168,7 @@ export function useHoverFeatureTooltip({ ), portalTarget ); - }, [enabled, renderTooltip, hoverTooltip, tooltipContainer]); + }, [active, renderTooltip, hoverTooltip, tooltipContainer]); return { containerRef, resolveTooltip, clearTooltip, tooltipPortal }; } diff --git a/packages/vis/tests/shapesProjection.spec.ts b/packages/vis/tests/shapesProjection.spec.ts index 3b0e5d62..21865815 100644 --- a/packages/vis/tests/shapesProjection.spec.ts +++ b/packages/vis/tests/shapesProjection.spec.ts @@ -22,6 +22,16 @@ const config = { fillColorByColumn: { columnName: 'colY', mode: 'category' }, } as unknown as ShapesLayerConfig; +/** The cache shape `getStableShapeFeatureStateRuntime` requires. */ +type RuntimeCache = Map< + string, + { + signature: string; + runtime: ShapeFeatureStateRuntime; + fillColorEntry: ShapeFillColorEntry | undefined; + } +>; + const entry = ( fillColorByFeatureId: Record ): ShapeFillColorEntry => ({ @@ -35,7 +45,7 @@ const entry = ( describe('getStableShapeFeatureStateRuntime', () => { it('picks up new fill-colour data when the entry changes under an unchanged signature', () => { - const cache = new Map(); + const cache: RuntimeCache = new Map(); // First: entry built from the PREVIOUS column's still-loaded rows. const stale = getStableShapeFeatureStateRuntime( @@ -58,7 +68,7 @@ describe('getStableShapeFeatureStateRuntime', () => { }); it('returns a stable runtime identity when the entry is unchanged (no buffer thrash)', () => { - const cache = new Map(); + const cache: RuntimeCache = new Map(); const sameEntry = entry({ f1: [1, 2, 3, 255] }); const a = getStableShapeFeatureStateRuntime('layer-1', config, sameEntry, cache); const b = getStableShapeFeatureStateRuntime('layer-1', config, sameEntry, cache); diff --git a/packages/vis/tests/useHoverFeatureTooltip.spec.tsx b/packages/vis/tests/useHoverFeatureTooltip.spec.tsx index 20496e3f..f0994396 100644 --- a/packages/vis/tests/useHoverFeatureTooltip.spec.tsx +++ b/packages/vis/tests/useHoverFeatureTooltip.spec.tsx @@ -1,5 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import type { DeckGLRef, PickingInfo } from 'deck.gl'; +import { createRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { SpatialFeatureTooltipData } from '../src/SpatialCanvas/SpatialFeatureTooltip.js'; import { useHoverFeatureTooltip } from '../src/SpatialCanvas/useHoverFeatureTooltip.js'; @@ -17,7 +18,7 @@ const TOOLTIP: SpatialFeatureTooltipData = { items: [{ label: 'feature_id', value: 'cell-1' }], }; -const deckRef = { current: null } as unknown as React.RefObject; +const deckRef = createRef(); /** A picked, single-layer hover (aggregate off ⇒ no Deck instance needed). */ const PICK = { @@ -85,6 +86,27 @@ describe('useHoverFeatureTooltip', () => { expect(result.current.tooltipPortal).toBeNull(); }); + it('drops a resolved tooltip when disabled, so re-enabling does not resurrect it', () => { + const getFeatureTooltip = vi.fn(() => TOOLTIP); + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useHoverFeatureTooltip({ ...baseOptions(getFeatureTooltip), enabled }), + { initialProps: { enabled: true } } + ); + result.current.containerRef.current = document.createElement('div'); + + act(() => result.current.resolveTooltip(PICK)); + expect(result.current.tooltipPortal).not.toBeNull(); + + // Mode switched to 'off' — the stored result must be dropped, not just hidden. + rerender({ enabled: false }); + expect(result.current.tooltipPortal).toBeNull(); + + // Back on WITHOUT a fresh hover: nothing should reappear at the stale position. + rerender({ enabled: true }); + expect(result.current.tooltipPortal).toBeNull(); + }); + it('shows nothing for an unpicked hover', () => { const getFeatureTooltip = vi.fn(() => TOOLTIP); const { result } = renderHook(() => useHoverFeatureTooltip(baseOptions(getFeatureTooltip))); From 5f5c11a66ebcc217eccdf8404e4f31a5de70a95d Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Mon, 20 Jul 2026 08:12:46 +0100 Subject: [PATCH 10/10] vis: adjust tooltip reset during render, not in an effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The off→on stale-tooltip fix used setState inside useEffect, which react-hooks/set-state-in-effect rejects (eslint-plugin-react-hooks v7 recommended-latest) — it failed the react-lint CI job. Use React's "adjusting state when a prop changes" pattern instead: compare the previous activation against the current one during render and reset there. Same behaviour, and it re-renders immediately with the corrected state rather than painting the stale tooltip for a frame first. pnpm lint:react and pnpm lint:biome both exit 0. Co-Authored-By: Claude Opus 4.8 --- .../SpatialCanvas/useHoverFeatureTooltip.tsx | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx b/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx index 44558fa9..c70cb052 100644 --- a/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx +++ b/packages/vis/src/SpatialCanvas/useHoverFeatureTooltip.tsx @@ -20,15 +20,7 @@ */ import type { DeckGLRef, PickingInfo } from 'deck.gl'; -import { - type ReactNode, - type RefObject, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { type FeatureTooltipResolver, @@ -103,15 +95,20 @@ export function useHoverFeatureTooltip({ const clearTooltip = useCallback(() => setHoverTooltip(null), []); // Tooltips active at all? Used both to gate resolution and to DROP any stored - // result on deactivation — hiding the portal alone would keep the last tooltip in - // state, so switching the mode back on would resurrect it at its stale position - // without a fresh hover. + // result when activation flips — hiding the portal alone would keep the last + // tooltip in state, so switching the mode off and back on would resurrect it at + // its stale position without a fresh hover. + // + // Adjusted during render (React's "adjusting state when a prop changes" pattern) + // rather than in an effect: `react-hooks/set-state-in-effect` rightly rejects the + // effect form, and this re-renders immediately with the corrected state instead of + // painting the stale tooltip for a frame first. const active = enabled && renderTooltip !== false; - useEffect(() => { - if (!active) { - setHoverTooltip(null); - } - }, [active]); + const [lastActive, setLastActive] = useState(active); + if (lastActive !== active) { + setLastActive(active); + setHoverTooltip(null); + } // The expensive part of hovering: pick the feature(s) under the cursor and // position the tooltip. Callers throttle by only invoking this per hover event.