From 628d2e3366c719b4a3e1350fbbc3ab5729d6df5f Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 26 May 2026 16:50:02 +0100 Subject: [PATCH 1/3] Fix image overlay layer resolution --- docs/docs/layers/overview.mdx | 17 ++++ docs/docs/vis/layer-prop-flow.mdx | 81 ++++++++++++------- docs/docs/vis/mdv-integration.mdx | 58 ++++++++++++- docs/docs/vis/spatial-canvas-status.mdx | 10 +++ .../vis/src/SpatialCanvas/useLayerData.ts | 39 ++++++--- packages/vis/tests/spatialCanvasUtils.spec.ts | 52 ++++++++++++ packages/vis/tests/vivSpatialViewer.spec.ts | 74 ++++++++++++++++- 7 files changed, 284 insertions(+), 47 deletions(-) diff --git a/docs/docs/layers/overview.mdx b/docs/docs/layers/overview.mdx index a6cf4b4d..cec9bde9 100644 --- a/docs/docs/layers/overview.mdx +++ b/docs/docs/layers/overview.mdx @@ -34,3 +34,20 @@ See also the [visualization overview](../vis/overview): deck-only integrators ca - the public shapes config stays representation-agnostic so a stronger future `deck.gl-geoarrow` backend can slot in without changing saved props For shapes, the important contract is stable feature identity plus table-join-driven styling/filtering. `@spatialdata/core` loads render-oriented shape data, `@spatialdata/layers` turns that into deck layers, and `@spatialdata/vis` consumes the shared behavior for viewer use. + +Points should follow the same package split before we make a GeoArrow migration +load-bearing: + +- `@spatialdata/core` discovers SpatialData points stores and exposes coordinate + columns, stable point ids, row-index alignment, and Arrow batches/vectors when + available +- `@spatialdata/layers` owns the deck-facing points renderer, including + filtering/styling feature state and backend choice +- the initial backend can remain `ScatterplotLayer` over typed coordinate + arrays, but the public points config should be representation-agnostic enough + to add `@geoarrow/deck.gl-geoarrow` / `GeoArrowScatterplotLayer` when point + data is GeoArrow-encoded or cheaply adaptable + +The migration test is the same as shapes: MDV or Vitessce should be able to +drive hide/fade/color/radius state without knowing whether the renderer used JS +arrays, deck binary attributes, or GeoArrow batches underneath. diff --git a/docs/docs/vis/layer-prop-flow.mdx b/docs/docs/vis/layer-prop-flow.mdx index 5951a11a..8fbbb8d8 100644 --- a/docs/docs/vis/layer-prop-flow.mdx +++ b/docs/docs/vis/layer-prop-flow.mdx @@ -23,8 +23,12 @@ right answer once. a prop's change should invalidate downstream async work (tile fetching, re-loading data), it goes into `updateTriggers` on the layer that owns the side effect. There is no parallel registry. +4. **Viv image layers are an adapter boundary, not an exception.** Images still + follow the same deck rule. Viv constructs the actual image layers, and Viv + 0.21's multiscale image tile layer declares `[loader, selections]` as the + `getTileData` trigger set. -If you follow those three rules, deck.gl's existing layer matching + prop +If you follow those rules, deck.gl's existing layer matching + prop diffing handles the rest. Cosmetic prop tweaks repaint without touching the tileset cache; structural changes invalidate via `updateTriggers` and refetch. Nothing more is needed. @@ -57,6 +61,30 @@ refetches. The fix is upstream stability, not downstream caching. loaded fallback should still be memoized to keep render output cheap, but identity stability is not required for correctness. +### For image layers through Viv + +SpatialCanvas image rendering currently routes through Viv +`DetailView.getLayers()`, which creates Viv `ImageLayer` / +`MultiscaleImageLayer` instances. That means `@spatialdata/vis` does not +directly own the image tile layer class, but it still owns the props it passes +to Viv. + +- Treat Viv's image layer as the tile-loading owner. In Viv 0.21, + `MultiscaleImageLayer` sets `updateTriggers.getTileData` to + `[loader, selections]`. +- Keep `loader` and `selections` identity-stable in `useLayerData`. Cosmetic + image props (`colors`, `contrastLimits`, `channelsVisible`, `opacity`, + `modelMatrix`) can flow through as normal props. +- `VivSpatialViewer` may call `detailView.getLayers()` on each render. The + important requirements are stable layer ids, passing the complete prop bag + into Viv/deck, and avoiding any viewer-local classification of prop names. +- Do not patch Viv-created layers by spreading `layer.props` after creation. + Some Viv/deck props, including extension defaults, are not safe to preserve + with object spread. Pass props into Viv up front, then use `layer.clone()` only + for identity-neutral deck props such as the final layer id. +- If a future Viv version changes the image tile trigger set, update this note + and add or adjust an image behavioral test in the same PR. + ### For layer authors (`LabelsLayer`, future custom layers) - Pass cosmetic props through `getSubLayerProps`; do not enumerate them by @@ -79,11 +107,11 @@ refetches. The fix is upstream stability, not downstream caching. That is the only structural change it should make to incoming layers. - Must not extract, classify, or re-route props by name. The viewer is transparent to whatever props the producer or extensions chose to pass. -- The only legitimate viewer-local cache is for the layer instances that Viv's - `detailView.getLayers()` itself constructs on each call (because Viv's API - does not return stable references). Cache key is the producer-side - `(loader, selectionsRef)` tuple; visual updates go through the deck-native - `layer.clone(props)` of *all* incoming props, not a hand-picked subset. +- It should not maintain viewer-local layer caches unless there is runtime + evidence that deck's layer matching cannot preserve the relevant Viv layer + state. If such a cache becomes necessary, the cache key must be structural + only (`loader`, `selectionsRef`, and any future Viv-declared tile trigger), + and cosmetic updates must still flow through deck-native props. ## Anti-patterns (do not reintroduce) @@ -122,31 +150,22 @@ expect(fetchCount.value).toBe(before); One such test per layer type (`image`, `labels`, future custom layers) is enough to keep the contract honest. -## Migration plan - -> This section is tactical and should be removed once the follow-up PR -> implementing the redesign has merged. - -The cosmetic-prop performance bug exists on `main` at the time of writing. -The redesign that fixes it lives in a follow-up branch. Sequence: - -1. **Diagnostic round.** Instrument `useLayerData.getVivLayerProps()` and the - labels branch with one-shot identity-stability logging — for each render, - record which fields' identity changed. Confirm whether the culprit is - `loader`, `selections`, `getTileData`, or all three. -2. **Restore identity stability in `useLayerData`.** Memoize the offending - fields. Do *not* add any new caches outside `useLayerData`. -3. **Simplify `VivSpatialViewer`.** Remove all bespoke per-extra-layer caches. - Keep the minimal image-layer cache only if `detailView.getLayers()` still - requires it; key it on `(loader, selectionsRef)` only. -4. **Simplify `LabelsLayer`.** Plain CompositeLayer that passes props through - via `getSubLayerProps` and declares `updateTriggers.getTileData` on the - inner tile layer. No module caches, no lifecycle overrides for state. -5. **Add the behavioral test** described above for both image and labels. - -Acceptance: opacity slider drag and channel-color edit on a labels layer -produce zero new `getTile` calls in the test harness; manual DevTools Network -panel during cosmetic drags stays empty. +## Current audit checklist + +Use this checklist when changing images, labels, or future raster-like layers. + +1. **Identify the tile-loading owner.** For labels, that is + `LabelsLayer` / its inner `TileLayer`. For images, that is Viv's + `MultiscaleImageLayer`. +2. **Read the owner's `updateTriggers.getTileData`.** The trigger list is the + structural contract. Mirror it in the producer's identity-stability work; do + not invent a second visual-vs-structural table. +3. **Keep adapter components transparent.** Viewers may normalize ids and + compose layers, but should not sort props into structural and cosmetic + buckets. +4. **Test behavior, not cache mechanics.** A cosmetic opacity/color/channel + change should not produce new tile reads. A real selection/loader change + should. ## See also diff --git a/docs/docs/vis/mdv-integration.mdx b/docs/docs/vis/mdv-integration.mdx index 55143262..301843ee 100644 --- a/docs/docs/vis/mdv-integration.mdx +++ b/docs/docs/vis/mdv-integration.mdx @@ -204,6 +204,23 @@ For now, the Vitessce note should be treated as an API pressure test and a succe We should track upstream deck.gl / loaders.gl / deck.gl-community work around Arrow, GeoArrow, and GeoParquet carefully. This affects the boundary between `@spatialdata/core`, `@spatialdata/layers`, `@spatialdata/vis`, deck loaders, and app-specific adapters. +Current upstream read: + +- `geoarrow/deck.gl-geoarrow` is the renamed home for the former + `geoarrow/deck.gl-layers` project. The published package to evaluate is + `@geoarrow/deck.gl-geoarrow`; it targets deck.gl 9 and Apache Arrow JS. +- The useful layer for SpatialData points is likely `GeoArrowScatterplotLayer`, + but it expects GeoArrow point/multipoint data, not arbitrary `x` / `y` + columns. SpatialData points currently store coordinate columns in Parquet, so + an adapter still has to build or expose a GeoArrow point column/batch. +- The library is most useful when we can keep Arrow chunks columnar all the way + to deck.gl's binary attribute interface. It is less compelling if we first + materialise every point as JS objects or as the current ndarray-ish wrapper. +- deck.gl-community's Arrow layers are a second signal in the same direction, + but the community docs explicitly warn about maintenance bandwidth. Treat + that as an API pressure test rather than a dependency to bet the public + contract on. + Current local state: - `@spatialdata/core` currently loads Parquet bytes/tables through `parquet-wasm` in `VTableSource`, inherited by points and shapes sources. @@ -211,6 +228,32 @@ Current local state: - shapes currently expose a render-oriented core payload with stable feature ids, shared row-index alignment, and a mixed backend path in `@spatialdata/layers`. - labels are still their own image/tile rendering path. - Vitessce-derived code already has more advanced point handling in places, including tiled point loading, viewport filtering, feature-index filtering, and `DataFilterExtension` use. +- `VTableSource` recognises `points//points.parquet` and + `points//points.parquet/part.0.parquet`, but it does not yet model a + multi-file Parquet dataset as multiple chunks/batches. That is the wrong + shape for large point stores. + +Points-specific target shape: + +- `core` should expose a `PointsRenderData`-style payload, parallel to + `ShapesRenderData`, with stable point ids, row-index alignment, coordinate + axis names, optional `feature_key` / `instance_key` columns, and the original + Arrow table or record batches when available. +- `layers` should own a shared points renderer. The renderer should choose + between: + - a current fallback `ScatterplotLayer` over typed coordinate arrays + - a binary deck.gl attribute path for `x` / `y` / optional `z` + - a `GeoArrowScatterplotLayer` path when data is already GeoArrow point + encoded, or when the adapter can build that point column without copying too + much +- Points need the same feature-state language as shapes: hide, fade, color, + radius, and filtered opacity by stable point id or row index. MDV/Vitessce + filters should update feature-state or filter columns; they should not force a + full Parquet reload. +- Viewport/row-group filtering belongs behind the points data adapter, not in + `SpatialCanvas` UI code. A multi-file Parquet directory can naturally map to + progressive chunks/layers first, then later to row-group or bounding-box + pruning when metadata is available. Upstream signals to monitor: @@ -248,14 +291,22 @@ Recommended direction for now: - [ ] Keep `@spatialdata/core` free of deck.gl dependencies. - [ ] Align `@spatialdata/core`'s points and shapes support with Vitessce's SpatialData-derived loaders so we do not fall behind format coverage while the rendering backend evolves. -- [ ] Move toward `core` exposing Arrow-ish columnar primitives for points/shapes/tables, while preserving convenience methods for simple JS arrays. +- [ ] Move toward `core` exposing Arrow-ish columnar primitives for + points/shapes/tables, while preserving convenience methods for simple JS + arrays. For points, that means preserving Arrow batches/vectors alongside the + current coordinate-array convenience path. - [ ] Make `@spatialdata/layers` responsible for choosing the rendering backend: - current polygon fallback for compatibility - current `geoarrow-table` runtime branch for shared columnar payloads - - `deck.gl-geoarrow` as the intended stronger near-term fast path when the external dependency is adopted cleanly + - `@geoarrow/deck.gl-geoarrow` as the intended stronger near-term fast path + when the external dependency is adopted cleanly - future Arrow/community-layer backends without changing the public shapes config - [x] Keep feature identity, table association, coordinate transforms, and metadata interpretation in `core`; keep GPU filtering, tiling, layer construction, and picking/render props in `layers`. - [ ] Avoid baking `parquet-wasm` as the only long-term path. Treat it as the current implementation behind a replaceable interface. +- [ ] Upgrade points before adopting GeoArrow broadly: first add stable point + identity, row-index alignment, feature-state filtering/styling, and multi-part + Parquet discovery; then add the GeoArrow renderer as an adapter behind the + same public points config. One more API-design note to preserve: the current table-association helpers are still `obs`-oriented and string-column-oriented because that is enough for the first feature-id join path. A future revision should widen the shared contract so style/filter inputs can come coherently from all of the AnnData surfaces we care about: `obs`, `var`, selected `X` columns for chosen `var` rows, `obsm`, potential future `obsp` graph/network data, and `uns`, without forcing integrators to encode everything as ad hoc string column lookups. In principle, many of those richer access patterns should likely be pushed upstream into `anndata.js` rather than duplicated forever in SpatialData.js. - [ ] Treat Vitessce parity tests as compatibility fixtures: when Vitessce supports a points/shapes SpatialData layout, `core` should either support it too or document why not. @@ -268,6 +319,9 @@ One more API-design note to preserve: the current table-association helpers are Open questions: - Should `PointsElement.loadPoints()` return Arrow vectors/tables in addition to typed arrays? +- Should `PointsElement` expose a chunked/multipart API (`loadPointBatches`) so + large `points.parquet/part-*.parquet` directories can progressively render + without pretending they are one file? - Should `ShapesElement.loadPolygonShapes()` preserve feature ids alongside geometry in a first-class row object or columnar structure? - Where should row-group / viewport filtering live: `core`, `vis`, or app adapter? - Can deck.gl-community Arrow layers become a dependency of `@spatialdata/vis`, or should they be optional peer/adapter code? diff --git a/docs/docs/vis/spatial-canvas-status.mdx b/docs/docs/vis/spatial-canvas-status.mdx index 15d01110..216aabe5 100644 --- a/docs/docs/vis/spatial-canvas-status.mdx +++ b/docs/docs/vis/spatial-canvas-status.mdx @@ -18,6 +18,12 @@ sidebar_position: 1 - **Single primary image** path in the Viv viewer composition (first enabled image layer drives `DetailView.getLayers`). - **View state** conversion between SpatialCanvas and Viv is still **2D-oriented**; full 3D orbit state is not fully round-tripped. - **`useLayerData`** prefers explicit `LayerConfig.channels` when arrays are non-empty; further **override flags** may still be useful for edge cases. +- **Points are still a minimal scatter path:** `PointsElement.loadPoints()` + loads coordinate columns into an ndarray-ish object and + `@spatialdata/vis` renders them directly with `ScatterplotLayer`. There is + not yet stable point identity, table-backed feature state, viewport/row-group + filtering, progressive multi-file Parquet loading, or a GeoArrow-backed + renderer. ## Upstream Viv Follow-ups @@ -49,6 +55,10 @@ sidebar_position: 1 - Flesh out **`SpatialLayer`** sublayers (image, scatter, shapes, …) and keep **`SpatialLayerProps`** migrations honest as kinds grow. - Harden **`@spatialdata/avivatorish`** for MDV adoption (telemetry hooks, docs). - **MDV integration** checklist: replace vendored avivatorish, adopt shared layers, scatter/table-backed props, phased contour extraction. +- **Points parity with shapes:** move point rendering into + `@spatialdata/layers`, add stable point ids plus hide/fade/color/radius + feature state, and make multi-file Parquet stores render progressively before + adopting GeoArrow as a fast-path adapter. - **GeoArrow / Parquet** paths for shapes and points; clarify **`@spatialdata/core`** vs deck-facing buffers. - **3D view mode** and **time (`t`)** in the public scene contract. diff --git a/packages/vis/src/SpatialCanvas/useLayerData.ts b/packages/vis/src/SpatialCanvas/useLayerData.ts index 1fc24f96..2802211e 100644 --- a/packages/vis/src/SpatialCanvas/useLayerData.ts +++ b/packages/vis/src/SpatialCanvas/useLayerData.ts @@ -186,6 +186,19 @@ function serializeRasterSelections(selections: RasterSelection[]): string { .join('\x00'); } +function getElementMapKey(config: Pick): string { + return `${config.type}:${config.elementKey}`; +} + +export function resolveLayerElement( + layerId: string, + config: LayerConfig | undefined, + elementMap: Map +): AvailableElement | undefined { + if (!config) return undefined; + return elementMap.get(getElementMapKey(config)) ?? elementMap.get(layerId); +} + async function loadShapesLayerData( element: ShapesElement ): Promise> { @@ -271,7 +284,7 @@ export function useLayerData( for (const layerId of layerOrder) { const config = layers[layerId]; if (!config?.visible || config.type !== 'shapes') continue; - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, config, elementMap.current); if (!elem) continue; const loadedShapes = loaded.shapes.get(elem.key); if (!loadedShapes) continue; @@ -301,7 +314,7 @@ export function useLayerData( const config = layers[layerId]; if (!config?.visible) continue; - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, config, elementMap.current); if (!elem) continue; if (config.type === 'shapes') { @@ -745,9 +758,9 @@ export function useLayerData( if (type === 'shapes') { loaded.shapes.delete(key); // Clear prebuilt data for every layer that maps to this element key. - for (const [lId, elem] of elementMap.current) { - if (elem.key === key && elem.type === 'shapes') { - loaded.shapePrebuiltData.delete(lId); + for (const [layerId, config] of Object.entries(layersRef.current)) { + if (config.type === 'shapes' && config.elementKey === key) { + loaded.shapePrebuiltData.delete(layerId); } } } else if (type === 'points') { @@ -772,7 +785,7 @@ export function useLayerData( }, []); const hasRenderableLayerData = useCallback((layerId: string): boolean => { - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, layersRef.current[layerId], elementMap.current); if (!elem) return false; if (elem.type === 'shapes') { return loadedDataRef.current.shapes.has(elem.key); @@ -793,7 +806,7 @@ export function useLayerData( (layerId: string): AxisAlignedBounds | null => { try { const config = layers[layerId]; - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, config, elementMap.current); if (!config?.visible || !elem) return null; const loaded = loadedDataRef.current; if (elem.type === 'shapes') { @@ -862,7 +875,7 @@ export function useLayerData( const config = layers[layerId]; if (!config?.visible) continue; - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, config, elementMap.current); if (!elem) continue; if (config.type === 'shapes') { @@ -949,13 +962,13 @@ export function useLayerData( }, [layers, layerOrder, getStableSelections]); const getImageLayerLoadedData = useCallback((layerId: string): ImageLoaderData | undefined => { - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, layersRef.current[layerId], elementMap.current); if (!elem || elem.type !== 'image') return undefined; return loadedDataRef.current.images.get(elem.key); }, []); const getLabelsLayerLoadedData = useCallback((layerId: string): LabelsLoaderData | undefined => { - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, layersRef.current[layerId], elementMap.current); if (!elem || elem.type !== 'labels') return undefined; return loadedDataRef.current.labels.get(elem.key); }, []); @@ -973,7 +986,7 @@ export function useLayerData( layerId: string, pickInfo: Pick<{ index?: number; object?: unknown }, 'index' | 'object'> ): SpatialFeatureTooltipData | undefined => { - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, layersRef.current[layerId], elementMap.current); if (!elem) { return undefined; } @@ -1050,7 +1063,7 @@ export function useLayerData( const getShapePickEvent = useCallback( (layerId: string, pickInfo: Pick<{ index?: number; object?: unknown }, 'index' | 'object'>) => { - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, layersRef.current[layerId], elementMap.current); if (!elem || elem.type !== 'shapes') { return undefined; } @@ -1085,7 +1098,7 @@ export function useLayerData( const config = layers[layerId]; if (!config?.visible || config.type !== 'image') continue; - const elem = elementMap.current.get(layerId); + const elem = resolveLayerElement(layerId, config, elementMap.current); if (!elem || elem.type !== 'image') continue; const imageData = loaded.images.get(elem.key); diff --git a/packages/vis/tests/spatialCanvasUtils.spec.ts b/packages/vis/tests/spatialCanvasUtils.spec.ts index f09b44b8..7edc0e99 100644 --- a/packages/vis/tests/spatialCanvasUtils.spec.ts +++ b/packages/vis/tests/spatialCanvasUtils.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { resolveLayerElement } from '../src/SpatialCanvas/useLayerData.js'; import { calculateInitialViewState } from '../src/SpatialCanvas/utils.js'; describe('calculateInitialViewState (vis SpatialCanvas utils)', () => { @@ -12,3 +13,54 @@ describe('calculateInitialViewState (vis SpatialCanvas utils)', () => { expect(calculateInitialViewState(null, 100, 100)).toEqual({ target: [0, 0], zoom: 0 }); }); }); + +describe('resolveLayerElement', () => { + it('resolves controlled layer ids through elementKey', () => { + const imageElement = { + key: 'image-a', + type: 'image', + element: {}, + transform: {}, + } as any; + const elements = new Map([[`${imageElement.type}:${imageElement.key}`, imageElement]]); + + expect( + resolveLayerElement( + 'overlay-red', + { + id: 'overlay-red', + type: 'image', + elementKey: 'image-a', + visible: true, + opacity: 0.5, + }, + elements + ) + ).toBe(imageElement); + }); + + it('keeps generated layer ids working as a fallback', () => { + const imageElement = { + key: 'image-a', + type: 'image', + element: {}, + transform: {}, + } as any; + const elements = new Map([['image:image-a', imageElement]]); + + expect(resolveLayerElement('image:image-a', undefined, elements)).toBeUndefined(); + expect( + resolveLayerElement( + 'image:image-a', + { + id: 'image:image-a', + type: 'image', + elementKey: 'missing-old-config', + visible: true, + opacity: 1, + }, + elements + ) + ).toBe(imageElement); + }); +}); diff --git a/packages/vis/tests/vivSpatialViewer.spec.ts b/packages/vis/tests/vivSpatialViewer.spec.ts index 9fe7f2f2..c8b31163 100644 --- a/packages/vis/tests/vivSpatialViewer.spec.ts +++ b/packages/vis/tests/vivSpatialViewer.spec.ts @@ -1,6 +1,28 @@ import { ScatterplotLayer } from 'deck.gl'; +import type { Layer } from 'deck.gl'; import { describe, expect, it } from 'vitest'; -import { normalizeVivLayers, normalizeVivZoom } from '../src/SpatialCanvas/VivSpatialViewer.js'; +import { + VivSpatialViewer, + normalizeVivLayers, + normalizeVivZoom, +} from '../src/SpatialCanvas/VivSpatialViewer.js'; + +function makeImageLoader() { + return [ + { + constructor: { name: 'MockSource' }, + dtype: 'Uint16', + labels: ['c', 'y', 'x'], + shape: [1, 64, 64], + tileSize: 64, + getRaster: async () => ({ + data: new Uint16Array(64 * 64), + width: 64, + height: 64, + }), + }, + ]; +} describe('normalizeVivZoom', () => { it('uses the first zoom level when Viv returns an array', () => { @@ -23,3 +45,53 @@ describe('normalizeVivLayers', () => { ]); }); }); + +describe('VivSpatialViewer image composition', () => { + it('keeps multiple image layers distinct in the same Viv viewport', () => { + const viewer = new VivSpatialViewer({ + width: 512, + height: 512, + viewState: { target: [32, 32], zoom: 1 }, + onViewStateChange: () => {}, + vivLayerProps: [ + { + id: 'image:first', + loader: makeImageLoader(), + colors: [[255, 0, 0]], + contrastLimits: [[0, 255]], + channelsVisible: [true], + selections: [{}], + opacity: 0.5, + visible: true, + }, + { + id: 'image:second', + loader: makeImageLoader(), + colors: [[0, 255, 0]], + contrastLimits: [[0, 255]], + channelsVisible: [true], + selections: [{}], + opacity: 0.5, + visible: true, + }, + ], + }); + + const testViewer = viewer as unknown as { + _renderLayers: () => unknown; + layerFilter: (args: { layer: Layer; viewport: { id: string } }) => boolean; + viewId: string; + }; + const layers = normalizeVivLayers(testViewer._renderLayers()); + const imageLayers = layers.filter((layer) => layer.id.includes('MockSource')); + + expect(imageLayers.map((layer) => layer.id)).toEqual([ + expect.stringContaining('image:first'), + expect.stringContaining('image:second'), + ]); + expect(new Set(imageLayers.map((layer) => layer.id)).size).toBe(2); + for (const layer of imageLayers) { + expect(testViewer.layerFilter({ layer, viewport: { id: testViewer.viewId } })).toBe(true); + } + }); +}); From 912e94aebd382eb0a6e1dca6be580233447487f8 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Wed, 27 May 2026 14:18:18 +0100 Subject: [PATCH 2/3] Order SpatialCanvas layers by global layer order --- .../src/SpatialCanvas/SpatialCanvasViewer.tsx | 1 + .../vis/src/SpatialCanvas/SpatialViewer.tsx | 4 + .../src/SpatialCanvas/VivSpatialViewer.tsx | 112 ++++++++++-------- packages/vis/src/SpatialCanvas/index.tsx | 4 + packages/vis/tests/vivSpatialViewer.spec.ts | 49 ++++++++ 5 files changed, 123 insertions(+), 47 deletions(-) diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index b0387ac8..f999d9b7 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -398,6 +398,7 @@ function SpatialCanvasViewerInner({ viewState={viewState} onViewStateChange={onViewStateChange} layers={renderer.deckLayers} + layerOrder={layerOrder} vivLayerProps={renderer.vivLayerProps.length > 0 ? renderer.vivLayerProps : undefined} onHover={handleHover} onClick={handleClick} diff --git a/packages/vis/src/SpatialCanvas/SpatialViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialViewer.tsx index 9dbc65fb..f26a6040 100644 --- a/packages/vis/src/SpatialCanvas/SpatialViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialViewer.tsx @@ -29,6 +29,8 @@ export interface SpatialViewerProps { onViewStateChange: (vs: ViewState) => void; /** deck.gl layers to render (shapes, points, etc.) */ layers: Layer[]; + /** Global SpatialCanvas layer order, bottom to top. */ + layerOrder?: string[]; /** Optional: Viv layer props for image layers */ vivLayerProps?: ImageLayerConfig[]; /** Optional: Callback on hover */ @@ -52,6 +54,7 @@ export function SpatialViewer({ viewState, onViewStateChange, layers, + layerOrder, vivLayerProps, onHover, onClick, @@ -69,6 +72,7 @@ export function SpatialViewer({ onViewStateChange={onViewStateChange} vivLayerProps={vivLayerProps} extraLayers={layers} + layerOrder={layerOrder} onHover={onHover} onClick={onClick} deckProps={deckProps} diff --git a/packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx b/packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx index 060eaceb..c4380c5b 100644 --- a/packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx +++ b/packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx @@ -85,6 +85,8 @@ export interface VivSpatialViewerProps { vivLayerProps: ImageLayerConfig[]; /** Extra deck.gl layers (shapes, points, etc.) */ extraLayers?: Layer[]; + /** Global SpatialCanvas layer order, bottom to top. */ + layerOrder?: string[]; /** Viewport width */ width: number; /** Viewport height */ @@ -106,45 +108,55 @@ interface VivSpatialViewerState { // deckRef?: React.MutableRefObject; } -/** - * Pure function to compose layers: [vivImageLayers, ...extraLayers, scaleBarLayer] - * Note: extraLayers (shapes/points) render on top of images - * - * This matches MDVivViewer's pattern exactly: - * - When deckProps.layers exists: [otherLayers (images), ...deckProps.layers (shapes), scaleBar] - * - When deckProps.layers is undefined: [vivLayers (all), scaleBar] - */ -function composeLayers( - vivLayers: LayersList, - extraLayers: LayersList = [], - deckPropsLayers?: LayersList -): LayersList { - // Separate scale bar from other Viv layers - const scaleBarLayer = vivLayers.find((layer) => layer instanceof ScaleBarLayer); - const otherVivLayers = vivLayers.filter((layer) => layer !== scaleBarLayer); - - // Follow MDV pattern: [otherLayers (images), ...deckProps.layers (shapes), scaleBar] - // In our case, extraLayers = shapes/points (equivalent to deckProps.layers in MDV) - // Always compose: [image layers, ...extraLayers, ...deckPropsLayers, scaleBar] - const layers: LayersList = []; - - // Add image layers (without scale bar) first - these render at the bottom - if (otherVivLayers.length > 0) { - layers.push(...otherVivLayers); - } +interface OrderedLayerRecord { + layer: Layer; + orderId?: string; +} + +function stripVivId(id: string, vivId: string): string { + return id.includes(vivId) ? id.replace(vivId, '') : id; +} - // Add extra layers (shapes/points) - these render on top of images - // This is equivalent to deckProps.layers in MDV - if (extraLayers.length > 0) { - layers.push(...extraLayers); +function sortLayerRecords(records: OrderedLayerRecord[], layerOrder?: string[]): Layer[] { + if (!layerOrder?.length) { + return records.map((record) => record.layer); } + const orderIndex = new Map(layerOrder.map((id, index) => [id, index])); + return records + .map((record, originalIndex) => ({ + ...record, + originalIndex, + order: record.orderId === undefined ? undefined : orderIndex.get(record.orderId), + })) + .sort((a, b) => { + if (a.order === undefined && b.order === undefined) { + return a.originalIndex - b.originalIndex; + } + if (a.order === undefined) { + return 1; + } + if (b.order === undefined) { + return -1; + } + return a.order - b.order; + }) + .map((record) => record.layer); +} - // Add any additional deckProps layers - if (deckPropsLayers && deckPropsLayers.length > 0) { +function composeLayers( + orderedLayers: OrderedLayerRecord[], + deckPropsLayers: LayersList = [], + scaleBarLayer?: Layer, + layerOrder?: string[] +): LayersList { + const layers: LayersList = sortLayerRecords(orderedLayers, layerOrder); + + // Caller-supplied deckProps layers are not necessarily SpatialData layers, so + // keep them above the generated stack unless a future API gives them order ids. + if (deckPropsLayers.length > 0) { layers.push(...deckPropsLayers); } - // Scale bar always on top if (scaleBarLayer) { layers.push(scaleBarLayer); } @@ -356,7 +368,7 @@ class VivSpatialViewer extends React.PureComponent layer.id.includes(vivId) ? layer : layer.clone({ id: `${layer.id}${vivId}` }); - const extraLayersWithVivId = (extraLayers || []).map(withVivId); + const extraLayerRecords: OrderedLayerRecord[] = (extraLayers || []).map((layer) => { + const layerWithVivId = withVivId(layer); + return { + layer: layerWithVivId, + orderId: stripVivId(layerWithVivId.id, vivId), + }; + }); const deckPropsLayersWithVivId = normalizeVivLayers(deckProps?.layers ?? []).map(withVivId); if (vivLayerProps.length === 0) { - return composeLayers([], extraLayersWithVivId, deckPropsLayersWithVivId); + return composeLayers(extraLayerRecords, deckPropsLayersWithVivId, undefined, layerOrder); } - const vivLayers: Layer[] = []; - let scaleBarAdded = false; + const orderedLayers: OrderedLayerRecord[] = [...extraLayerRecords]; + let scaleBarLayer: Layer | undefined; const scaleBarView = this.getScaleBarView(); for (const imageLayerProps of vivLayerProps) { @@ -406,30 +424,30 @@ class VivSpatialViewer extends React.PureComponent layer instanceof ScaleBarLayer); } - // Compose with extra layers - following MDV pattern exactly - // MDV does: [otherLayers (images), ...deckProps.layers (shapes), scaleBar] - return composeLayers(vivLayers, extraLayersWithVivId, deckPropsLayersWithVivId); + return composeLayers(orderedLayers, deckPropsLayersWithVivId, scaleBarLayer, layerOrder); } render() { diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index d6fb3189..1417471d 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -235,6 +235,7 @@ function LayerSelector({ elements, enabledLayerIds, onToggleLayer }: LayerSelect */ interface ViewerSectionProps { deckLayers: Layer[]; + layerOrder: string[]; vivLayerProps: ImageLayerConfig[]; hasEnabledLayers: boolean; isBlocking: boolean; @@ -249,6 +250,7 @@ interface ViewerSectionProps { function ViewerSection({ deckLayers, + layerOrder, vivLayerProps, hasEnabledLayers, isBlocking, @@ -322,6 +324,7 @@ function ViewerSection({ viewState={viewState} onViewStateChange={handleViewStateChange} layers={deckLayers} + layerOrder={layerOrder} vivLayerProps={vivLayerProps.length > 0 ? vivLayerProps : undefined} onHover={onHover} /> @@ -708,6 +711,7 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn
{ expect(testViewer.layerFilter({ layer, viewport: { id: testViewer.viewId } })).toBe(true); } }); + + it('interleaves image and deck layers by SpatialCanvas layer order', () => { + const middleLayer = new ScatterplotLayer({ + id: 'shapes:middle', + data: [], + getPosition: [0, 0], + }); + const viewer = new VivSpatialViewer({ + width: 512, + height: 512, + viewState: { target: [32, 32], zoom: 1 }, + onViewStateChange: () => {}, + layerOrder: ['image:first', 'shapes:middle', 'image:second'], + extraLayers: [middleLayer], + vivLayerProps: [ + { + id: 'image:first', + loader: makeImageLoader(), + colors: [[255, 0, 0]], + contrastLimits: [[0, 255]], + channelsVisible: [true], + selections: [{}], + opacity: 0.5, + visible: true, + }, + { + id: 'image:second', + loader: makeImageLoader(), + colors: [[0, 255, 0]], + contrastLimits: [[0, 255]], + channelsVisible: [true], + selections: [{}], + opacity: 0.5, + visible: true, + }, + ], + }); + + const testViewer = viewer as unknown as { + _renderLayers: () => unknown; + }; + const layers = normalizeVivLayers(testViewer._renderLayers()); + + expect(layers.map((layer) => layer.id)).toEqual([ + expect.stringContaining('image:first'), + expect.stringContaining('shapes:middle'), + expect.stringContaining('image:second'), + ]); + }); }); From 9005818d88080f09355709060d7b1b95c6d71c09 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Wed, 27 May 2026 14:42:02 +0100 Subject: [PATCH 3/3] Cache world bounds and avoid render-time fit work --- docs/docs/vis/layer-prop-flow.mdx | 19 +++- packages/vis/src/SpatialCanvas/index.tsx | 27 +++--- .../vis/src/SpatialCanvas/useLayerData.ts | 96 ++++++++++++++++--- packages/vis/tests/spatialCanvasUtils.spec.ts | 45 ++++++++- 4 files changed, 155 insertions(+), 32 deletions(-) diff --git a/docs/docs/vis/layer-prop-flow.mdx b/docs/docs/vis/layer-prop-flow.mdx index 8fbbb8d8..3bba1533 100644 --- a/docs/docs/vis/layer-prop-flow.mdx +++ b/docs/docs/vis/layer-prop-flow.mdx @@ -60,6 +60,16 @@ refetches. The fix is upstream stability, not downstream caching. - Channel control values that are derived from `LayerConfig.channels` and a loaded fallback should still be memoized to keep render output cheap, but identity stability is not required for correctness. +- World bounds are structural too. Computing polygon bounds can be O(n-vertices), + so bounds must be cached by loaded data reference plus transform reference. + Opacity, color, visibility toggles inside the properties pane, and other + cosmetic layer edits must not re-run `boundsFromPolygons` / + `accumulatePolygonBounds`. +- Keep expensive fitting work behind command/effect boundaries. Render should + ask cheap questions such as "is this layer visible and renderable?" rather + than computing bounds just to decide whether a button looks enabled. The + actual bounds lookup belongs in the button handler or the guarded auto-fit + effect. ### For image layers through Viv @@ -152,7 +162,7 @@ enough to keep the contract honest. ## Current audit checklist -Use this checklist when changing images, labels, or future raster-like layers. +Use this checklist when changing images, labels, shapes, or future layer types. 1. **Identify the tile-loading owner.** For labels, that is `LabelsLayer` / its inner `TileLayer`. For images, that is Viv's @@ -166,6 +176,13 @@ Use this checklist when changing images, labels, or future raster-like layers. 4. **Test behavior, not cache mechanics.** A cosmetic opacity/color/channel change should not produce new tile reads. A real selection/loader change should. +5. **Profile non-fetch structural work too.** A cosmetic prop change should not + rebuild precomputed shape arrays, re-decode geometry, or re-scan polygon + vertices for world bounds. Tile fetches are only one symptom of a structural + leak. +6. **Check render-time UI state for hidden geometry work.** Buttons and panels + should not call `getWorldBoundsForLayer()` unless they are executing a user + command. ## See also diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index 1417471d..ef9055e3 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -310,9 +310,7 @@ function ViewerSection({ if (viewState === null) { return ( -
- {isBlocking ? 'Loading layer data...' : 'Framing view...'} -
+
{isBlocking ? 'Loading layer data...' : 'Framing view...'}
); } @@ -533,12 +531,12 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn : undefined; const selectedLayerLoadState = getLayerLoadState(selectedConfig?.id); - const selectedLayerWorldBounds = (() => { - const id = selectedConfig?.id; - if (!id) return null; - if (!hasRenderableLayerData(id)) return null; - return getWorldBoundsForLayer(id); - })(); + const selectedLayerCanCenter = + !!selectedConfig?.id && + selectedConfig.visible && + vw > 0 && + vh > 0 && + hasRenderableLayerData(selectedConfig.id); // we probably want to see more than obs columns here... but I also don't understand what subset of those we end up with. // why not allow instanceKey & regionKey... @@ -582,13 +580,13 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn ); const handleCenterOnSelectedLayer = useCallback(() => { - if (!selectedLayerId || vw <= 0 || vh <= 0) return; + if (!selectedLayerCanCenter || !selectedLayerId) return; const config = layers[selectedLayerId]; if (!config) return; const b = getWorldBoundsForLayer(config.id); if (!b) return; actions.setViewState(viewStateFromBounds(b, vw, vh)); - }, [selectedLayerId, layers, vw, vh, getWorldBoundsForLayer, actions]); + }, [selectedLayerCanCenter, selectedLayerId, layers, getWorldBoundsForLayer, actions, vw, vh]); if (sdLoading) { return ( @@ -759,11 +757,10 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn type="button" style={{ ...selectStyle, - cursor: - vw > 0 && vh > 0 && selectedLayerWorldBounds ? 'pointer' : 'not-allowed', - opacity: vw > 0 && vh > 0 && selectedLayerWorldBounds ? 1 : 0.5, + cursor: selectedLayerCanCenter ? 'pointer' : 'not-allowed', + opacity: selectedLayerCanCenter ? 1 : 0.5, }} - disabled={vw <= 0 || vh <= 0 || !selectedLayerWorldBounds} + disabled={!selectedLayerCanCenter} onClick={handleCenterOnSelectedLayer} > Center on layer diff --git a/packages/vis/src/SpatialCanvas/useLayerData.ts b/packages/vis/src/SpatialCanvas/useLayerData.ts index 2802211e..8c5a128b 100644 --- a/packages/vis/src/SpatialCanvas/useLayerData.ts +++ b/packages/vis/src/SpatialCanvas/useLayerData.ts @@ -79,6 +79,12 @@ interface ShapePrebuiltEntry { signature: string; } +export interface WorldBoundsCacheEntry { + dataRef: unknown; + transformRef: Matrix4; + bounds: AxisAlignedBounds | null; +} + interface LoadedData { shapes: Map; points: Map; @@ -91,6 +97,11 @@ interface LoadedData { * `hiddenFeatureIds` changes. */ shapePrebuiltData: Map; + /** + * World bounds keyed by element identity. Bounds depend on loaded geometry / + * loader source and transform, not cosmetic layer props such as opacity. + */ + worldBounds: Map; } type ResourceLoadStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -190,6 +201,10 @@ function getElementMapKey(config: Pick): str return `${config.type}:${config.elementKey}`; } +function getWorldBoundsCacheKey(elem: AvailableElement): string { + return `${elem.type}:${elem.key}`; +} + export function resolveLayerElement( layerId: string, config: LayerConfig | undefined, @@ -199,6 +214,22 @@ export function resolveLayerElement( return elementMap.get(getElementMapKey(config)) ?? elementMap.get(layerId); } +export function getCachedWorldBounds( + cache: Map, + key: string, + dataRef: unknown, + transformRef: Matrix4, + compute: () => AxisAlignedBounds | null +): AxisAlignedBounds | null { + const cached = cache.get(key); + if (cached && cached.dataRef === dataRef && cached.transformRef === transformRef) { + return cached.bounds; + } + const bounds = compute(); + cache.set(key, { dataRef, transformRef, bounds }); + return bounds; +} + async function loadShapesLayerData( element: ShapesElement ): Promise> { @@ -231,6 +262,7 @@ export function useLayerData( images: new Map(), labels: new Map(), shapePrebuiltData: new Map(), + worldBounds: new Map(), }); const stableSelectionArraysRef = useRef< Map @@ -757,6 +789,7 @@ export function useLayerData( const loaded = loadedDataRef.current; if (type === 'shapes') { loaded.shapes.delete(key); + loaded.worldBounds.delete(`shapes:${key}`); // Clear prebuilt data for every layer that maps to this element key. for (const [layerId, config] of Object.entries(layersRef.current)) { if (config.type === 'shapes' && config.elementKey === key) { @@ -765,10 +798,13 @@ export function useLayerData( } } else if (type === 'points') { loaded.points.delete(key); + loaded.worldBounds.delete(`points:${key}`); } else if (type === 'image') { loaded.images.delete(key); + loaded.worldBounds.delete(`image:${key}`); } else if (type === 'labels') { loaded.labels.delete(key); + loaded.worldBounds.delete(`labels:${key}`); } // The useEffect will pick up the missing data and reload }, []); @@ -813,28 +849,50 @@ export function useLayerData( const shapeData = loaded.shapes.get(elem.key); if (!shapeData) return null; const { renderData } = shapeData; - if ( - (renderData.geometryKind === 'circle' || renderData.geometryKind === 'point') && - renderData.circles - ) { - return boundsFromCircles(renderData.circles, elem.transform); - } - if (!renderData.polygons?.length) return null; - return boundsFromPolygons(renderData.polygons, elem.transform); + return getCachedWorldBounds( + loaded.worldBounds, + getWorldBoundsCacheKey(elem), + renderData, + elem.transform, + () => { + if ( + (renderData.geometryKind === 'circle' || renderData.geometryKind === 'point') && + renderData.circles + ) { + return boundsFromCircles(renderData.circles, elem.transform); + } + if (!renderData.polygons?.length) return null; + return boundsFromPolygons(renderData.polygons, elem.transform); + } + ); } if (elem.type === 'points') { const pointData = loaded.points.get(elem.key); if (!pointData) return null; - return boundsFromPoints(pointData, elem.transform, false); + return getCachedWorldBounds( + loaded.worldBounds, + getWorldBoundsCacheKey(elem), + pointData, + elem.transform, + () => boundsFromPoints(pointData, elem.transform, false) + ); } if (elem.type === 'image') { const imageData = loaded.images.get(elem.key); if (!imageData?.loader) return null; const source = Array.isArray(imageData.loader) ? imageData.loader[0] : imageData.loader; if (!source || typeof source !== 'object') return null; - const { width, height } = getImageSize(source as never); - const physical = getPhysicalSizeScalingMatrixFromMeta(source); - return boundsFromImagePixelExtents(width, height, elem.transform, physical); + return getCachedWorldBounds( + loaded.worldBounds, + getWorldBoundsCacheKey(elem), + source, + elem.transform, + () => { + const { width, height } = getImageSize(source as never); + const physical = getPhysicalSizeScalingMatrixFromMeta(source); + return boundsFromImagePixelExtents(width, height, elem.transform, physical); + } + ); } if (elem.type === 'labels') { const labelsData = loaded.labels.get(elem.key); @@ -843,9 +901,17 @@ export function useLayerData( ? labelsData.loader[0] : labelsData.loader; if (!source || typeof source !== 'object') return null; - const { width, height } = getImageSize(source as never); - const physical = getPhysicalSizeScalingMatrixFromMeta(source); - return boundsFromImagePixelExtents(width, height, elem.transform, physical); + return getCachedWorldBounds( + loaded.worldBounds, + getWorldBoundsCacheKey(elem), + source, + elem.transform, + () => { + const { width, height } = getImageSize(source as never); + const physical = getPhysicalSizeScalingMatrixFromMeta(source); + return boundsFromImagePixelExtents(width, height, elem.transform, physical); + } + ); } return null; } catch (err) { diff --git a/packages/vis/tests/spatialCanvasUtils.spec.ts b/packages/vis/tests/spatialCanvasUtils.spec.ts index 7edc0e99..ce84f00f 100644 --- a/packages/vis/tests/spatialCanvasUtils.spec.ts +++ b/packages/vis/tests/spatialCanvasUtils.spec.ts @@ -1,5 +1,6 @@ +import { Matrix4 } from '@math.gl/core'; import { describe, expect, it } from 'vitest'; -import { resolveLayerElement } from '../src/SpatialCanvas/useLayerData.js'; +import { getCachedWorldBounds, resolveLayerElement } from '../src/SpatialCanvas/useLayerData.js'; import { calculateInitialViewState } from '../src/SpatialCanvas/utils.js'; describe('calculateInitialViewState (vis SpatialCanvas utils)', () => { @@ -64,3 +65,45 @@ describe('resolveLayerElement', () => { ).toBe(imageElement); }); }); + +describe('getCachedWorldBounds', () => { + it('reuses structural bounds across cosmetic rerenders', () => { + const cache = new Map(); + const dataRef = { polygons: [] }; + const transformRef = new Matrix4(); + let calls = 0; + + const first = getCachedWorldBounds(cache, 'shapes:cells', dataRef, transformRef, () => { + calls += 1; + return { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + }); + const second = getCachedWorldBounds(cache, 'shapes:cells', dataRef, transformRef, () => { + calls += 1; + return { minX: 100, minY: 100, maxX: 200, maxY: 200 }; + }); + + expect(first).toEqual({ minX: 0, minY: 0, maxX: 10, maxY: 10 }); + expect(second).toBe(first); + expect(calls).toBe(1); + }); + + it('recomputes when structural data changes', () => { + const cache = new Map(); + const transformRef = new Matrix4(); + const initialData = { polygons: [] }; + const nextData = { polygons: [] }; + let calls = 0; + + getCachedWorldBounds(cache, 'shapes:cells', initialData, transformRef, () => { + calls += 1; + return { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + }); + const next = getCachedWorldBounds(cache, 'shapes:cells', nextData, transformRef, () => { + calls += 1; + return { minX: 20, minY: 20, maxX: 30, maxY: 30 }; + }); + + expect(next).toEqual({ minX: 20, minY: 20, maxX: 30, maxY: 30 }); + expect(calls).toBe(2); + }); +});