Layer prop flow and identity stability
This is an architecture note for anyone working on layer rendering inside
@spatialdata/vis or writing extension layers that plug into SpatialCanvas.
It exists because we have repeatedly tried to "fix" cosmetic-prop-triggered
network activity by adding bespoke caches, global stores, and visual-vs-structural
enumerations — all of which were the wrong answer. This page documents the
right answer once.
TL;DR
- Layers are pure functions of their props.
renderLayers()constructs layer instances from props; it never does async work, never reads globals, never maintains side-channel state. - Identity stability is the producer's job.
useLayerDatamust hand the same object reference forloader,selections, etc. across renders when the value hasn't changed. Cosmetic prop identity may churn freely. updateTriggersis the only mechanism that declares "structural". When a prop's change should invalidate downstream async work (tile fetching, re-loading data), it goes intoupdateTriggerson the layer that owns the side effect. There is no parallel registry.- 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 thegetTileDatatrigger set.
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.
Why this matters
TileLayer.updateState in deck.gl only calls tileset.reloadAll() when
changeFlags.dataChanged is true, and dataChanged is computed only from
changeFlags.updateTriggersChanged.getTileData (or the data prop changing
its dataComparator-equality). Every other prop change at most clears
tile.layers so the lightweight sub-layer wrappers regenerate — the tileset
itself, and all its cached tile data, is preserved.
This means cosmetic prop changes already "just work" — provided the producer
is not feeding deck a fresh loader reference or fresh selections array on
every render. When it is, deck sees the structural prop change and dutifully
refetches. The fix is upstream stability, not downstream caching.
The contract
For useLayerData (the producer)
- The public ordered input is moving toward
RenderStack(renderStack.entries) from@spatialdata/layers. The resolver may normalize that into lookup maps internally, but entry order and identity come from the stack, not a parallel publiclayerOrderarray. loader: same object reference until the underlying element changes.selections: same array reference until the selected values change. Memoize withuseMemokeyed on a stable signature of the selection values.- Layer caches for derived visual state must be keyed by layer identity when the value can differ between two layers that point at the same element. A single shapes element may appear in multiple layer configs with different opacity, filtering, table-driven colour encodings, or other visual properties.
- Cosmetic props (
colors,contrastLimits,channelsVisible,opacity, per-channel arrays,modelMatrix): identity may churn freely. Deck diffs them efficiently and updates uniforms without disturbing tile loading. - Channel control values that are derived from
LayerConfig.channelsand 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
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,
MultiscaleImageLayersetsupdateTriggers.getTileDatato[loader, selections]. - Keep
loaderandselectionsidentity-stable inuseLayerData. Cosmetic image props (colors,contrastLimits,channelsVisible,opacity,modelMatrix) can flow through as normal props. VivSpatialViewermay calldetailView.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.propsafter creation. Some Viv/deck props, including extension defaults, are not safe to preserve with object spread. Pass props into Viv up front, then uselayer.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 name in a "visual props" helper. - Declare structural dependencies on the tile-loading sublayer with
updateTriggers: { getTileData: [loader, selectionsKey] }. This is the single, declarative source of truth for "what counts as structural here." - Do not maintain module-level caches keyed by stringified layer ids.
- Do not override
initializeState/finalizeState/updateState/getSubLayerPropsByTileto read from globals. Cosmetic state flows through React → layer props → deck diff, end of story. - Accept arbitrary extra sub-layer overrides via the standard deck.gl
_subLayerPropsescape hatch. New extension props "just work" without changes to the layer or the producer.
For VivSpatialViewer
- Composes
vivLayerPropsandextraLayersfrom the producer. - Adds the Viv viewport-id suffix to layer ids for the layer-filter machinery. 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.
- 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)
These have been tried, do not work, and add design debt that obscures the actual problem. If you see yourself reaching for any of them, stop.
| Anti-pattern | Why it's wrong |
|---|---|
layerPropGroups.ts-style files enumerating "visual" vs "structural" props | Hand-rolled; silently breaks for any new prop a user-supplied extension might add. |
Global stores keyed by layer id (e.g. labelsVisualStore) | Bypasses React/deck; two SpatialCanvases sharing the same data can't have independent state; lifetimes become unmanageable. |
Module-level Maps caching getTileData, layer instances, or tile shells | Singletons across all canvases; memory leaks; racy cleanup; keyed by ad-hoc strings like [object Object]\x00.... |
| Hand-rolled layer matchers in viewer components | Reimplements LayerManager._transferLayerState. |
| Lifecycle overrides reading from globals | Not idiomatic deck; couples layer correctness to module-level state. |
| Cache-key unit tests in place of behavioral tests | They keep passing while the user still sees network activity. |
Behavioral test pattern
The acceptance criterion for any change in this area is runtime evidence,
not cache-hit counts. Set up a mock loader that increments a counter on
getTile, render the canvas, drive a cosmetic prop change (opacity slider,
channel color), and assert the counter did not change.
const fetchCount = { value: 0 };
const mockLoader = makeMockMultiscaleLoader({
onGetTile: () => { fetchCount.value += 1; },
});
const { rerender } = render(<SpatialCanvas {...propsWithLoader(mockLoader)} />);
await waitForTilesLoaded();
const before = fetchCount.value;
rerender(<SpatialCanvas {...propsWithLoader(mockLoader, { opacity: 0.5 })} />);
await flushUpdates();
expect(fetchCount.value).toBe(before);
One such test per layer type (image, labels, future custom layers) is
enough to keep the contract honest.
Current audit checklist
Use this checklist when changing images, labels, shapes, or future layer types.
- Identify the tile-loading owner. For labels, that is
LabelsLayer/ its innerTileLayer. For images, that is Viv'sMultiscaleImageLayer. - 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. - Keep adapter components transparent. Viewers may normalize ids and compose layers, but should not sort props into structural and cosmetic buckets.
- Test behavior, not cache mechanics. A cosmetic opacity/color/channel change should not produce new tile reads. A real selection/loader change should.
- 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.
- Check render-time UI state for hidden geometry work. Buttons and panels
should not call
getWorldBoundsForLayer()unless they are executing a user command.
Shapes feature state and deck.gl performance
Shapes layers can carry hundreds of thousands to millions of features. Most
deck.gl update cost on large layers is accessor re-execution (see
deck.gl performance — optimize accessors),
not React reconciliation. Treat shapes rendering with the same discipline as
tile layers: minimize how often per-feature work runs, and prefer uniform
props (opacity, radiusScale, etc.) for cosmetic changes.
What is structural vs cosmetic for shapes
| Change | Category | Expected work |
|---|---|---|
opacity, fillColor / strokeColor defaults, stroke width clamps | Cosmetic | Deck uniform / attribute invalidation only; no geometry or feature-state rebuild |
hiddenFeatureIds, fadedFeatureIds, per-feature colour maps, table-driven fill column | Structural | Rebuild shapePrebuiltData and/or ShapeFeatureStateRuntime |
| Geometry load, transform, element key | Structural | Reload / re-decode geometry |
Cosmetic edits must not re-run Record→Map conversion, re-filter the
feature list, or change updateTriggers keys that point at fresh objects every
frame.
Producer contract (useLayerData)
shapePrebuiltData(keyed by layer id): built when geometry loads and whenhiddenFeatureIdschanges — not on opacity tweaks.stableShapeFeatureStateRef(keyed by layer id): holds aShapeFeatureStateRuntime(Maps/Sets) rebuilt only when the feature-state signature changes (hidden/faded ids, opacity multiplier, manual colour record identity, table fill-colour signature). Pass this intorenderShapesLayer({ featureStateRuntime })sogetLayers()does not allocate on every render.- Merged feature-state objects must not be allocated each frame when only
cosmetic layer props change. Table-driven fill colours are merged inside
getStableShapeFeatureStateRuntimeonly when the signature changes. - Table-driven fill colours should be computed from
@spatialdata/core-resolvedrowIndexByFeatureIndexand the shared@spatialdata/layerscolour encoder. Do not add local feature-to-row precedence rules inSpatialCanvasor layer modules.
Runtime attachments are not layer props
renderStack.entries[*].props is for serializable renderer input. Runtime
functions and objects must stay outside that bag:
- host overlay factories belong in
hostLayerResolver - precomputed per-feature colour bytes belong in
featureColorResolver— aUint8Arrayinentry.propswould be a lie about what a saved config is - feature listeners belong in
onFeatureHover/onFeatureClick - raw deck listeners belong in
onHover/onClickordeckProps - tooltip portals belong in
tooltipContainer
These runtime attachments may read the resolved feature payload, including the
same SpatialFeatureTooltipData used by built-in tooltips, but they must not be
part of resource cache keys. Changing a listener should not reload images,
geometry, bounds, shape prebuilds, or feature-state runtimes.
Layer contract (@spatialdata/layers)
buildShapeFeatureStateRuntime: converts serializablefeatureStaterecords to Maps/Sets once. Accepts an existing runtime and returns it unchanged (isShapeFeatureStateRuntime).normalizeShapeFeatureState: thin wrapper; use at layer boundaries when the caller may still pass records (tests,SpatialLayer).updateTriggersforgetFillColor/getLineColor: list the specific Maps/Sets/scalars that affect colour, not the wholefeatureStateobject.buildShapeFillColorByFeatureId: converts a table column plus resolved row alignment into per-feature colours. It is renderer-agnostic and does not load tables or decide SpatialData feature/table association semantics.- Prefer constant accessors and layer
opacityover per-feature callbacks when the visual change is uniform.
Anti-patterns (shapes)
| Anti-pattern | Why it's wrong |
|---|---|
new Map(Object.entries(fillColorByFeatureId)) on every getLayers() | O(n) allocation and GC pressure on large feature sets |
Spreading featureState each render for table fill colours | Defeats WeakMap identity caches; triggers full accessor rebuilds |
| Resolving feature/table row precedence inside a colour encoder | Duplicates @spatialdata/core association semantics and drifts from Python spatialdata |
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 |
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.
VShapesSourcedecodes 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_VertexIDselects 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 withfwidthin 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.
Labels render path: a label-id-indexed LUT
Labels reuse the primitive above, with the one substitution their data forces. A shape's colour texture is indexed by feature index; a label has no geometry — it is a raster pixel value — so its table is indexed by the label's own integer instance id, and the bitmask fragment shader samples the instance-id raster and looks the integer up.
What carries over is the property that matters: a feature-state change re-uploads only the small table, never the tiles.
- The LUT texture is owned by
LabelsLayerand shared across tile sublayers. Per-tile textures would multiply a table that is already megabytes for a large segmentation. - Picking consults the same table, so a hidden label can never be picked.
- Label
0is background and never drawn, but is still filled in the table, so the shader reads it without a bounds branch per fragment. LabelsResolver.fillColorsis keyed by element and column — keying by element alone let two layers colouring one element by different columns evict each other on every plan.
Hover highlight: a uniform, not a table entry
The label under the cursor is drawn highlighted, matching what autoHighlight gives
shapes. Deck's own machinery cannot do it here: a labels tile's picking colour covers the
whole quad, so there is no per-label deck object for picking_filterHighlightColor to act
on, and enabling autoHighlight would light up the entire tile. The highlight is resolved
per fragment instead — the shader compares the sampled instance id against a
highlightedLabelId uniform, rather than a LUT entry, so a pointer move re-uploads
nothing.
- Both canvas surfaces resolve the pick through one function.
SpatialCanvasViewerand the full-UISpatialCanvaseach own ahandleHover; the highlight shipped working in the first and dead in the second precisely because of that.resolveHoveredLabelinfeatureTooltipHover.tsis the single implementation both must call — when adding hover behaviour, add it there, not in onehandleHover. - Hover is runtime render state, never Render Stack config.
useLayerDatakeeps it on a ref plus a version counter (setHoveredLabel) so a saved view can never carry it, and so pointer motion within one label schedules no re-render. Points already carry their highlight the same way. - One slot, not a per-layer map. Only one thing is under the cursor at a time, so "moved to a different labels layer" and "moved off" are the same transition.
resolveHighlightedLabelrefuses background and hidden labels before the value reaches the shader, so an id that went stale between the pick and the frame cannot light up something the filter hides.- The tint reuses deck's
highlightColor, with the same meaning (alpha is the blend weight), so one prop name covers shapes and labels. The labels default is declared indefaultProps; see the anti-patterns below for why it cannot be a use-site fallback.
Anti-patterns (labels)
| Anti-pattern | Why it's wrong |
|---|---|
| Building the LUT per tile sublayer | Duplicates a multi-megabyte table across every tile |
Returning a fresh FeatureColorBuffer wrapper each render | Re-uploads the texture every frame; keep the colors identity stable |
| Keying a colour resource by element alone | Two layers, two columns, one element → eviction ping-pong that never settles |
| Carrying the hovered label in the colour LUT | Re-uploads a multi-megabyte texture on every pointer move; use the uniform |
Defaulting a prop deck also defines with props.x ?? MY_DEFAULT | deck fills its own default in, so the prop is never absent and your fallback never runs. This drew every labels hover in deck's navy highlightColor. Redefine it in the layer's defaultProps, which does override deck's |
Shape/table annotation controls
Shape UI controls that expose table-backed values should not be limited to
associated table obs columns forever. They should also be able to use extra
annotation columns carried by the shapes element itself. Future work should
extend the same concept to entries corresponding to var values in X /
layers, once the core table/annotation API exposes those data sources
cleanly.
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.
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
packages/vis/src/SpatialCanvas/useLayerData.tspackages/vis/src/SpatialCanvas/VivSpatialViewer.tsxpackages/layers/src/LabelsLayer.ts- deck.gl
TileLayersource —updateStateis the canonical reference for what counts asdataChanged.