diff --git a/AGENTS.md b/AGENTS.md index a74f3c16..78e8c3b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,15 @@ joining work on this repository. ## Working norms +- Use the Node.js and pnpm versions pinned in `package.json` under `volta`. + If `node` or `pnpm` is missing or resolves to a different version, prefer + Volta-managed commands (for example `$(volta which pnpm)` or `~/.volta/bin/pnpm`) rather + than falling back to the Codex app bundled Node or the system Node. - Prefer behavioral tests over cache-key unit tests. If a change is performance-related, the test should observe runtime side effects (e.g. fetch counts), not internal cache hits. +- Treat layers as independent views of spatial elements: it must be valid for + multiple layer configs to represent the same underlying element with different + visual properties, filters, or table-driven encodings. - Worktrees share `.git` but not working state. Documents intended to outlive the current branch must land on `main`. diff --git a/docs/docs/core/elements.mdx b/docs/docs/core/elements.mdx index 4e6dd65a..cb074fd3 100644 --- a/docs/docs/core/elements.mdx +++ b/docs/docs/core/elements.mdx @@ -113,6 +113,12 @@ labels.ndim; labels.getTransformation('global'); ``` +Current implementation note: +labels currently expose picked feature identity from raster values (for example +segment/object ids) at render-time. A dedicated `LabelsElement` feature-id +loading API, parallel to `ShapesElement.loadFeatureIds()`, is planned but not +yet part of the public surface. + ## ShapesElement Shapes represent vector geometries like polygons and circles. @@ -185,6 +191,55 @@ table.attrs.region; // Element(s) this table annotates table.attrs.region_key; // Column name linking to region ``` +### Experimental extension idea: equivalent region encodings + +The SpatialData table contract currently maps each row to a single target +region+instance pair via `region_key` + `instance_key`. For some workflows, we +may want to assert that one row can be interpreted across multiple equivalent +elements (for example both `labels/cell_labels` and `shapes/cell_shapes`), then choose +whichever runtime representation is more appropriate for a task. + +This is not part of the SpatialData spec today. In this repo, a possible +experimental approach is to keep the canonical mapping unchanged and add +optional sidecar metadata under `table.uns.spatialdata_attrs`, for example: + +```ts +table.uns.spatialdata_attrs.experimental_equivalent_mappings = { + canonical: { + region_key: 'region', + instance_key: 'instance_id', + }, + aliases: { + labels: { + region: 'labels/cell_labels', + // Optional: when labels use a different obs column than canonical. + region_key: 'label_region_key', + instance_key: 'label_instance_id', + }, + shapes: { + region: 'shapes/cell_shapes', + // Omit keys when canonical columns already apply. + }, + }, +}; +``` + +Guidelines for experimentation: + +- Treat this metadata as optional and non-authoritative. +- Preserve normal `region_key`/`instance_key` behavior when it is absent. +- Prefer column-level equivalence (`*_region_key`, `*_instance_key`) over + per-id dictionaries. +- Keep one canonical identity per row to avoid ambiguous write/update paths. +- Do not assume other SpatialData tools will read this field. + +Alternative worth discussing upstream: +for common cases where an alias always targets one fixed region, it may be +simpler to store only `region: ''` for that alias (without a +`region_key` column reference). This is more divergent from today's +`region`/`region_key`/`instance_key` table-keys contract, but could reduce +friction for simple one-region equivalence workflows. + Implementation note: `loadObsIndex()` and `loadObsColumns()` are used by the feature-association helpers and currently stay on the direct zarr/parquet loader path rather than diff --git a/docs/docs/core/internals.mdx b/docs/docs/core/internals.mdx index 036e3a54..39f3a2f8 100644 --- a/docs/docs/core/internals.mdx +++ b/docs/docs/core/internals.mdx @@ -80,6 +80,32 @@ Planned evolution: - keep the current convenience helpers focused on association / tooltip use - continue feeding capability gaps back upstream to `anndata.js` +### Feature Identity Convergence (labels and shapes) + +Current state: + +- shapes expose stable feature ids through `ShapesElement.loadFeatureIds()` + (plus render data carrying `featureIds` and row-alignment metadata); +- labels currently expose feature identity from picked raster values during + interactive rendering/tooltips. + +Near-term unification direction: + +1. Add a labels-side feature-id API in `@spatialdata/core` (for example + non-zero unique ids at the selected scale). +2. Route both labels and shapes through the same association helper shape: + `featureId -> table row index`. +3. Keep render-time picking as one producer of feature ids, not the only one. + +This keeps the table contract (`region`, `region_key`, `instance_key`) intact +while making labels/shapes more symmetric in higher-level APIs. + +Experimental metadata idea (non-spec): +we may prototype optional table metadata that asserts equivalence classes across +elements (for example one canonical row identity corresponding to both +`labels//` and `shapes//`). This should remain explicitly +experimental and advisory until there is upstream spec support. + ## Element Factory (Internal) Elements are created internally when loading stores. These functions are not intended for application use: diff --git a/docs/docs/vis/feature-table-associations.mdx b/docs/docs/vis/feature-table-associations.mdx new file mode 100644 index 00000000..1bf34c98 --- /dev/null +++ b/docs/docs/vis/feature-table-associations.mdx @@ -0,0 +1,135 @@ +--- +sidebar_position: 6 +--- + +# Feature table associations and annotation columns + +This note records the intended foundation for linking SpatialData features to +table rows, tooltip values, and visual encodings. The current `SpatialCanvas` +demo supports table-driven shape fill colour and aggregated feature tooltips, +but some of that logic still lives too close to the demo UI. Before publishing +the first stable visualization API, these responsibilities should be pulled +into reusable core/layer utilities. + +## Reference semantics + +Use Python `spatialdata` as the source of truth for association semantics. + +- Tables annotate regions through the `region`, `region_key`, `instance_key` + triplet. `region_key` identifies which spatial element a row annotates, and + `instance_key` identifies which instance within that element the row + annotates. See the upstream + [table annotations tutorial](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html) + and + [SpatialData design document](https://github.com/scverse/spatialdata/blob/main/docs/design_doc.md). +- Shapes are GeoDataFrames. Their semantic feature ids are the GeoDataFrame + index values, and extra GeoDataFrame columns are valid shape annotations. + The table `instance_key` should match those shape index values; row order is + not the association contract. +- Labels instances are raster label values, excluding background. Table + `instance_key` values annotate those label values. +- Points are not regions in the same sense as shapes/labels. They can carry + annotations directly and may also participate in future feature-key based + workflows, but they should not be forced into the region-table model by + default. + +In TypeScript terms, `featureId` should mean the canonical SpatialData +instance id. `featureIndex` should mean render/order position only. A +zero-based `featureIndex` can match a table row only when the upstream element +index is actually a zero-based range and has been exposed as the feature id. + +## Desired API shape + +`@spatialdata/core` should expose a single canonical alignment helper for +regions: + +```ts +type FeatureTableAlignment = { + rowIndexByFeatureIndex: Int32Array; + rowIndexByFeatureId?: Map; + resolveRowIndex(feature: { + featureId: string; + featureIndex: number; + rowIndex?: number; + }): number | undefined; +}; +``` + +The exact type may change, but the principle should not: tooltip resolution, +click/hover events, table-driven fill colour, filtering, and downstream +applications should all use one shared resolver. Avoid adding one-off helpers +such as `resolveShapeFillColorRowIndex` in `SpatialCanvas`. + +The resolver should: + +- Load association metadata from `region`, `region_key`, and `instance_key`. +- Filter rows by the target region/element. +- Match table rows to canonical feature ids, not to render order. +- Preserve unmatched features explicitly, rather than silently inventing a row. +- Treat positional fallback as a compatibility path only when the element ids + are known to be positional ids. + +## Package boundaries + +`@spatialdata/core` should own semantic association: + +- Reading table keys and annotation metadata. +- Loading element feature ids / instances. +- Building `FeatureTableAlignment`. +- Exposing shape/label/point annotation columns in a consistent way. + +`@spatialdata/layers` should own reusable deck/layer helpers: + +- Shape/label feature state runtimes. +- Pick datum interpretation and logical layer id normalization for composite + layers where needed. +- Optional column-to-colour encoders when they are renderer-agnostic and useful + to downstream apps. + +`@spatialdata/vis` and `SpatialCanvas` should remain UI glue: + +- Choosing which column to use. +- Displaying property panels. +- Loading the requested columns through core helpers. +- Passing resolved feature state into deck layers. + +The demo UI can exercise a feature before the public API is final, but new +semantic rules should not be invented in `SpatialCanvas`. + +## Annotation column roadmap + +Column choices in the demo should eventually include more than associated table +obs columns: + +- Associated table obs columns, excluding `instance_key` and `region_key`. +- Extra annotation columns stored directly on shape elements. +- Future entries corresponding to `vars` in `X` / `layers` for expression-like + matrices. + +Those sources should be surfaced through a common annotation-column discovery +API so downstream apps do not need to know whether a value came from AnnData +obs, a GeoDataFrame column, or a matrix-backed feature. + +## Current branch status + +The current `SpatialCanvas` behaviour is acceptable as demo functionality: + +- Shape fill colour can be driven by a chosen table column. +- Tooltips can aggregate multiple visible layers under the cursor. +- Multiple layer configs may represent the same element with different visual + properties. + +However, these are not yet the desired foundations for a first stable +library-facing API. Before publishing, revisit the implementation with this +checklist: + +1. Move feature-to-row association into a core helper with tests against the + SpatialData table semantics above. +2. Replace local row-index precedence rules in tooltip, fill-colour, and pick + event paths with that helper. +3. Move reusable colour encoders and feature-state helpers out of the demo UI + when downstream apps need them. +4. Keep `SpatialCanvas` as the consumer of these utilities, not the owner of + the semantics. +5. Add fixture coverage for non-matching row order, missing rows, mixed-region + tables, labels values, and shape annotation columns. diff --git a/docs/docs/vis/layer-prop-flow.mdx b/docs/docs/vis/layer-prop-flow.mdx index 3bba1533..51be7a05 100644 --- a/docs/docs/vis/layer-prop-flow.mdx +++ b/docs/docs/vis/layer-prop-flow.mdx @@ -54,6 +54,11 @@ refetches. The fix is upstream stability, not downstream caching. - `loader`: same object reference until the underlying element changes. - `selections`: same array reference until the selected values change. Memoize with `useMemo` keyed 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. @@ -184,6 +189,99 @@ Use this checklist when changing images, labels, shapes, or future layer types. 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](https://deck.gl/docs/developer-guide/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 when + `hiddenFeatureIds` changes — not on opacity tweaks. +- **`stableShapeFeatureStateRef`** (keyed by layer id): holds a + `ShapeFeatureStateRuntime` (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 into + `renderShapesLayer({ featureStateRuntime })` so `getLayers()` 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 + `getStableShapeFeatureStateRuntime` only when the signature changes. + +### Layer contract (`@spatialdata/layers`) + +- **`buildShapeFeatureStateRuntime`**: converts serializable `featureState` + records 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`). +- **`updateTriggers` for `getFillColor` / `getLineColor`**: list the specific + Maps/Sets/scalars that affect colour, not the whole `featureState` object. +- Prefer **constant accessors** and layer `opacity` over 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 | +| 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. + +## 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. + +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. + ## See also - [`packages/vis/src/SpatialCanvas/useLayerData.ts`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/vis/src/SpatialCanvas/useLayerData.ts) diff --git a/docs/docs/vis/overview.mdx b/docs/docs/vis/overview.mdx index ef9738a4..634df2ce 100644 --- a/docs/docs/vis/overview.mdx +++ b/docs/docs/vis/overview.mdx @@ -15,6 +15,8 @@ This document describes how **`@spatialdata/vis`** fits into the wider visualiza Dependencies include **`@hms-dbmi/viv`**, **deck.gl**, **`@spatialdata/avivatorish`**, **`@spatialdata/layers`**, and **`@spatialdata/core`** / **`@spatialdata/react`**. See [SpatialCanvas + images — status and roadmap](./spatial-canvas-status). +For the current boundary between demo UI behaviour and reusable feature/table +semantics, see [Feature table associations and annotation columns](./feature-table-associations). ## `@spatialdata/layers` diff --git a/docs/docs/vis/spatial-canvas-status.mdx b/docs/docs/vis/spatial-canvas-status.mdx index 216aabe5..fecc44ce 100644 --- a/docs/docs/vis/spatial-canvas-status.mdx +++ b/docs/docs/vis/spatial-canvas-status.mdx @@ -49,6 +49,10 @@ sidebar_position: 1 - **Histogram** (optional) and richer channel UX (MDV parity). - **Fold or replace `ImageView`** once SpatialCanvas covers the single-image case well. +- **Feature/table foundations:** keep current shape colour and aggregate + tooltip behaviour as demo functionality, then move canonical feature-to-row + association and annotation column discovery into reusable core/layer APIs. + See [Feature table associations and annotation columns](./feature-table-associations). ## Medium-term roadmap diff --git a/packages/core/src/tableAssociations.ts b/packages/core/src/tableAssociations.ts index f572f90a..4e13e65d 100644 --- a/packages/core/src/tableAssociations.ts +++ b/packages/core/src/tableAssociations.ts @@ -36,6 +36,40 @@ function buildAcceptedRegionValues( return accepted; } +function areZeroBasedSequentialFeatureIds(featureIds: readonly string[]): boolean { + return featureIds.every((featureId, index) => featureId === String(index)); +} + +function shouldAlignFeatureRowsByPosition( + featureIds: readonly string[], + filteredRowIds: readonly string[] +): boolean { + if (featureIds.length !== filteredRowIds.length || featureIds.length === 0) { + return false; + } + if (filteredRowIds.every((rowId, index) => rowId === featureIds[index])) { + return false; + } + // Shapes whose ids are 0..n-1 carry parquet row positions, not table instance keys. + // When row counts match, align by row order rather than string id equality. + return areZeroBasedSequentialFeatureIds(featureIds); +} + +function alignFeatureRowIndicesByPosition( + featureIds: readonly string[], + filteredRowIds: readonly string[], + rowIndexByFeatureId: Map +): Int32Array { + const rowIndexByFeatureIndex = createDefaultRowIndexByFeatureIndex(featureIds.length); + for (let featureIndex = 0; featureIndex < featureIds.length; featureIndex++) { + const rowIndex = rowIndexByFeatureId.get(filteredRowIds[featureIndex] ?? ''); + if (rowIndex !== undefined) { + rowIndexByFeatureIndex[featureIndex] = rowIndex; + } + } + return rowIndexByFeatureIndex; +} + export function isSpatialData(value: unknown): value is SpatialData { return ( typeof value === 'object' && @@ -147,10 +181,21 @@ export async function loadFeatureRowIndexByFeatureIndex({ key, }); - if (!associatedRows.rowIndexByFeatureId) { + if (!associatedRows.rowIndexByFeatureId || !associatedRows.rowIds) { return rowIndexByFeatureIndex; } + const filteredRowIds = associatedRows.rowIds; + const usePositionalAlignment = shouldAlignFeatureRowsByPosition(featureIds, filteredRowIds); + + if (usePositionalAlignment) { + return alignFeatureRowIndicesByPosition( + featureIds, + filteredRowIds, + associatedRows.rowIndexByFeatureId + ); + } + for (const [featureIndex, featureId] of featureIds.entries()) { const matchedRowIndex = associatedRows.rowIndexByFeatureId.get(featureId); if (matchedRowIndex !== undefined) { diff --git a/packages/core/src/tooltip.ts b/packages/core/src/tooltip.ts index 627921ff..c0e7e5ed 100644 --- a/packages/core/src/tooltip.ts +++ b/packages/core/src/tooltip.ts @@ -11,11 +11,80 @@ export type SpatialFeatureTooltipItem = { value: string; }; +/** One spatial element's worth of tooltip content (used when aggregating multi-layer picks). */ +export type SpatialFeatureTooltipSection = { + /** Spatial element key (e.g. `Leap034_imc_cell_shapes`). */ + elementKey: string; + /** Element kind (`shapes`, `labels`, …). */ + elementType: string; + /** Layer config id when known (e.g. `shapes:Leap034_imc_cell_shapes`). */ + layerId?: string; + title?: string; + items: SpatialFeatureTooltipItem[]; +}; + export type SpatialFeatureTooltipData = { + /** Picked spatial element key when showing a single-element tooltip. */ + elementKey?: string; + /** Picked spatial element type when showing a single-element tooltip. */ + elementType?: string; + /** Layer config id when known. */ + layerId?: string; title?: string; items: SpatialFeatureTooltipItem[]; + /** Multiple elements under the cursor (bottom-to-top pick order). */ + sections?: SpatialFeatureTooltipSection[]; +}; + +export type SpatialFeatureTooltipElementContext = { + elementKey: string; + elementType: string; + layerId?: string; }; +export function formatSpatialElementLabel(elementType: string, elementKey: string): string { + return `${elementType}/${elementKey}`; +} + +export function attachTooltipElementContext( + tooltip: Pick, + context: SpatialFeatureTooltipElementContext +): SpatialFeatureTooltipData { + const elementValue = formatSpatialElementLabel(context.elementType, context.elementKey); + const items = tooltip.items.filter((item) => item.label !== 'element'); + return { + ...tooltip, + elementKey: context.elementKey, + elementType: context.elementType, + layerId: context.layerId, + items: [{ label: 'element', value: elementValue }, ...items], + }; +} + +export function mergeSpatialFeatureTooltips( + tooltips: SpatialFeatureTooltipData[] +): SpatialFeatureTooltipData | undefined { + if (tooltips.length === 0) { + return undefined; + } + if (tooltips.length === 1) { + return tooltips[0]; + } + + const sections: SpatialFeatureTooltipSection[] = tooltips.map((tooltip) => ({ + elementKey: tooltip.elementKey ?? '', + elementType: tooltip.elementType ?? '', + layerId: tooltip.layerId, + title: tooltip.title, + items: tooltip.items, + })); + + return { + items: [], + sections, + }; +} + interface BaseTooltipMetadata { tooltipSignature?: string; tooltipFields?: string[]; diff --git a/packages/core/tests/shapesRenderData.spec.ts b/packages/core/tests/shapesRenderData.spec.ts index 3a1e16f1..8037523e 100644 --- a/packages/core/tests/shapesRenderData.spec.ts +++ b/packages/core/tests/shapesRenderData.spec.ts @@ -60,6 +60,54 @@ describe('loadFeatureRowIndexByFeatureIndex', () => { ).resolves.toEqual(new Int32Array([-1])); }); + it('aligns zero-based shape indices to table rows by order when instance ids differ', async () => { + const sdata = createMockSpatialData(); + const [, table] = sdata.getAssociatedTable('shapes', 'cells')!; + table.loadObsIndex = async () => ['1', '2', '3']; + table.loadObsColumns = async () => [['cells', 'cells', 'cells']]; + + await expect( + loadFeatureRowIndexByFeatureIndex({ + spatialData: sdata, + kind: 'shapes', + key: 'cells', + featureIds: ['0', '1', '2'], + }) + ).resolves.toEqual(new Int32Array([0, 1, 2])); + }); + + it('aligns zero-based shape indices even when table instance ids are non-sequential', async () => { + const sdata = createMockSpatialData(); + const [, table] = sdata.getAssociatedTable('shapes', 'cells')!; + table.loadObsIndex = async () => ['1', '5', '99']; + table.loadObsColumns = async () => [['cells', 'cells', 'cells']]; + + await expect( + loadFeatureRowIndexByFeatureIndex({ + spatialData: sdata, + kind: 'shapes', + key: 'cells', + featureIds: ['0', '1', '2'], + }) + ).resolves.toEqual(new Int32Array([0, 1, 2])); + }); + + it('aligns zero-based shape indices by row order when table ids are opaque strings', async () => { + const sdata = createMockSpatialData(); + const [, table] = sdata.getAssociatedTable('shapes', 'cells')!; + table.loadObsIndex = async () => ['cell-a', 'cell-b', 'cell-c']; + table.loadObsColumns = async () => [['cells', 'cells', 'cells']]; + + await expect( + loadFeatureRowIndexByFeatureIndex({ + spatialData: sdata, + kind: 'shapes', + key: 'cells', + featureIds: ['0', '1', '2'], + }) + ).resolves.toEqual(new Int32Array([0, 1, 2])); + }); + it('enriches ShapesElement render data with shared row alignment', async () => { const sdata = createMockSpatialData(); const shapeElement = sdata.shapes!.cells as any; diff --git a/packages/core/tests/tooltipDisplay.spec.ts b/packages/core/tests/tooltipDisplay.spec.ts new file mode 100644 index 00000000..3ce6cc1b --- /dev/null +++ b/packages/core/tests/tooltipDisplay.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + attachTooltipElementContext, + formatSpatialElementLabel, + mergeSpatialFeatureTooltips, +} from '../src/tooltip.js'; + +describe('tooltip display helpers', () => { + it('formats element labels as type/key', () => { + expect(formatSpatialElementLabel('shapes', 'cells')).toBe('shapes/cells'); + }); + + it('prepends element context to tooltip items', () => { + expect( + attachTooltipElementContext( + { title: 'cell-1', items: [{ label: 'area_px', value: '42' }] }, + { elementKey: 'cells', elementType: 'shapes', layerId: 'shapes:cells' } + ) + ).toEqual({ + title: 'cell-1', + elementKey: 'cells', + elementType: 'shapes', + layerId: 'shapes:cells', + items: [ + { label: 'element', value: 'shapes/cells' }, + { label: 'area_px', value: '42' }, + ], + }); + }); + + it('merges multiple tooltips into sections', () => { + const merged = mergeSpatialFeatureTooltips([ + attachTooltipElementContext( + { items: [{ label: 'a', value: '1' }] }, + { elementKey: 'shapes_a', elementType: 'shapes' } + ), + attachTooltipElementContext( + { items: [{ label: 'b', value: '2' }] }, + { elementKey: 'labels_b', elementType: 'labels' } + ), + ]); + + expect(merged?.sections).toHaveLength(2); + expect(merged?.sections?.[0].elementKey).toBe('shapes_a'); + expect(merged?.sections?.[1].elementKey).toBe('labels_b'); + }); +}); diff --git a/packages/layers/src/index.ts b/packages/layers/src/index.ts index 95a9b3ba..c91846cb 100644 --- a/packages/layers/src/index.ts +++ b/packages/layers/src/index.ts @@ -6,6 +6,13 @@ export type { LabelsLayerProps, LabelsSelection } from './LabelsLayer'; export { createShapesDeckLayer, buildShapesPrebuiltData, + DEFAULT_SHAPE_STROKE_WIDTH, + DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, + DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, + DEFAULT_SHAPE_STROKE_WIDTH_UNITS, + buildShapeFeatureStateRuntime, + EMPTY_SHAPE_FEATURE_STATE_RUNTIME, + isShapeFeatureStateRuntime, normalizeShapeFeatureState, resolveShapeFeatureFromPick, resolveShapeFeatureFromPickInfo, @@ -14,9 +21,13 @@ export { type ShapesLayerPickEvent, type ShapeCircleRenderDatum, type ShapeFeatureRenderDatum, + type ShapeFeatureStateInput, + type ShapeFeatureStateRuntime, type ShapePolygonRenderDatum, + type SpatialShapesRuntimeSublayer, type ShapesPrebuiltData, type ShapesRenderDataLike, + type ShapeStrokeWidthUnits, type ShapeTooltipRuntimeData, type GeoarrowTableLike, } from './shapesLayer'; diff --git a/packages/layers/src/shapesLayer.ts b/packages/layers/src/shapesLayer.ts index 720407fa..08cd5c48 100644 --- a/packages/layers/src/shapesLayer.ts +++ b/packages/layers/src/shapesLayer.ts @@ -1,5 +1,5 @@ import type { Matrix4 } from '@math.gl/core'; -import { PolygonLayer, ScatterplotLayer, type Layer, type PickingInfo } from 'deck.gl'; +import { type Layer, type PickingInfo, PolygonLayer, ScatterplotLayer } from 'deck.gl'; import type { SpatialShapesSublayer } from './spatialLayerProps'; export type ShapePolygon = Array>; @@ -8,8 +8,13 @@ export type ShapesGeometryKind = 'polygon' | 'circle' | 'point'; /** Default marker radius for point landmarks (pixels). */ export const DEFAULT_SHAPE_POINT_RADIUS_PX = 8; +export const DEFAULT_SHAPE_STROKE_WIDTH = 1; +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 ShapeStrokeWidthUnits = 'common' | 'pixels'; export interface ShapeCircleColumnarLike { positions: [Float32Array, Float32Array]; @@ -45,6 +50,14 @@ export interface ShapeFeatureStateRuntime { filteredOpacityMultiplier: number; } +export type ShapeFeatureStateInput = + | SpatialShapesSublayer['featureState'] + | ShapeFeatureStateRuntime; + +export type SpatialShapesRuntimeSublayer = Omit & { + featureState?: ShapeFeatureStateInput; +}; + export interface ShapePolygonRenderDatum { featureId: string; featureIndex: number; @@ -90,11 +103,11 @@ export interface ShapesPrebuiltData { data: ShapePolygonRenderDatum[] | ShapeCircleRenderDatum[]; } -/** Cache normalised featureState runtimes by object identity. */ +/** Cache normalised featureState runtimes by plain-object identity. */ const normalizeCache = new WeakMap(); /** Singleton for the common case of no featureState at all. */ -const EMPTY_FEATURE_STATE_RUNTIME = Object.freeze({ +export const EMPTY_SHAPE_FEATURE_STATE_RUNTIME = Object.freeze({ fillColorByFeatureId: new Map(), strokeColorByFeatureId: new Map(), hiddenFeatureIds: new Set(), @@ -102,15 +115,51 @@ const EMPTY_FEATURE_STATE_RUNTIME = Object.freeze({ filteredOpacityMultiplier: 0.35, } satisfies ShapeFeatureStateRuntime); -export function normalizeShapeFeatureState( - featureState: SpatialShapesSublayer['featureState'] +export function isShapeFeatureStateRuntime(value: unknown): value is ShapeFeatureStateRuntime { + if (!isRecord(value)) { + return false; + } + return ( + value.fillColorByFeatureId instanceof Map && + value.strokeColorByFeatureId instanceof Map && + value.hiddenFeatureIds instanceof Set && + value.fadedFeatureIds instanceof Set && + typeof value.filteredOpacityMultiplier === 'number' + ); +} + +function recordToRgbaMap( + record: Record | undefined +): Map { + if (!record) { + return new Map(); + } + const map = new Map(); + for (const key in record) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + map.set(key, record[key]); + } + } + return map; +} + +/** + * Build the Map/Set runtime used by deck accessors. Call once when feature-state + * content changes (filtering, table-driven colours), not on cosmetic prop churn. + */ +export function buildShapeFeatureStateRuntime( + featureState: NonNullable ): ShapeFeatureStateRuntime { - if (!featureState) return EMPTY_FEATURE_STATE_RUNTIME; + if (isShapeFeatureStateRuntime(featureState)) { + return featureState; + } const cached = normalizeCache.get(featureState); - if (cached) return cached; + if (cached) { + return cached; + } const result: ShapeFeatureStateRuntime = { - fillColorByFeatureId: new Map(Object.entries(featureState.fillColorByFeatureId ?? {})), - strokeColorByFeatureId: new Map(Object.entries(featureState.strokeColorByFeatureId ?? {})), + fillColorByFeatureId: recordToRgbaMap(featureState.fillColorByFeatureId), + strokeColorByFeatureId: recordToRgbaMap(featureState.strokeColorByFeatureId), hiddenFeatureIds: new Set(featureState.hiddenFeatureIds ?? []), fadedFeatureIds: new Set(featureState.fadedFeatureIds ?? []), filteredOpacityMultiplier: featureState.filteredOpacityMultiplier ?? 0.35, @@ -119,6 +168,28 @@ export function normalizeShapeFeatureState( return result; } +export function normalizeShapeFeatureState( + featureState: ShapeFeatureStateInput +): ShapeFeatureStateRuntime { + if (!featureState) { + return EMPTY_SHAPE_FEATURE_STATE_RUNTIME; + } + return buildShapeFeatureStateRuntime(featureState); +} + +function shapeFeatureColorUpdateTriggers( + featureState: ShapeFeatureStateRuntime, + defaultColor: [number, number, number, number] +) { + return [ + featureState.fillColorByFeatureId, + featureState.strokeColorByFeatureId, + featureState.fadedFeatureIds, + featureState.filteredOpacityMultiplier, + defaultColor, + ]; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -180,6 +251,19 @@ function multiplyAlpha( ]; } +function resolveFeatureColor( + featureId: string, + primaryColors: Map, + fallbackColors: Map, + defaultColor: [number, number, number, number], + featureState: ShapeFeatureStateRuntime +): [number, number, number, number] { + const base = primaryColors.get(featureId) ?? fallbackColors.get(featureId) ?? defaultColor; + return featureState.fadedFeatureIds.has(featureId) + ? multiplyAlpha(base, featureState.filteredOpacityMultiplier) + : base; +} + function resolveGeometryKind(renderData: ShapesRenderDataLike): ShapesGeometryKind { if (renderData.geometryKind) { return renderData.geometryKind; @@ -242,9 +326,7 @@ function buildCircleRenderedFeatures( } const x = xs[featureIndex]; const y = ys[featureIndex]; - const radius = usePerFeatureRadius - ? radii[featureIndex] - : DEFAULT_SHAPE_POINT_RADIUS_PX; + const radius = usePerFeatureRadius ? radii[featureIndex] : DEFAULT_SHAPE_POINT_RADIUS_PX; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(radius) || radius < 0) { continue; } @@ -373,24 +455,27 @@ export function resolveShapeTooltipRowIndex( alignment?: ShapeTooltipRowIndexAlignment ): number | undefined { const fromFeatureId = alignment?.tooltipRowIndexByFeatureId?.get(feature.featureId); - if (fromFeatureId !== undefined && fromFeatureId >= 0) { - return fromFeatureId; - } + const fromFeatureRowIndex = + feature.rowIndex !== undefined && feature.rowIndex >= 0 ? feature.rowIndex : undefined; + const fromTooltip = alignment?.tooltipRowIndices?.[feature.featureIndex]; + const fromRender = alignment?.rowIndexByFeatureIndex?.[feature.featureIndex]; - if (feature.rowIndex !== undefined && feature.rowIndex >= 0) { - return feature.rowIndex; + if (fromFeatureRowIndex !== undefined) { + return fromFeatureRowIndex; } - const fromTooltip = alignment?.tooltipRowIndices?.[feature.featureIndex]; if (fromTooltip !== undefined && fromTooltip >= 0) { return fromTooltip; } - const fromRender = alignment?.rowIndexByFeatureIndex?.[feature.featureIndex]; if (fromRender !== undefined && fromRender >= 0) { return fromRender; } + if (fromFeatureId !== undefined && fromFeatureId >= 0) { + return fromFeatureId; + } + return undefined; } @@ -420,14 +505,23 @@ export function resolveShapeTooltipFromPickInfo( return undefined; } const rowIndex = resolveShapeTooltipRowIndex(feature, alignment); - if ( - rowIndex === undefined || - rowIndex < 0 || - !renderData.tooltipFields || - !renderData.tooltipColumns - ) { + if (!renderData.tooltipFields || !renderData.tooltipColumns) { return undefined; } + if (rowIndex === undefined || rowIndex < 0) { + return { + title: feature.featureId, + items: [ + { label: 'feature_id', value: feature.featureId }, + { label: 'feature_index', value: String(feature.featureIndex) }, + { + label: 'table_row', + value: + 'unmatched — shape index was not found in the associated table instance_key column', + }, + ], + }; + } const items = renderData.tooltipFields .map((field, fieldIndex) => { @@ -441,7 +535,17 @@ export function resolveShapeTooltipFromPickInfo( .filter((item) => item.value !== ''); if (items.length === 0) { - return undefined; + return { + title: feature.featureId, + items: [ + { label: 'feature_id', value: feature.featureId }, + { label: 'table_row', value: String(rowIndex) }, + { + label: 'tooltip', + value: 'matched table row has no non-empty values for the selected tooltip fields', + }, + ], + }; } return { @@ -452,32 +556,49 @@ export function resolveShapeTooltipFromPickInfo( function createPolygonDeckLayer( data: ShapePolygonRenderDatum[], - sublayer: SpatialShapesSublayer, + sublayer: SpatialShapesRuntimeSublayer, options: CreateShapesDeckLayerOptions ): Layer { const featureState = normalizeShapeFeatureState(sublayer.featureState); const defaultFillColor = sublayer.defaultFillColor ?? [100, 100, 200, 180]; - const defaultStrokeColor = sublayer.defaultStrokeColor ?? [255, 255, 255, 255]; - const defaultStrokeWidth = sublayer.defaultStrokeWidth ?? 1; + const defaultStrokeColor = sublayer.defaultStrokeColor ?? defaultFillColor; + const defaultStrokeWidth = sublayer.defaultStrokeWidth ?? DEFAULT_SHAPE_STROKE_WIDTH; + const defaultStrokeWidthUnits = + sublayer.defaultStrokeWidthUnits ?? DEFAULT_SHAPE_STROKE_WIDTH_UNITS; + const defaultStrokeWidthMinPixels = + sublayer.defaultStrokeWidthMinPixels ?? DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS; + const defaultStrokeWidthMaxPixels = + sublayer.defaultStrokeWidthMaxPixels ?? DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS; return new PolygonLayer({ id: options.id, data, getPolygon: (d) => d.polygon, - getFillColor: (d) => { - const base = featureState.fillColorByFeatureId.get(d.featureId) ?? defaultFillColor; - return featureState.fadedFeatureIds.has(d.featureId) - ? multiplyAlpha(base, featureState.filteredOpacityMultiplier) - : base; - }, - getLineColor: (d) => { - const base = featureState.strokeColorByFeatureId.get(d.featureId) ?? defaultStrokeColor; - return featureState.fadedFeatureIds.has(d.featureId) - ? multiplyAlpha(base, featureState.filteredOpacityMultiplier) - : base; - }, + getFillColor: (d) => + resolveFeatureColor( + d.featureId, + featureState.fillColorByFeatureId, + EMPTY_SHAPE_FEATURE_STATE_RUNTIME.fillColorByFeatureId, + defaultFillColor, + featureState + ), + getLineColor: (d) => + resolveFeatureColor( + d.featureId, + featureState.strokeColorByFeatureId, + featureState.fillColorByFeatureId, + defaultStrokeColor, + featureState + ), getLineWidth: defaultStrokeWidth, - lineWidthUnits: 'pixels', + lineWidthUnits: defaultStrokeWidthUnits, + lineWidthMinPixels: defaultStrokeWidthMinPixels, + lineWidthMaxPixels: defaultStrokeWidthMaxPixels, + updateTriggers: { + getFillColor: shapeFeatureColorUpdateTriggers(featureState, defaultFillColor), + getLineColor: shapeFeatureColorUpdateTriggers(featureState, defaultStrokeColor), + getLineWidth: [defaultStrokeWidth], + }, filled: true, stroked: true, opacity: options.opacity ?? 1, @@ -503,7 +624,7 @@ function createPolygonDeckLayer( function createCircleDeckLayer( data: ShapeCircleRenderDatum[], geometryKind: 'circle' | 'point', - sublayer: SpatialShapesSublayer, + sublayer: SpatialShapesRuntimeSublayer, options: CreateShapesDeckLayerOptions ): Layer { const featureState = normalizeShapeFeatureState(sublayer.featureState); @@ -522,6 +643,9 @@ function createCircleDeckLayer( ? multiplyAlpha(base, featureState.filteredOpacityMultiplier) : base; }, + updateTriggers: { + getFillColor: shapeFeatureColorUpdateTriggers(featureState, defaultFillColor), + }, opacity: options.opacity ?? 1, modelMatrix: options.modelMatrix, pickable: true, @@ -556,7 +680,7 @@ function createCircleDeckLayer( */ export function createShapesDeckLayer( renderData: ShapesRenderDataLike, - sublayer: SpatialShapesSublayer, + sublayer: SpatialShapesRuntimeSublayer, options: CreateShapesDeckLayerOptions, prebuilt?: ShapesPrebuiltData ): Layer | null { @@ -574,11 +698,7 @@ export function createShapesDeckLayer( options ); } - return createPolygonDeckLayer( - prebuilt.data as ShapePolygonRenderDatum[], - sublayer, - options - ); + return createPolygonDeckLayer(prebuilt.data as ShapePolygonRenderDatum[], sublayer, options); } // Fallback: build data inline (backward-compatible path for external callers). diff --git a/packages/layers/src/spatialLayerProps.ts b/packages/layers/src/spatialLayerProps.ts index 2fd00af4..0aa7949b 100644 --- a/packages/layers/src/spatialLayerProps.ts +++ b/packages/layers/src/spatialLayerProps.ts @@ -26,23 +26,36 @@ export const spatialScatterSublayerSchema = sublayerBase.extend({ kind: z.literal('scatter'), }); -export const spatialShapesSublayerSchema = sublayerBase.extend({ - kind: z.literal('shapes'), - elementKey: z.string(), - tooltipFields: z.array(z.string()).optional(), - defaultFillColor: rgbaColorSchema.optional(), - defaultStrokeColor: rgbaColorSchema.optional(), - defaultStrokeWidth: z.number().min(0).optional(), - featureState: z - .object({ - fillColorByFeatureId: z.record(z.string(), rgbaColorSchema).optional(), - strokeColorByFeatureId: z.record(z.string(), rgbaColorSchema).optional(), - hiddenFeatureIds: z.array(z.string()).optional(), - fadedFeatureIds: z.array(z.string()).optional(), - filteredOpacityMultiplier: z.number().min(0).max(1).optional(), - }) - .optional(), -}); +export const spatialShapesSublayerSchema = sublayerBase + .extend({ + kind: z.literal('shapes'), + elementKey: z.string(), + tooltipFields: z.array(z.string()).optional(), + defaultFillColor: rgbaColorSchema.optional(), + defaultStrokeColor: rgbaColorSchema.optional(), + defaultStrokeWidth: z.number().min(0).optional(), + defaultStrokeWidthUnits: z.enum(['common', 'pixels']).optional(), + defaultStrokeWidthMinPixels: z.number().min(0).optional(), + defaultStrokeWidthMaxPixels: z.number().min(0).optional(), + featureState: z + .object({ + fillColorByFeatureId: z.record(z.string(), rgbaColorSchema).optional(), + strokeColorByFeatureId: z.record(z.string(), rgbaColorSchema).optional(), + hiddenFeatureIds: z.array(z.string()).optional(), + fadedFeatureIds: z.array(z.string()).optional(), + filteredOpacityMultiplier: z.number().min(0).max(1).optional(), + }) + .optional(), + }) + .superRefine((data, ctx) => { + const min = data.defaultStrokeWidthMinPixels; + const max = data.defaultStrokeWidthMaxPixels; + if (min !== undefined && max !== undefined && min > max) { + const message = 'defaultStrokeWidthMinPixels must be <= defaultStrokeWidthMaxPixels'; + ctx.addIssue({ code: z.ZodIssueCode.custom, message, path: ['defaultStrokeWidthMinPixels'] }); + ctx.addIssue({ code: z.ZodIssueCode.custom, message, path: ['defaultStrokeWidthMaxPixels'] }); + } + }); export const spatialLabelsSublayerSchema = sublayerBase.extend({ kind: z.literal('labels'), diff --git a/packages/layers/tests/shapesLayer.spec.ts b/packages/layers/tests/shapesLayer.spec.ts index 69b5e1cb..e466fcc8 100644 --- a/packages/layers/tests/shapesLayer.spec.ts +++ b/packages/layers/tests/shapesLayer.spec.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; import { SpatialLayer } from '../src/SpatialLayer'; import { - createShapesDeckLayer, + DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, + DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, + DEFAULT_SHAPE_STROKE_WIDTH_UNITS, + type GeoarrowTableLike, + type ShapesRenderDataLike, + buildShapeFeatureStateRuntime, buildShapesPrebuiltData, + createShapesDeckLayer, + isShapeFeatureStateRuntime, + normalizeShapeFeatureState, resolveShapeFeatureFromPick, resolveShapeTooltipFromPickInfo, resolveShapeTooltipRowIndex, - type GeoarrowTableLike, - type ShapesRenderDataLike, } from '../src/shapesLayer'; const renderData: ShapesRenderDataLike = { @@ -16,9 +22,30 @@ const renderData: ShapesRenderDataLike = { elementKey: 'cells', featureIds: ['cell-1', 'cell-2', 'cell-3'], polygons: [ - [[[0, 0], [1, 0], [1, 1], [0, 0]]], - [[[2, 2], [3, 2], [3, 3], [2, 2]]], - [[[4, 4], [5, 4], [5, 5], [4, 4]]], + [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + [ + [ + [2, 2], + [3, 2], + [3, 3], + [2, 2], + ], + ], + [ + [ + [4, 4], + [5, 4], + [5, 5], + [4, 4], + ], + ], ], rowIndexByFeatureIndex: new Int32Array([10, 11, 12]), }; @@ -38,8 +65,22 @@ const geoarrowRenderData: ShapesRenderDataLike = { return { get(index: number) { return index === 0 - ? [[[0, 0], [1, 0], [1, 1], [0, 0]]] - : [[[2, 2], [3, 2], [3, 3], [2, 2]]]; + ? [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ] + : [ + [ + [2, 2], + [3, 2], + [3, 3], + [2, 2], + ], + ]; }, }; }, @@ -47,6 +88,27 @@ const geoarrowRenderData: ShapesRenderDataLike = { rowIndexByFeatureIndex: new Int32Array([20, 21]), }; +describe('shape feature state runtime', () => { + it('reuses a pre-built runtime without reconverting records', () => { + const runtime = buildShapeFeatureStateRuntime({ + fillColorByFeatureId: { a: [1, 2, 3, 255] }, + hiddenFeatureIds: ['b'], + }); + expect(isShapeFeatureStateRuntime(runtime)).toBe(true); + expect(buildShapeFeatureStateRuntime(runtime)).toBe(runtime); + expect(normalizeShapeFeatureState(runtime)).toBe(runtime); + }); + + it('caches record conversion by plain-object identity', () => { + const featureState = { + fillColorByFeatureId: { 'cell-1': [1, 2, 3, 255] as [number, number, number, number] }, + }; + expect(normalizeShapeFeatureState(featureState)).toBe( + normalizeShapeFeatureState(featureState) + ); + }); +}); + describe('createShapesDeckLayer', () => { it('applies feature-state styling and filtering keyed by feature id', () => { const layer = createShapesDeckLayer( @@ -76,11 +138,83 @@ describe('createShapesDeckLayer', () => { 'cell-3', ]); expect((props.getFillColor as (d: any) => number[])(props.data[0])).toEqual([1, 2, 3, 255]); - expect((props.getFillColor as (d: any) => number[])(props.data[1])).toEqual([ - 10, 20, 30, 128, + expect((props.getFillColor as (d: any) => number[])(props.data[1])).toEqual([10, 20, 30, 128]); + expect((props.getLineColor as (d: { featureId: string }) => number[])(props.data[0])).toEqual([ + 4, 5, 6, 255, ]); }); + it('uses fill colours for outlines by default with zoom-scaled stroke defaults', () => { + const layer = createShapesDeckLayer( + renderData, + { + kind: 'shapes', + elementKey: 'cells', + visible: true, + defaultFillColor: [10, 20, 30, 180], + featureState: { + fillColorByFeatureId: { 'cell-1': [1, 2, 3, 180] }, + }, + }, + { id: 'shapes-fill-stroke' } + ); + + if (!layer) { + throw new Error('Expected shapes layer to render'); + } + const props = layer.props as unknown as { + data: Array<{ featureId: string }>; + getLineColor: (datum: { featureId: string }) => number[]; + lineWidthUnits: string; + lineWidthMinPixels: number; + lineWidthMaxPixels: number; + updateTriggers: { + getFillColor: unknown[]; + getLineColor: unknown[]; + getLineWidth: unknown[]; + }; + }; + expect(props.lineWidthUnits).toBe(DEFAULT_SHAPE_STROKE_WIDTH_UNITS); + expect(props.lineWidthMinPixels).toBe(DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS); + expect(props.lineWidthMaxPixels).toBe(DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS); + expect(props.updateTriggers.getFillColor).toHaveLength(5); + 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]); + }); + + it('allows callers to configure polygon stroke width behavior', () => { + const layer = createShapesDeckLayer( + renderData, + { + kind: 'shapes', + elementKey: 'cells', + visible: true, + defaultStrokeWidth: 3, + defaultStrokeWidthUnits: 'pixels', + defaultStrokeWidthMinPixels: 0.5, + defaultStrokeWidthMaxPixels: 2, + }, + { id: 'shapes-configured-stroke' } + ); + + if (!layer) { + throw new Error('Expected shapes layer to render'); + } + const props = layer.props as unknown as { + getLineWidth: number; + lineWidthUnits: string; + lineWidthMinPixels: number; + lineWidthMaxPixels: number; + }; + expect(props.getLineWidth).toBe(3); + expect(props.lineWidthUnits).toBe('pixels'); + expect(props.lineWidthMinPixels).toBe(0.5); + expect(props.lineWidthMaxPixels).toBe(2); + }); + it('emits enriched pick callbacks', () => { const onShapeHover = vi.fn(); const layer = createShapesDeckLayer( @@ -161,9 +295,9 @@ describe('createShapesDeckLayer', () => { expect(layer).not.toBeNull(); const props = layer!.props as any; expect(props.radiusUnits).toBe('common'); - expect((props.data as Array<{ featureId: string; radius: number }>).map((d) => d.featureId)).toEqual( - ['cell-1', 'cell-2'] - ); + expect( + (props.data as Array<{ featureId: string; radius: number }>).map((d) => d.featureId) + ).toEqual(['cell-1', 'cell-2']); expect((props.getRadius as (d: { radius: number }) => number)(props.data[1])).toBe(2); }); @@ -203,7 +337,10 @@ describe('createShapesDeckLayer', () => { resolveShapeTooltipFromPickInfo( { tooltipFields: ['gene', 'score'], - tooltipColumns: [['a', 'b', 'c'], [1, 2, 3]], + tooltipColumns: [ + ['a', 'b', 'c'], + [1, 2, 3], + ], }, { object: picked }, { rowIndexByFeatureIndex: new Int32Array([0, 1, 2]) } @@ -280,7 +417,19 @@ describe('createShapesDeckLayer', () => { }); }); - it('prefers feature-id table lookup over geometry feature index', () => { + it('prefers feature-index alignment over instance-key map when both are present', () => { + expect( + resolveShapeTooltipRowIndex( + { featureId: '23816', featureIndex: 23816, rowIndex: 23816, polygon: renderData.polygons![0] }, + { + tooltipRowIndexByFeatureId: new Map([['23816', 22271]]), + rowIndexByFeatureIndex: new Int32Array(49750).fill(-1), + } + ) + ).toBe(23816); + }); + + it('prefers feature-id table lookup when feature-index alignment is unavailable', () => { expect( resolveShapeTooltipRowIndex( { featureId: 'cell-1', featureIndex: 5, polygon: renderData.polygons![0] }, diff --git a/packages/layers/tests/spatialLayerProps.spec.ts b/packages/layers/tests/spatialLayerProps.spec.ts index bc797797..60e923f1 100644 --- a/packages/layers/tests/spatialLayerProps.spec.ts +++ b/packages/layers/tests/spatialLayerProps.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; import { - migrateSpatialLayerProps, SPATIAL_LAYER_PROPS_SCHEMA_VERSION, + migrateSpatialLayerProps, spatialLayerPropsSchema, + spatialShapesSublayerSchema, } from '../src/spatialLayerProps'; describe('migrateSpatialLayerProps', () => { @@ -40,6 +41,21 @@ describe('migrateSpatialLayerProps', () => { expect(out.sublayers).toEqual([]); }); + it('rejects shapes sublayer when stroke width min exceeds max', () => { + const result = spatialShapesSublayerSchema.safeParse({ + kind: 'shapes', + elementKey: 'cells', + defaultStrokeWidthMinPixels: 5, + defaultStrokeWidthMaxPixels: 1, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message.includes('must be <='))).toBe( + true + ); + } + }); + it('parses shapes feature-state props', () => { const out = migrateSpatialLayerProps({ schemaVersion: SPATIAL_LAYER_PROPS_SCHEMA_VERSION, @@ -48,6 +64,9 @@ describe('migrateSpatialLayerProps', () => { kind: 'shapes', elementKey: 'cells', defaultFillColor: [1, 2, 3, 4], + defaultStrokeWidthUnits: 'common', + defaultStrokeWidthMinPixels: 0, + defaultStrokeWidthMaxPixels: 1, featureState: { fillColorByFeatureId: { 'cell-1': [5, 6, 7, 8] }, hiddenFeatureIds: ['cell-2'], @@ -61,6 +80,9 @@ describe('migrateSpatialLayerProps', () => { kind: 'shapes', elementKey: 'cells', defaultFillColor: [1, 2, 3, 4], + defaultStrokeWidthUnits: 'common', + defaultStrokeWidthMinPixels: 0, + defaultStrokeWidthMaxPixels: 1, }); }); }); diff --git a/packages/vis/src/SpatialCanvas/ShapeFillColorPanel.tsx b/packages/vis/src/SpatialCanvas/ShapeFillColorPanel.tsx new file mode 100644 index 00000000..93012b70 --- /dev/null +++ b/packages/vis/src/SpatialCanvas/ShapeFillColorPanel.tsx @@ -0,0 +1,77 @@ +import { useId, type CSSProperties } from 'react'; +import type { ShapesLayerConfig } from './types'; + +const helperTextStyle: CSSProperties = { + color: '#888', + fontSize: '11px', + marginBottom: 8, +}; + +const selectStyle: CSSProperties = { + backgroundColor: '#333', + color: '#fff', + border: '1px solid #444', + borderRadius: 4, + padding: '4px 8px', + fontSize: '13px', +}; + +export interface ShapeFillColorPanelProps { + tableName?: string; + availableFields: string[]; + selected?: ShapesLayerConfig['fillColorByColumn']; + onChange: (next: ShapesLayerConfig['fillColorByColumn'] | undefined) => void; + noAssociatedTableMessage: string; + noFieldsMessage?: string; +} + +export function ShapeFillColorPanel({ + tableName, + availableFields, + selected, + onChange, + noAssociatedTableMessage, + noFieldsMessage = 'No eligible obs columns found on the associated table', +}: ShapeFillColorPanelProps) { + const fillColorSelectId = useId(); + + return ( +
+
+ + {tableName ? ( + <> +
Table: {tableName}
+ {availableFields.length > 0 ? ( + + ) : ( +
{noFieldsMessage}
+ )} + + ) : ( +
{noAssociatedTableMessage}
+ )} +
+
+ ); +} diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx index f999d9b7..e8a011a8 100644 --- a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -1,6 +1,6 @@ import { type SpatialData, viewStateFromBounds } from '@spatialdata/core'; import { useMeasure } from '@uidotdev/usehooks'; -import type { DeckGLProps, Layer, PickingInfo } from 'deck.gl'; +import type { DeckGLProps, DeckGLRef, Layer, PickingInfo } from 'deck.gl'; import { type CSSProperties, type ReactNode, @@ -18,6 +18,7 @@ import { } from './SpatialFeatureTooltip'; import { SpatialViewer } from './SpatialViewer'; import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; +import { getDeckFromDeckGlRef, resolveHoverFeatureTooltip } from './featureTooltipHover'; import type { ElementsByType, LayerConfig, ShapesLayerPickEvent, ViewState } from './types'; import { useLayerData } from './useLayerData'; import { getAvailableElements } from './utils'; @@ -44,6 +45,10 @@ export interface SpatialCanvasViewerProps { showLoadingOverlay?: boolean; autoFit?: boolean; style?: CSSProperties; + /** + * When true (default), hover tooltips aggregate picks from all layers under the cursor. + */ + aggregateHoverTooltips?: boolean; } interface AutoFitInput { @@ -240,15 +245,14 @@ function SpatialCanvasViewerInner({ showLoadingOverlay = true, autoFit = true, style, + aggregateHoverTooltips = true, }: SpatialCanvasViewerProps) { const [measureRef, { width, height }] = useMeasure(); const viewerContainerRef = useRef(null); - const [hoverTooltip, setHoverTooltip] = useState<{ - x: number; - y: number; - title?: string; - items: Array<{ label: string; value: string }>; - } | null>(null); + const deckRef = useRef(null); + const [hoverTooltip, setHoverTooltip] = useState< + (SpatialFeatureTooltipData & { x: number; y: number }) | null + >(null); const vw = width ?? 0; const vh = height ?? 0; @@ -264,6 +268,10 @@ function SpatialCanvasViewerInner({ deckLayers: externalDeckLayers, autoFit, }); + const hoverPickLayerIds = useMemo( + () => Array.from(renderer.enabledLayerIds), + [renderer.enabledLayerIds] + ); const handleHover = useCallback( (info: PickingInfo) => { @@ -288,21 +296,22 @@ function SpatialCanvasViewerInner({ if (!shouldRenderInternalTooltip(renderTooltip)) { return; } - const tooltip = renderer.getFeatureTooltip(normalizedLayerId, { - index: info.index, - object: info.object, - }); - if (!tooltip) { - setHoverTooltip(null); - return; - } - setHoverTooltip({ - x: info.x, - y: info.y, - ...tooltip, + const tooltip = resolveHoverFeatureTooltip(info, renderer.getFeatureTooltip, { + aggregate: aggregateHoverTooltips, + deck: getDeckFromDeckGlRef(deckRef), + pickLayerIds: hoverPickLayerIds, }); + setHoverTooltip(tooltip); }, - [coordinateSystem, onHover, onShapeHover, renderTooltip, renderer] + [ + aggregateHoverTooltips, + coordinateSystem, + hoverPickLayerIds, + onHover, + onShapeHover, + renderTooltip, + renderer, + ] ); const handleClick = useCallback( @@ -346,12 +355,7 @@ function SpatialCanvasViewerInner({ : null; const tooltipPayload: SpatialFeatureTooltipData | null = - hoverTooltip && tooltipClientPosition - ? { - title: hoverTooltip.title, - items: hoverTooltip.items, - } - : null; + hoverTooltip && tooltipClientPosition ? hoverTooltip : null; const portalTarget = typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; const tooltipPortal = @@ -403,6 +407,7 @@ function SpatialCanvasViewerInner({ onHover={handleHover} onClick={handleClick} deckProps={deckProps} + deckRef={deckRef} /> {showLoadingOverlay && renderer.isBlocking && (
Loading layer data...
diff --git a/packages/vis/src/SpatialCanvas/SpatialFeatureTooltip.tsx b/packages/vis/src/SpatialCanvas/SpatialFeatureTooltip.tsx index c1a8b617..98709db1 100644 --- a/packages/vis/src/SpatialCanvas/SpatialFeatureTooltip.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialFeatureTooltip.tsx @@ -1,9 +1,15 @@ import type { CSSProperties } from 'react'; -import type { SpatialFeatureTooltipData, SpatialFeatureTooltipItem } from '@spatialdata/core'; +import type { + SpatialFeatureTooltipData, + SpatialFeatureTooltipItem, + SpatialFeatureTooltipSection, +} from '@spatialdata/core'; +import { formatSpatialElementLabel } from '@spatialdata/core'; export type { SpatialFeatureTooltipData, SpatialFeatureTooltipItem, + SpatialFeatureTooltipSection, } from '@spatialdata/core'; export type SpatialCanvasTooltipRenderProps = { @@ -13,7 +19,7 @@ export type SpatialCanvasTooltipRenderProps = { }; const tooltipBaseStyle: CSSProperties = { - maxWidth: 260, + maxWidth: 300, borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)', backgroundColor: 'rgba(10, 10, 10, 0.9)', @@ -30,6 +36,26 @@ const titleStyle: CSSProperties = { color: '#fafafa', }; +const sectionHeaderStyle: CSSProperties = { + marginBottom: 4, + fontWeight: 600, + fontSize: 11, + letterSpacing: '0.02em', + textTransform: 'uppercase', + color: '#94a3b8', +}; + +const sectionWrapStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 4, +}; + +const sectionDividerStyle: CSSProperties = { + margin: '8px 0', + borderTop: '1px solid rgba(255,255,255,0.12)', +}; + const itemsWrapStyle: CSSProperties = { display: 'flex', flexDirection: 'column', @@ -53,6 +79,39 @@ const itemValueStyle: CSSProperties = { const TOOLTIP_OFFSET = 12; +function elementHeaderLabel(section: SpatialFeatureTooltipSection): string { + return formatSpatialElementLabel(section.elementType, section.elementKey); +} + +function TooltipItems({ items }: { items: SpatialFeatureTooltipItem[] }) { + return ( +
+ {items.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ ); +} + +function TooltipSectionBlock({ + section, + showElementHeader, +}: { + section: SpatialFeatureTooltipSection; + showElementHeader: boolean; +}) { + return ( +
+ {showElementHeader &&
{elementHeaderLabel(section)}
} + {section.title &&
{section.title}
} + +
+ ); +} + export interface SpatialFeatureTooltipProps { /** Viewport X of the picked feature (deck.gl `info.x` + viewer origin). */ x: number; @@ -83,17 +142,32 @@ export function SpatialFeatureTooltip({ ...(position === 'fixed' ? { zIndex } : {}), }; - return ( -
- {tooltip.title &&
{tooltip.title}
} -
- {tooltip.items.map((item) => ( -
- {item.label} - {item.value} + const sections = tooltip.sections; + if (sections && sections.length > 0) { + return ( +
+ {sections.map((section, index) => ( +
+ {index > 0 &&
} +
))}
+ ); + } + + const showElementHeader = !!(tooltip.elementKey && tooltip.elementType); + const singleSection: SpatialFeatureTooltipSection = { + elementKey: tooltip.elementKey ?? '', + elementType: tooltip.elementType ?? '', + layerId: tooltip.layerId, + title: tooltip.title, + items: tooltip.items, + }; + + return ( +
+
); } diff --git a/packages/vis/src/SpatialCanvas/SpatialViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialViewer.tsx index f26a6040..b74ef299 100644 --- a/packages/vis/src/SpatialCanvas/SpatialViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialViewer.tsx @@ -10,10 +10,11 @@ * - Otherwise: uses simplified functional component with DetailView */ +import type { Deck } from '@deck.gl/core'; import { DetailView } from '@hms-dbmi/viv'; import { DeckGL } from 'deck.gl'; -import type { DeckGLProps, Layer, PickingInfo } from 'deck.gl'; -import { useCallback, useId, useMemo } from 'react'; +import type { DeckGLProps, DeckGLRef, Layer, PickingInfo } from 'deck.gl'; +import { type RefObject, useCallback, useId, useMemo } from 'react'; import VivSpatialViewer, { normalizeVivLayers } from './VivSpatialViewer'; import type { ViewState } from './types'; import type { ImageLayerConfig } from './useLayerData'; @@ -39,6 +40,8 @@ export interface SpatialViewerProps { onClick?: (info: PickingInfo) => void; /** Optional: Additional deck.gl props */ deckProps?: Partial; + /** Ref to the underlying Deck instance (for multi-layer tooltip picking). */ + deckRef?: RefObject; } /** @@ -59,6 +62,7 @@ export function SpatialViewer({ onHover, onClick, deckProps, + deckRef, }: SpatialViewerProps) { const hasImageLayers = vivLayerProps && vivLayerProps.length > 0; @@ -76,6 +80,7 @@ export function SpatialViewer({ onHover={onHover} onClick={onClick} deckProps={deckProps} + deckRef={deckRef} /> ); } @@ -91,6 +96,7 @@ export function SpatialViewer({ onHover={onHover} onClick={onClick} deckProps={deckProps} + deckRef={deckRef} /> ); } @@ -107,7 +113,8 @@ function SpatialViewerSimple({ onHover, onClick, deckProps, -}: Omit) { + deckRef, +}: Omit) { const viewId = useId(); const detailViewId = useMemo(() => `spatial-${viewId}`, [viewId]); type DeckDetailViewState = { @@ -178,6 +185,7 @@ function SpatialViewerSimple({ return ( void; /** Optional: Additional deck.gl props */ deckProps?: Partial; + /** Ref to the underlying Deck instance (for multi-layer tooltip picking). */ + deckRef?: React.RefObject; } interface VivSpatialViewerState { @@ -466,7 +469,7 @@ class VivSpatialViewer extends React.PureComponent b.length - a.length) + .map((id) => normalizeDeckLayerId(id)); + for (const candidate of candidates) { + if ( + normalized === candidate || + normalized.startsWith(`${candidate}-`) || + normalized.endsWith(`-${candidate}`) || + normalized.includes(`-${candidate}-`) + ) { + return candidate; + } + } + return normalized; +} + +function collectDeckLayerIds(layers: unknown, ids: string[] = []): string[] { + if (!layers) { + return ids; + } + if (Array.isArray(layers)) { + for (const layer of layers) { + collectDeckLayerIds(layer, ids); + } + return ids; + } + if (typeof layers === 'object') { + const id = Reflect.get(layers, 'id'); + if (typeof id === 'string') { + ids.push(id); + } + } + return ids; +} + +function collectCurrentDeckLayerIds(deck: PickMultipleObjectsCapable | null | undefined): string[] { + const ids: string[] = []; + collectDeckLayerIds(deck?.props?.layers, ids); + const layerManager = + typeof deck === 'object' && deck !== null ? Reflect.get(deck, 'layerManager') : undefined; + const getLayers = + typeof layerManager === 'object' && layerManager !== null + ? Reflect.get(layerManager, 'getLayers') + : undefined; + const flattenedLayers = + typeof getLayers === 'function' ? getLayers.call(layerManager) : undefined; + collectDeckLayerIds(flattenedLayers, ids); + return ids; +} + +function getSeenLogicalLayerIds(picks: PickingInfo[], logicalLayerIds: string[]): Set { + const seen = new Set(); + for (const pick of picks) { + const rawLayerId = typeof pick.layer?.id === 'string' ? pick.layer.id : ''; + const layerId = resolveLogicalLayerId(rawLayerId, logicalLayerIds); + if (layerId) { + seen.add(layerId); + } + } + return seen; +} + +export function resolveDeckPickLayerIds( + deck: PickMultipleObjectsCapable | null | undefined, + logicalLayerIds: string[] | undefined +): string[] | undefined { + if (!logicalLayerIds?.length) { + return undefined; + } + const logical = new Set(logicalLayerIds); + const deckLayerIds = collectCurrentDeckLayerIds(deck); + const resolved = deckLayerIds.filter( + (id) => logical.has(id) || logical.has(resolveLogicalLayerId(id, logicalLayerIds)) + ); + const uniqueResolved = Array.from(new Set(resolved)); + return uniqueResolved.length > 0 ? uniqueResolved : logicalLayerIds; +} + +export type FeatureTooltipResolver = ( + layerId: string, + pickInfo: Pick<{ index?: number; object?: unknown }, 'index' | 'object'> +) => SpatialFeatureTooltipData | undefined; + +export interface ResolveHoverFeatureTooltipOptions { + /** When true (default), query all pickable layers under the cursor via the Deck instance. */ + aggregate?: boolean; + deck?: PickMultipleObjectsCapable | null; + /** Candidate logical deck layer ids for tooltip aggregation. Used to cap Deck's repeated pick passes. */ + pickLayerIds?: string[]; + pickRadius?: number; + pickDepth?: number; +} + +export function getAggregateHoverPickDepth( + pickLayerIds: readonly string[] | undefined, + pickDepth?: number +): number { + if (typeof pickDepth === 'number') { + return pickDepth; + } + return pickLayerIds?.length ? Math.max(1, pickLayerIds.length) : DEFAULT_PICK_DEPTH; +} + +function collectPicks( + info: PickingInfo, + deck: PickMultipleObjectsCapable | null | undefined, + aggregate: boolean, + pickRadius: number, + pickDepth: number, + pickLayerIds: string[] | undefined +): PickingInfo[] { + if ( + aggregate && + deck && + typeof deck.pickMultipleObjects === 'function' && + typeof info.x === 'number' && + typeof info.y === 'number' + ) { + const layerIds = resolveDeckPickLayerIds(deck, pickLayerIds); + const picks = deck.pickMultipleObjects({ + x: info.x, + y: info.y, + radius: pickRadius, + depth: layerIds?.length ? Math.min(pickDepth, layerIds.length) : pickDepth, + layerIds, + }); + if (picks.length === 0) { + return [info]; + } + + if (!pickLayerIds?.length) { + return picks; + } + + const seenLayerIds = getSeenLogicalLayerIds(picks, pickLayerIds); + const missingLayerIds = pickLayerIds.filter( + (layerId) => !seenLayerIds.has(normalizeDeckLayerId(layerId)) + ); + if (missingLayerIds.length === 0) { + return picks; + } + + const supplementalPicks = missingLayerIds.flatMap((layerId) => + deck.pickMultipleObjects({ + x: info.x, + y: info.y, + radius: pickRadius, + depth: 1, + layerIds: resolveDeckPickLayerIds(deck, [layerId]), + }) + ); + return supplementalPicks.length > 0 ? [...picks, ...supplementalPicks] : picks; + } + return [info]; +} + +export function resolveHoverFeatureTooltip( + info: PickingInfo, + getFeatureTooltip: FeatureTooltipResolver, + options?: ResolveHoverFeatureTooltipOptions +): (SpatialFeatureTooltipData & { x: number; y: number }) | null { + if (!info.picked || typeof info.x !== 'number' || typeof info.y !== 'number') { + return null; + } + + const aggregate = options?.aggregate !== false; + const picks = collectPicks( + info, + options?.deck, + aggregate, + options?.pickRadius ?? DEFAULT_PICK_RADIUS, + getAggregateHoverPickDepth(options?.pickLayerIds, options?.pickDepth), + options?.pickLayerIds + ); + + const tooltips: SpatialFeatureTooltipData[] = []; + const seenLayerIds = new Set(); + + for (const pick of picks) { + if (!pick.picked) { + continue; + } + const rawLayerId = typeof pick.layer?.id === 'string' ? pick.layer.id : ''; + const layerId = resolveLogicalLayerId(rawLayerId, options?.pickLayerIds); + if (!layerId || seenLayerIds.has(layerId)) { + continue; + } + const tooltip = getFeatureTooltip(layerId, { + index: pick.index, + object: pick.object, + }); + if (!tooltip) { + continue; + } + seenLayerIds.add(layerId); + tooltips.push(tooltip); + } + + const merged = mergeSpatialFeatureTooltips(tooltips); + if (!merged) { + return null; + } + + return { + x: info.x, + y: info.y, + ...merged, + }; +} diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index ef9055e3..8ccc4c47 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -10,7 +10,7 @@ import { viewStateFromBounds } from '@spatialdata/core'; import { useSpatialData } from '@spatialdata/react'; import { useMeasure } from '@uidotdev/usehooks'; -import type { Layer, PickingInfo } from 'deck.gl'; +import type { DeckGLRef, Layer, PickingInfo } from 'deck.gl'; import { type CSSProperties, type ReactNode, @@ -24,8 +24,8 @@ import { createPortal } from 'react-dom'; import { ImageChannelPanel } from './ImageChannelPanel'; import { LabelsChannelPanel } from './LabelsChannelPanel'; import { LayerOrderList } from './LayerOrderList'; +import { ShapeFillColorPanel } from './ShapeFillColorPanel'; import { shouldAutoFitSpatialView, useSpatialCanvasRenderer } from './SpatialCanvasViewer'; -import type { ImageLayerConfig } from './useLayerData'; import { type SpatialCanvasTooltipRenderProps, SpatialFeatureTooltip, @@ -35,43 +35,12 @@ import { SpatialViewer } from './SpatialViewer'; import { TooltipFieldsPanel } from './TooltipFieldsPanel'; import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; import { SpatialCanvasProvider, useSpatialCanvasActions, useSpatialCanvasStore } from './context'; +import { getDeckFromDeckGlRef, resolveHoverFeatureTooltip } from './featureTooltipHover'; import type { SpatialCanvasStoreApi } from './stores'; import type { AvailableElement, ElementsByType, LayerConfig, ViewState } from './types'; +import type { ImageLayerConfig } from './useLayerData'; import { generateLayerId, getAllCoordinateSystems } from './utils'; -export { - SpatialFeatureTooltip, - type SpatialFeatureTooltipData, - type SpatialFeatureTooltipItem, - type SpatialCanvasTooltipRenderProps, - type SpatialFeatureTooltipProps, -} from './SpatialFeatureTooltip'; - -// Re-export for external use -export { - SpatialCanvasProvider, - useSpatialCanvasStore, - useSpatialCanvasActions, - useSpatialCanvasStoreApi, -} from './context'; -export { createSpatialCanvasStore } from './stores'; -export type { SpatialCanvasStoreApi } from './stores'; -export type * from './types'; -export { useSpatialViewState, useViewStateUrl } from './hooks'; -export { VivSpatialViewer } from './VivSpatialViewer'; -export { - SpatialCanvasViewer, - composeSpatialDeckLayers, - shouldRenderInternalTooltip, - shouldAutoFitSpatialView, - useSpatialCanvasRenderer, -} from './SpatialCanvasViewer'; -export type { - SpatialCanvasViewerProps, - SpatialCanvasViewerRenderTooltip, -} from './SpatialCanvasViewer'; -export type { ImageLayerConfig as VivImageLayerConfig } from './useLayerData'; - // ============================================ // Styles // ============================================ @@ -246,6 +215,7 @@ interface ViewerSectionProps { vh: number; onHover: (info: PickingInfo) => void; coordinateSystem: string | null; + deckRef: React.RefObject; } function ViewerSection({ @@ -261,6 +231,7 @@ function ViewerSection({ vh, onHover, coordinateSystem, + deckRef, }: ViewerSectionProps) { const viewState = useSpatialCanvasStore((s) => s.viewState); const actions = useSpatialCanvasActions(); @@ -325,6 +296,7 @@ function ViewerSection({ layerOrder={layerOrder} vivLayerProps={vivLayerProps.length > 0 ? vivLayerProps : undefined} onHover={onHover} + deckRef={deckRef} /> {isBlocking && (
ReactNode; + /** + * When true (default), hover tooltips include picks from all layers under the cursor. + */ + aggregateHoverTooltips?: boolean; } -function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasInnerProps) { +function SpatialCanvasInner({ + tooltipContainer, + renderTooltip, + aggregateHoverTooltips = true, +}: SpatialCanvasInnerProps) { const { spatialData, loading: sdLoading } = useSpatialData(); const [measureRef, { width, height }] = useMeasure(); const shellRef = useRef(null); const viewerContainerRef = useRef(null); + const deckRef = useRef(null); const [fullscreen, setFullscreen] = useState(false); const [pendingFullscreenRefitSize, setPendingFullscreenRefitSize] = useState<{ width: number; height: number; } | null>(null); - const [hoverTooltip, setHoverTooltip] = useState<{ - x: number; - y: number; - title?: string; - items: Array<{ label: string; value: string }>; - } | null>(null); + const [hoverTooltip, setHoverTooltip] = useState< + (SpatialFeatureTooltipData & { x: number; y: number }) | null + >(null); const coordinateSystem = useSpatialCanvasStore((s) => s.coordinateSystem); const layers = useSpatialCanvasStore((s) => s.layers); @@ -446,6 +424,7 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn width: vw, height: vh, }); + const hoverPickLayerIds = useMemo(() => Array.from(enabledLayerIds), [enabledLayerIds]); useEffect(() => { if ( @@ -538,8 +517,9 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn 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... + // TODO: include extra annotation columns carried by the shapes element itself, + // not just associated table obs columns. Longer term, expose entries + // corresponding to vars in X / layers once core has a clean annotation API. const availableTooltipFields = associatedTable?.getObsColumnNames().filter((columnName) => { const tableKeys = associatedTable.getTableKeys(); @@ -548,27 +528,14 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn const handleHover = useCallback( (info: PickingInfo) => { - if (!info.picked || typeof info.x !== 'number' || typeof info.y !== 'number') { - setHoverTooltip(null); - return; - } - const rawLayerId = typeof info.layer?.id === 'string' ? info.layer.id : ''; - const normalizedLayerId = rawLayerId.replace(/-#.*#$/, ''); - const tooltip = getFeatureTooltip(normalizedLayerId, { - index: info.index, - object: info.object, - }); - if (!tooltip) { - setHoverTooltip(null); - return; - } - setHoverTooltip({ - x: info.x, - y: info.y, - ...tooltip, + const tooltip = resolveHoverFeatureTooltip(info, getFeatureTooltip, { + aggregate: aggregateHoverTooltips, + deck: getDeckFromDeckGlRef(deckRef), + pickLayerIds: hoverPickLayerIds, }); + setHoverTooltip(tooltip); }, - [getFeatureTooltip] + [aggregateHoverTooltips, getFeatureTooltip, hoverPickLayerIds] ); const handleViewerRef = useCallback( @@ -620,12 +587,7 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn : { ...containerStyle, position: 'relative' }; const tooltipPayload: SpatialFeatureTooltipData | null = - hoverTooltip && tooltipClientPosition - ? { - title: hoverTooltip.title, - items: hoverTooltip.items, - } - : null; + hoverTooltip && tooltipClientPosition ? hoverTooltip : null; const portalTarget = typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; @@ -720,6 +682,7 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn vh={vh} onHover={handleHover} coordinateSystem={coordinateSystem} + deckRef={deckRef} />
@@ -838,6 +801,17 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn updateLayer={actions.updateLayer} /> )} + {selectedConfig.type === 'shapes' && ( + { + actions.updateLayer(selectedConfig.id, { fillColorByColumn }); + }} + noAssociatedTableMessage="No associated table found for this shapes layer" + /> + )} {(selectedConfig.type === 'shapes' || selectedConfig.type === 'labels') && ( ReactNode; + /** + * When true (default), hover tooltips aggregate picks from all layers under the cursor. + */ + aggregateHoverTooltips?: boolean; } /** @@ -943,11 +921,16 @@ export default function SpatialCanvas({ store, tooltipContainer, renderTooltip, + aggregateHoverTooltips, }: SpatialCanvasProps) { return ( - + ); diff --git a/packages/vis/src/SpatialCanvas/public.ts b/packages/vis/src/SpatialCanvas/public.ts new file mode 100644 index 00000000..27550bf2 --- /dev/null +++ b/packages/vis/src/SpatialCanvas/public.ts @@ -0,0 +1,32 @@ +export { + SpatialFeatureTooltip, + type SpatialCanvasTooltipRenderProps, + type SpatialFeatureTooltipData, + type SpatialFeatureTooltipItem, + type SpatialFeatureTooltipProps, + type SpatialFeatureTooltipSection, +} from './SpatialFeatureTooltip'; +export { + SpatialCanvasProvider, + useSpatialCanvasActions, + useSpatialCanvasStore, + useSpatialCanvasStoreApi, +} from './context'; +export { useSpatialViewState, useViewStateUrl } from './hooks'; +export { createSpatialCanvasStore } from './stores'; +export type { SpatialCanvasStoreApi } from './stores'; +export type * from './types'; +export { VivSpatialViewer } from './VivSpatialViewer'; +export { + composeSpatialDeckLayers, + shouldAutoFitSpatialView, + shouldRenderInternalTooltip, + SpatialCanvasViewer, + useSpatialCanvasRenderer, +} from './SpatialCanvasViewer'; +export type { + SpatialCanvasViewerProps, + SpatialCanvasViewerRenderTooltip, +} from './SpatialCanvasViewer'; +export type { SpatialCanvasProps } from './index'; +export type { ImageLayerConfig as VivImageLayerConfig } from './useLayerData'; diff --git a/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts b/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts index b119b5bb..370fb64e 100644 --- a/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts +++ b/packages/vis/src/SpatialCanvas/renderers/shapesRenderer.ts @@ -8,7 +8,16 @@ import type { Matrix4 } from '@math.gl/core'; import type { ShapesElement, ShapesRenderData, SpatialFeatureTooltipData } from '@spatialdata/core'; -import { createShapesDeckLayer, type ShapesPrebuiltData } from '@spatialdata/layers'; +import { + DEFAULT_SHAPE_STROKE_WIDTH, + DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, + DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, + DEFAULT_SHAPE_STROKE_WIDTH_UNITS, + type ShapeStrokeWidthUnits, + type ShapeFeatureStateRuntime, + type ShapesPrebuiltData, + createShapesDeckLayer, +} from '@spatialdata/layers'; import type { Layer } from 'deck.gl'; export type ShapeTooltipDatum = SpatialFeatureTooltipData; @@ -28,8 +37,14 @@ export interface ShapesLayerRenderConfig { fillColor?: [number, number, number, number]; /** Fallback stroke color [r, g, b, a] (0-255) */ strokeColor?: [number, number, number, number]; - /** Fallback stroke width in pixels */ + /** Fallback stroke width in `strokeWidthUnits` */ strokeWidth?: number; + /** Units for polygon stroke width */ + strokeWidthUnits?: ShapeStrokeWidthUnits; + /** Minimum rendered stroke width in screen pixels */ + strokeWidthMinPixels?: number; + /** Maximum rendered stroke width in screen pixels */ + strokeWidthMaxPixels?: number; featureState?: { fillColorByFeatureId?: Record; strokeColorByFeatureId?: Record; @@ -44,6 +59,11 @@ export interface ShapesLayerRenderConfig { * construction and acts as a pure descriptor assembler. */ prebuilt?: ShapesPrebuiltData; + /** + * Pre-built Map/Set runtime from the load-path cache. When provided, deck + * layer assembly skips Record→Map conversion on every `getLayers()` call. + */ + featureStateRuntime?: ShapeFeatureStateRuntime; } /** @@ -59,9 +79,13 @@ export function renderShapesLayer(config: ShapesLayerRenderConfig): Layer | null opacity, visible, fillColor = [100, 100, 200, 180], - strokeColor = [255, 255, 255, 255], - strokeWidth = 1, + strokeColor, + strokeWidth = DEFAULT_SHAPE_STROKE_WIDTH, + strokeWidthUnits = DEFAULT_SHAPE_STROKE_WIDTH_UNITS, + strokeWidthMinPixels = DEFAULT_SHAPE_STROKE_WIDTH_MIN_PIXELS, + strokeWidthMaxPixels = DEFAULT_SHAPE_STROKE_WIDTH_MAX_PIXELS, featureState, + featureStateRuntime, renderData, prebuilt, } = config; @@ -80,7 +104,10 @@ export function renderShapesLayer(config: ShapesLayerRenderConfig): Layer | null defaultFillColor: fillColor, defaultStrokeColor: strokeColor, defaultStrokeWidth: strokeWidth, - featureState, + defaultStrokeWidthUnits: strokeWidthUnits, + defaultStrokeWidthMinPixels: strokeWidthMinPixels, + defaultStrokeWidthMaxPixels: strokeWidthMaxPixels, + featureState: featureStateRuntime ?? featureState, }, { id, diff --git a/packages/vis/src/SpatialCanvas/shapeColorEncoding.ts b/packages/vis/src/SpatialCanvas/shapeColorEncoding.ts new file mode 100644 index 00000000..12d47d96 --- /dev/null +++ b/packages/vis/src/SpatialCanvas/shapeColorEncoding.ts @@ -0,0 +1,148 @@ +import { COLOR_PALLETE } from '@spatialdata/avivatorish'; +import type { TableColumnData } from '@spatialdata/core'; +import type { ShapesLayerConfig } from './types'; + +export type ShapeFillColorMode = NonNullable['mode']; + +export interface BuildShapeFillColorByFeatureIdOptions { + featureIds: string[]; + rowIndexByFeatureIndex: Int32Array; + rowIndexByFeatureId?: Map; + column: TableColumnData | undefined; + mode: ShapeFillColorMode; + alpha: number; +} + +const NUMERIC_LOW: [number, number, number] = [0, 64, 255]; +const NUMERIC_HIGH: [number, number, number] = [255, 220, 0]; + +function normalizeCellValue(value: unknown): string { + if (value === null || value === undefined) return ''; + return String(value); +} + +function numericValue(value: string): number | undefined { + if (value.trim() === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function rgba( + rgb: readonly [number, number, number], + alpha: number +): [number, number, number, number] { + return [rgb[0], rgb[1], rgb[2], alpha]; +} + +function interpolateRgb( + low: readonly [number, number, number], + high: readonly [number, number, number], + t: number +): [number, number, number] { + const clamped = Math.max(0, Math.min(1, t)); + return [ + Math.round(low[0] + (high[0] - low[0]) * clamped), + Math.round(low[1] + (high[1] - low[1]) * clamped), + Math.round(low[2] + (high[2] - low[2]) * clamped), + ]; +} + +function getFiniteExtent(values: Array): [number, number] | undefined { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (value === undefined) continue; + if (value < min) min = value; + if (value > max) max = value; + } + return min === Number.POSITIVE_INFINITY ? undefined : [min, max]; +} + +export function resolveShapeFillColorMode( + mode: ShapeFillColorMode, + values: readonly string[] +): Exclude { + if (mode !== 'auto') return mode; + return values.every((value) => numericValue(value) !== undefined) ? 'continuous' : 'categorical'; +} + +function resolveShapeFillColorRowIndex({ + featureId, + featureIndex, + rowIndexByFeatureIndex, + rowIndexByFeatureId, +}: Pick & { + featureId: string; + featureIndex: number; +}): number | undefined { + const fromFeatureIndex = rowIndexByFeatureIndex[featureIndex]; + if (fromFeatureIndex !== undefined && fromFeatureIndex >= 0) { + return fromFeatureIndex; + } + const fromFeatureId = rowIndexByFeatureId?.get(featureId); + return fromFeatureId !== undefined && fromFeatureId >= 0 ? fromFeatureId : undefined; +} + +export function buildShapeFillColorByFeatureId({ + featureIds, + rowIndexByFeatureIndex, + rowIndexByFeatureId, + column, + mode, + alpha, +}: BuildShapeFillColorByFeatureIdOptions): Record { + if (!column) return {}; + + const valuesByFeature = featureIds.map((featureId, featureIndex) => { + // why do we end up with a special function for this? + // there should be a clear and consistent way of associating feature-ids with rows. + // this is also a hot-path in terms of performance, some trepidation around that as well. + // also, I'm not convinced this should be in vis/SpatialCanvas; + // this should be more of a common layer method. + const rowIndex = resolveShapeFillColorRowIndex({ + featureId, + featureIndex, + rowIndexByFeatureIndex, + rowIndexByFeatureId, + }); + const value = rowIndex !== undefined ? normalizeCellValue(column[rowIndex]) : ''; + return { featureId, value }; + }); + const nonEmptyValues = valuesByFeature + .map(({ value }) => value) + .filter((value) => value.trim() !== ''); + if (nonEmptyValues.length === 0) return {}; + + const resolvedMode = resolveShapeFillColorMode(mode, nonEmptyValues); + const colors: Record = {}; + + if (resolvedMode === 'continuous') { + const numericValues = valuesByFeature.map(({ value }) => numericValue(value)); + const extent = getFiniteExtent(numericValues); + if (!extent) return {}; + const [min, max] = extent; + const range = max - min; + + for (const [featureIndex, featureId] of featureIds.entries()) { + const value = numericValues[featureIndex]; + if (value === undefined) continue; + const t = range === 0 ? 0.5 : (value - min) / range; + colors[featureId] = rgba(interpolateRgb(NUMERIC_LOW, NUMERIC_HIGH, t), alpha); + } + return colors; + } + + const categoryIndexByValue = new Map(); + for (const { featureId, value } of valuesByFeature) { + if (value.trim() === '') continue; + let index = categoryIndexByValue.get(value); + if (index === undefined) { + index = categoryIndexByValue.size; + categoryIndexByValue.set(value, index); + } + const paletteColor = COLOR_PALLETE[index % COLOR_PALLETE.length]; + colors[featureId] = rgba(paletteColor, alpha); + } + + return colors; +} diff --git a/packages/vis/src/SpatialCanvas/types.ts b/packages/vis/src/SpatialCanvas/types.ts index cbd82bca..deedf64b 100644 --- a/packages/vis/src/SpatialCanvas/types.ts +++ b/packages/vis/src/SpatialCanvas/types.ts @@ -3,8 +3,8 @@ */ import type { Matrix4 } from '@math.gl/core'; -import type { SpatialElement, AnyElement } from '@spatialdata/core'; -import type { ShapesLayerPickEvent } from '@spatialdata/layers'; +import type { AnyElement, SpatialElement } from '@spatialdata/core'; +import type { ShapeStrokeWidthUnits, ShapesLayerPickEvent } from '@spatialdata/layers'; // ============================================ // View State Types @@ -64,8 +64,15 @@ export interface ImageLayerConfig extends BaseLayerConfig { export interface ShapesLayerConfig extends BaseLayerConfig { type: 'shapes'; fillColor?: [number, number, number, number]; + fillColorByColumn?: { + columnName: string; + mode: 'auto' | 'categorical' | 'continuous'; + }; strokeColor?: [number, number, number, number]; strokeWidth?: number; + strokeWidthUnits?: ShapeStrokeWidthUnits; + strokeWidthMinPixels?: number; + strokeWidthMaxPixels?: number; /** Table obs columns to display for a picked feature in this shapes layer. */ tooltipFields?: string[]; featureState?: { diff --git a/packages/vis/src/SpatialCanvas/useLayerData.ts b/packages/vis/src/SpatialCanvas/useLayerData.ts index 8c5a128b..af2ccd78 100644 --- a/packages/vis/src/SpatialCanvas/useLayerData.ts +++ b/packages/vis/src/SpatialCanvas/useLayerData.ts @@ -28,12 +28,14 @@ import { type ShapesTooltipMetadata, type SpatialData, type SpatialFeatureTooltipData, + attachTooltipElementContext, boundsFromCircles, boundsFromImagePixelExtents, boundsFromPoints, boundsFromPolygons, getPhysicalSizeScalingMatrixFromMeta, getTooltipSignature, + loadAssociatedTableFeatureRows, loadLabelsTooltipMetadata, loadShapesTooltipMetadata, resolveTooltipItems, @@ -42,9 +44,13 @@ import { import { type ShapeFeatureRenderDatum, type ShapesPrebuiltData, + buildShapeFeatureStateRuntime, buildShapesPrebuiltData, + EMPTY_SHAPE_FEATURE_STATE_RUNTIME, + type ShapeFeatureStateRuntime, resolveShapeFeatureFromPick, resolveShapeTooltipFromPickInfo, + resolveShapeTooltipRowIndex, } from '@spatialdata/layers'; import type { Layer } from 'deck.gl'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -57,7 +63,8 @@ import { createImageLoader } from './renderers/imageRenderer'; import { renderLabelsLayer } from './renderers/labelsRenderer'; import { type PointData, renderPointsLayer } from './renderers/pointsRenderer'; import { loadShapesData, renderShapesLayer } from './renderers/shapesRenderer'; -import type { AvailableElement, ElementsByType, LayerConfig } from './types'; +import { type ShapeFillColorMode, buildShapeFillColorByFeatureId } from './shapeColorEncoding'; +import type { AvailableElement, ElementsByType, LayerConfig, ShapesLayerConfig } from './types'; export interface ImageLoaderData { loader: unknown; @@ -79,6 +86,11 @@ interface ShapePrebuiltEntry { signature: string; } +interface ShapeFillColorEntry { + fillColorByFeatureId: Record; + signature: string; +} + export interface WorldBoundsCacheEntry { dataRef: unknown; transformRef: Matrix4; @@ -97,6 +109,12 @@ interface LoadedData { * `hiddenFeatureIds` changes. */ shapePrebuiltData: Map; + /** + * Per-layer table-column fill colour maps. Kept separate from element-keyed + * geometry because two layers may render the same shapes with different + * table columns. + */ + shapeFillColorData: Map; /** * World bounds keyed by element identity. Bounds depend on loaded geometry / * loader source and transform, not cosmetic layer props such as opacity. @@ -185,12 +203,34 @@ function getLayerTooltipSignature(config: LayerConfig | undefined): string { return config && 'tooltipFields' in config ? getTooltipSignature(config.tooltipFields) : ''; } +function getShapeFillColorAlpha(config: ShapesLayerConfig): number { + return config.fillColor?.[3] ?? 180; +} + +function getShapeFillColorSignature(config: LayerConfig | undefined): string { + if (!config || config.type !== 'shapes' || !config.fillColorByColumn?.columnName) { + return ''; + } + const mode: ShapeFillColorMode = config.fillColorByColumn.mode; + return [config.fillColorByColumn.columnName, mode, String(getShapeFillColorAlpha(config))].join( + '\u0001' + ); +} + /** Stable serialisation of `hiddenFeatureIds` for cache-invalidation comparison. */ function serializeHiddenIds(ids?: string[]): string { if (!ids || ids.length === 0) return ''; return ids.slice().sort().join('\x00'); } +function serializeColorByFeatureId( + colors?: Record +): string { + if (!colors || Object.keys(colors).length === 0) return ''; + const entries = Object.entries(colors).sort(([a], [b]) => a.localeCompare(b)); + return `\x02${entries.length}:${JSON.stringify(entries)}`; +} + function serializeRasterSelections(selections: RasterSelection[]): string { return selections .map((selection) => `z:${selection.z ?? ''}|c:${selection.c ?? ''}|t:${selection.t ?? ''}`) @@ -237,6 +277,94 @@ async function loadShapesLayerData( return { renderData }; } +async function loadShapeFillColorData({ + spatialData, + element, + renderData, + config, +}: { + spatialData: SpatialData | undefined; + element: ShapesElement; + renderData: ShapesRenderData; + config: ShapesLayerConfig; +}): Promise { + const fillColorByColumn = config.fillColorByColumn; + const signature = getShapeFillColorSignature(config); + if (!fillColorByColumn?.columnName) { + return { signature: '', fillColorByFeatureId: {} }; + } + + const rows = await loadAssociatedTableFeatureRows({ + spatialData, + kind: 'shapes', + key: element.key, + extraColumnNames: [fillColorByColumn.columnName], + }); + + return { + signature, + fillColorByFeatureId: buildShapeFillColorByFeatureId({ + featureIds: renderData.featureIds, + rowIndexByFeatureIndex: renderData.rowIndexByFeatureIndex, + rowIndexByFeatureId: rows.rowIndexByFeatureId, + column: rows.extraColumns?.[0], + mode: fillColorByColumn.mode, + alpha: getShapeFillColorAlpha(config), + }), + }; +} + +function mergeShapeFeatureStateForRender( + config: ShapesLayerConfig, + fillColorEntry: ShapeFillColorEntry | undefined +): ShapesLayerConfig['featureState'] { + if (!config.fillColorByColumn?.columnName) { + return config.featureState; + } + return { + ...config.featureState, + fillColorByFeatureId: fillColorEntry?.fillColorByFeatureId ?? {}, + strokeColorByFeatureId: fillColorEntry?.fillColorByFeatureId ?? {}, + }; +} + +function getShapeFeatureStateSignature( + config: ShapesLayerConfig, + fillColorEntry: ShapeFillColorEntry | undefined +): string { + const featureState = config.featureState; + const fillColors = featureState?.fillColorByFeatureId; + const strokeColors = featureState?.strokeColorByFeatureId; + return [ + serializeHiddenIds(featureState?.hiddenFeatureIds), + serializeHiddenIds(featureState?.fadedFeatureIds), + String(featureState?.filteredOpacityMultiplier ?? ''), + fillColorEntry?.signature ?? '', + serializeColorByFeatureId(fillColors), + serializeColorByFeatureId(strokeColors), + ].join('\x01'); +} + +function getStableShapeFeatureStateRuntime( + layerId: string, + config: ShapesLayerConfig, + fillColorEntry: ShapeFillColorEntry | undefined, + cache: Map +): ShapeFeatureStateRuntime { + const signature = getShapeFeatureStateSignature(config, fillColorEntry); + const cached = cache.get(layerId); + if (cached?.signature === signature) { + return cached.runtime; + } + + const merged = mergeShapeFeatureStateForRender(config, fillColorEntry); + const runtime = merged + ? buildShapeFeatureStateRuntime(merged) + : EMPTY_SHAPE_FEATURE_STATE_RUNTIME; + cache.set(layerId, { signature, runtime }); + return runtime; +} + /** * Hook to manage async loading of layer data and produce deck.gl layers. * @@ -262,16 +390,25 @@ export function useLayerData( images: new Map(), labels: new Map(), shapePrebuiltData: new Map(), + shapeFillColorData: new Map(), worldBounds: new Map(), }); const stableSelectionArraysRef = useRef< Map >(new Map()); + const stableShapeFeatureStateRef = useRef< + Map + >(new Map()); const layersRef = useRef(layers); layersRef.current = layers; const [layerLoadStates, setLayerLoadStates] = useState>({}); + const [, setLoadedDataRevision] = useState(0); + + const notifyLoadedDataChanged = useCallback(() => { + setLoadedDataRevision((revision) => revision + 1); + }, []); // Build a map of element key -> AvailableElement for quick lookup const elementMap = useRef>(new Map()); @@ -337,6 +474,7 @@ export function useLayerData( element: AvailableElement; loadGeometry: boolean; loadTooltip: boolean; + loadFillColor: boolean; loadImage: boolean; loadPoints: boolean; loadLabels: boolean; @@ -352,14 +490,20 @@ export function useLayerData( if (config.type === 'shapes') { const loadedShapes = loaded.shapes.get(elem.key); const tooltipSignature = getLayerTooltipSignature(config); + const fillColorSignature = getShapeFillColorSignature(config); + const fillColorEntry = loaded.shapeFillColorData.get(layerId); const loadGeometry = !loadedShapes; const loadTooltip = !loadedShapes || loadedShapes.tooltipSignature !== tooltipSignature; - if (loadGeometry || loadTooltip) { + const loadFillColor = fillColorSignature + ? fillColorEntry?.signature !== fillColorSignature + : fillColorEntry !== undefined; + if (loadGeometry || loadTooltip || loadFillColor) { toLoad.push({ layerId, element: elem, loadGeometry, loadTooltip, + loadFillColor, loadImage: false, loadPoints: false, loadLabels: false, @@ -376,6 +520,7 @@ export function useLayerData( element: elem, loadGeometry: false, loadTooltip, + loadFillColor: false, loadImage: false, loadPoints: false, loadLabels, @@ -387,6 +532,7 @@ export function useLayerData( element: elem, loadGeometry: false, loadTooltip: false, + loadFillColor: false, loadImage: false, loadPoints: true, loadLabels: false, @@ -397,6 +543,7 @@ export function useLayerData( element: elem, loadGeometry: false, loadTooltip: false, + loadFillColor: false, loadImage: true, loadPoints: false, loadLabels: false, @@ -414,6 +561,7 @@ export function useLayerData( element, loadGeometry, loadTooltip, + loadFillColor, loadImage, loadPoints, loadLabels, @@ -513,6 +661,45 @@ export function useLayerData( console.error(`Failed to load shapes tooltip for ${layerId}:`, error); } } + + if (loadFillColor) { + const shapeLayerConfig = + layersRef.current[layerId]?.type === 'shapes' + ? layersRef.current[layerId] + : undefined; + const requestedSignature = getShapeFillColorSignature(shapeLayerConfig); + if (!shapeLayerConfig || !requestedSignature) { + loadedDataRef.current.shapeFillColorData.delete(layerId); + notifyLoadedDataChanged(); + } else { + try { + const current = loadedDataRef.current.shapes.get(element.key); + if (!current?.renderData) { + return; + } + const fillColorData = await loadShapeFillColorData({ + spatialData, + element: element.element as ShapesElement, + renderData: current.renderData, + config: shapeLayerConfig, + }); + const latestDesired = getShapeFillColorSignature( + layersRef.current[layerId]?.type === 'shapes' + ? layersRef.current[layerId] + : undefined + ); + if (latestDesired !== requestedSignature) { + return; + } + loadedDataRef.current.shapeFillColorData.set(layerId, fillColorData); + notifyLoadedDataChanged(); + } catch (error) { + loadedDataRef.current.shapeFillColorData.delete(layerId); + notifyLoadedDataChanged(); + console.error(`Failed to load shapes fill colours for ${layerId}:`, error); + } + } + } } else if (element.type === 'points' && loadPoints) { try { setLayerResourceStatus(layerId, 'geometry', 'loading'); @@ -783,7 +970,14 @@ export function useLayerData( }; loadData(); - }, [layers, layerOrder, getOmeZarrMultiscalesData, spatialData, setLayerResourceStatus]); + }, [ + layers, + layerOrder, + getOmeZarrMultiscalesData, + spatialData, + setLayerResourceStatus, + notifyLoadedDataChanged, + ]); const reloadElement = useCallback((type: string, key: string) => { const loaded = loadedDataRef.current; @@ -794,6 +988,7 @@ export function useLayerData( for (const [layerId, config] of Object.entries(layersRef.current)) { if (config.type === 'shapes' && config.elementKey === key) { loaded.shapePrebuiltData.delete(layerId); + loaded.shapeFillColorData.delete(layerId); } } } else if (type === 'points') { @@ -956,7 +1151,15 @@ export function useLayerData( fillColor: config.fillColor, strokeColor: config.strokeColor, strokeWidth: config.strokeWidth, - featureState: config.featureState, + strokeWidthUnits: config.strokeWidthUnits, + strokeWidthMinPixels: config.strokeWidthMinPixels, + strokeWidthMaxPixels: config.strokeWidthMaxPixels, + featureStateRuntime: getStableShapeFeatureStateRuntime( + layerId, + config, + loaded.shapeFillColorData.get(layerId), + stableShapeFeatureStateRef.current + ), renderData: shapeData.renderData, prebuilt: loaded.shapePrebuiltData.get(layerId)?.prebuilt, }); @@ -1057,6 +1260,12 @@ export function useLayerData( return undefined; } + const elementContext = { + elementKey: elem.key, + elementType: elem.type, + layerId, + }; + if (elem.type === 'labels') { const pickedObject = pickInfo.object as | { labelId?: number | string; channelIndex?: number } @@ -1069,7 +1278,6 @@ export function useLayerData( const loadedLabelData = loadedDataRef.current.labels.get(elem.key); const config = layersRef.current[layerId]; - const title = labelId; const items: Array<{ label: string; value: string }> = [{ label: 'id', value: labelId }]; if ( @@ -1091,10 +1299,13 @@ export function useLayerData( } } - return { - title, - items, - }; + return attachTooltipElementContext( + { + title: labelId, + items, + }, + elementContext + ); } if (elem.type !== 'shapes') { @@ -1103,25 +1314,41 @@ export function useLayerData( const config = layersRef.current[layerId]; const loadedShapeData = loadedDataRef.current.shapes.get(elem.key); + const prebuilt = loadedDataRef.current.shapePrebuiltData.get(layerId)?.prebuilt; + const feature = resolveShapeFeatureFromPick(pickInfo, prebuilt); + if (!feature) { + return undefined; + } + if ( - !loadedShapeData?.tooltipFields || - !loadedShapeData.tooltipColumns || - getLayerTooltipSignature(config) !== (loadedShapeData.tooltipSignature ?? '') + loadedShapeData?.tooltipFields && + loadedShapeData.tooltipColumns && + getLayerTooltipSignature(config) === (loadedShapeData.tooltipSignature ?? '') ) { - return undefined; + const tooltip = resolveShapeTooltipFromPickInfo( + { + tooltipFields: loadedShapeData.tooltipFields, + tooltipColumns: loadedShapeData.tooltipColumns, + }, + pickInfo, + { + tooltipRowIndexByFeatureId: loadedShapeData.tooltipRowIndexByFeatureId, + tooltipRowIndices: loadedShapeData.tooltipRowIndices, + rowIndexByFeatureIndex: loadedShapeData.renderData.rowIndexByFeatureIndex, + }, + prebuilt + ); + if (tooltip) { + return attachTooltipElementContext(tooltip, elementContext); + } } - return resolveShapeTooltipFromPickInfo( - { - tooltipFields: loadedShapeData.tooltipFields, - tooltipColumns: loadedShapeData.tooltipColumns, - }, - pickInfo, + + return attachTooltipElementContext( { - tooltipRowIndexByFeatureId: loadedShapeData.tooltipRowIndexByFeatureId, - tooltipRowIndices: loadedShapeData.tooltipRowIndices, - rowIndexByFeatureIndex: loadedShapeData.renderData.rowIndexByFeatureIndex, + title: feature.featureId, + items: [{ label: 'feature_id', value: feature.featureId }], }, - loadedDataRef.current.shapePrebuiltData.get(layerId)?.prebuilt + elementContext ); }, [] @@ -1141,9 +1368,13 @@ export function useLayerData( return undefined; } const rowIndex = - loadedDataRef.current.shapes - .get(elem.key) - ?.tooltipRowIndexByFeatureId?.get(feature.featureId) ?? feature.rowIndex; + resolveShapeTooltipRowIndex(feature, { + tooltipRowIndexByFeatureId: loadedDataRef.current.shapes.get(elem.key) + ?.tooltipRowIndexByFeatureId, + tooltipRowIndices: loadedDataRef.current.shapes.get(elem.key)?.tooltipRowIndices, + rowIndexByFeatureIndex: loadedDataRef.current.shapes.get(elem.key)?.renderData + .rowIndexByFeatureIndex, + }) ?? feature.rowIndex; return { layerId, elementKey: elem.key, diff --git a/packages/vis/src/index.ts b/packages/vis/src/index.ts index 8ead4e57..c518cff0 100644 --- a/packages/vis/src/index.ts +++ b/packages/vis/src/index.ts @@ -27,7 +27,7 @@ export { shouldRenderInternalTooltip, shouldAutoFitSpatialView, useSpatialCanvasRenderer, -} from './SpatialCanvas'; +} from './SpatialCanvas/public'; export type { SpatialCanvasStoreApi, SpatialCanvasState, @@ -43,7 +43,8 @@ export type { SpatialCanvasViewerRenderTooltip, SpatialFeatureTooltipData, SpatialFeatureTooltipItem, + SpatialFeatureTooltipSection, SpatialCanvasTooltipRenderProps, SpatialFeatureTooltipProps, -} from './SpatialCanvas'; -export { SpatialFeatureTooltip } from './SpatialCanvas'; +} from './SpatialCanvas/public'; +export { SpatialFeatureTooltip } from './SpatialCanvas/public'; diff --git a/packages/vis/tests/featureTooltipHover.spec.ts b/packages/vis/tests/featureTooltipHover.spec.ts new file mode 100644 index 00000000..7d6b7dff --- /dev/null +++ b/packages/vis/tests/featureTooltipHover.spec.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getAggregateHoverPickDepth, + normalizeDeckLayerId, + resolveDeckPickLayerIds, + resolveHoverFeatureTooltip, +} from '../src/SpatialCanvas/featureTooltipHover.js'; + +describe('featureTooltipHover', () => { + it('normalizes viv-suffixed deck layer ids', () => { + expect(normalizeDeckLayerId('shapes:cells-#image-a#')).toBe('shapes:cells'); + }); + + it('aggregates tooltips from multiple layer picks', () => { + const getFeatureTooltip = vi.fn((layerId: string) => { + if (layerId === 'shapes:cells') { + return { + elementKey: 'cells', + elementType: 'shapes', + layerId, + items: [{ label: 'element', value: 'shapes/cells' }], + }; + } + if (layerId === 'labels:mask') { + return { + elementKey: 'mask', + elementType: 'labels', + layerId, + items: [{ label: 'element', value: 'labels/mask' }], + }; + } + return undefined; + }); + + const deck = { + pickMultipleObjects: () => [ + { + picked: true, + x: 10, + y: 20, + layer: { id: 'labels:mask' }, + index: 0, + object: {}, + }, + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells' }, + index: 1, + object: {}, + }, + ], + }; + + const result = resolveHoverFeatureTooltip( + { picked: true, x: 10, y: 20, layer: { id: 'shapes:cells' }, index: 1, object: {} }, + getFeatureTooltip, + { deck } + ); + + expect(result?.sections).toHaveLength(2); + expect(getFeatureTooltip).toHaveBeenCalledTimes(2); + }); + + it('caps aggregate picking depth to candidate tooltip layers', () => { + expect(getAggregateHoverPickDepth(['shapes:cells', 'labels:mask'])).toBe(2); + expect(getAggregateHoverPickDepth(['shapes:cells'], 6)).toBe(6); + }); + + it('passes candidate layer ids and capped depth to Deck aggregation', () => { + const getFeatureTooltip = vi.fn((layerId: string) => ({ + elementKey: layerId, + elementType: 'shapes' as const, + layerId, + items: [{ label: 'element', value: layerId }], + })); + const pickMultipleObjects = vi.fn(() => [ + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells' }, + index: 0, + object: {}, + }, + ]); + + resolveHoverFeatureTooltip( + { picked: true, x: 10, y: 20, layer: { id: 'shapes:cells' }, index: 1, object: {} }, + getFeatureTooltip, + { + deck: { pickMultipleObjects }, + pickLayerIds: ['shapes:cells', 'labels:mask'], + } + ); + + expect(pickMultipleObjects).toHaveBeenCalledWith({ + x: 10, + y: 20, + radius: 4, + depth: 2, + layerIds: ['shapes:cells', 'labels:mask'], + }); + }); + + it('resolves logical pick layer ids to Viv-suffixed deck layer ids', () => { + expect( + resolveDeckPickLayerIds( + { + props: { + layers: [ + { id: 'image-layer-image:imc' }, + { id: 'shapes:cells-#spatial-view#' }, + [{ id: 'labels:mask-#spatial-view#' }], + ], + }, + pickMultipleObjects: () => [], + }, + ['shapes:cells', 'labels:mask'] + ) + ).toEqual(['shapes:cells-#spatial-view#', 'labels:mask-#spatial-view#']); + }); + + it('resolves logical label ids to flattened bitmask tile layer ids', () => { + expect( + resolveDeckPickLayerIds( + { + props: { + layers: [{ id: 'labels:mask-#spatial-view#' }, { id: 'shapes:cells-#spatial-view#' }], + }, + layerManager: { + getLayers: () => [ + { id: 'shapes:cells-#spatial-view#' }, + { id: 'sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels' }, + ], + }, + pickMultipleObjects: () => [], + }, + ['shapes:cells', 'labels:mask'] + ) + ).toEqual([ + 'labels:mask-#spatial-view#', + 'shapes:cells-#spatial-view#', + 'sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels', + ]); + }); + + it('uses resolved deck layer ids for aggregation under Viv', () => { + const getFeatureTooltip = vi.fn((layerId: string) => ({ + elementKey: layerId, + elementType: 'shapes' as const, + layerId, + items: [{ label: 'element', value: layerId }], + })); + const pickMultipleObjects = vi.fn(() => [ + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 0, + object: {}, + }, + { + picked: true, + x: 10, + y: 20, + layer: { id: 'labels:mask-#spatial-view#' }, + index: 0, + object: {}, + }, + ]); + + const result = resolveHoverFeatureTooltip( + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 0, + object: {}, + }, + getFeatureTooltip, + { + deck: { + props: { + layers: [{ id: 'shapes:cells-#spatial-view#' }, { id: 'labels:mask-#spatial-view#' }], + }, + pickMultipleObjects, + }, + pickLayerIds: ['shapes:cells', 'labels:mask'], + } + ); + + expect(pickMultipleObjects).toHaveBeenCalledWith({ + x: 10, + y: 20, + radius: 4, + depth: 2, + layerIds: ['shapes:cells-#spatial-view#', 'labels:mask-#spatial-view#'], + }); + expect(result?.sections).toHaveLength(2); + expect(getFeatureTooltip).toHaveBeenCalledWith('shapes:cells', expect.any(Object)); + expect(getFeatureTooltip).toHaveBeenCalledWith('labels:mask', expect.any(Object)); + }); + + it('normalizes label bitmask sublayer picks back to the logical label layer', () => { + const getFeatureTooltip = vi.fn((layerId: string) => ({ + elementKey: layerId, + elementType: layerId.startsWith('labels:') ? ('labels' as const) : ('shapes' as const), + layerId, + items: [{ label: 'element', value: layerId }], + })); + const pickMultipleObjects = vi.fn(() => [ + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 0, + object: {}, + }, + { + picked: true, + x: 10, + y: 20, + layer: { id: 'sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels' }, + index: 0, + object: { labelId: 7 }, + }, + ]); + + const result = resolveHoverFeatureTooltip( + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 0, + object: {}, + }, + getFeatureTooltip, + { + deck: { + layerManager: { + getLayers: () => [ + { id: 'shapes:cells-#spatial-view#' }, + { id: 'sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels' }, + ], + }, + pickMultipleObjects, + }, + pickLayerIds: ['shapes:cells', 'labels:mask'], + } + ); + + expect(result?.sections).toHaveLength(2); + expect(getFeatureTooltip).toHaveBeenCalledWith('shapes:cells', expect.any(Object)); + expect(getFeatureTooltip).toHaveBeenCalledWith( + 'labels:mask', + expect.objectContaining({ object: { labelId: 7 } }) + ); + }); + + it('runs a targeted pick for a logical layer hidden behind duplicate same-layer picks', () => { + const getFeatureTooltip = vi.fn((layerId: string) => ({ + elementKey: layerId, + elementType: layerId.startsWith('labels:') ? ('labels' as const) : ('shapes' as const), + layerId, + items: [{ label: 'element', value: layerId }], + })); + const pickMultipleObjects = vi.fn(({ layerIds }: { layerIds?: string[] }) => { + if ( + layerIds?.length === 1 && + layerIds.includes('sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels') + ) { + return [ + { + picked: true, + x: 10, + y: 20, + layer: { id: 'sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels' }, + index: 0, + object: { labelId: 7 }, + }, + ]; + } + + return [ + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 0, + object: { featureId: 'cell-a' }, + }, + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 1, + object: { featureId: 'cell-b' }, + }, + ]; + }); + + const result = resolveHoverFeatureTooltip( + { + picked: true, + x: 10, + y: 20, + layer: { id: 'shapes:cells-#spatial-view#' }, + index: 0, + object: { featureId: 'cell-a' }, + }, + getFeatureTooltip, + { + deck: { + layerManager: { + getLayers: () => [ + { id: 'shapes:cells-#spatial-view#' }, + { id: 'sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels' }, + ], + }, + pickMultipleObjects, + }, + pickLayerIds: ['shapes:cells', 'labels:mask'], + } + ); + + expect(pickMultipleObjects).toHaveBeenCalledTimes(2); + expect(pickMultipleObjects).toHaveBeenLastCalledWith({ + x: 10, + y: 20, + radius: 4, + depth: 1, + layerIds: ['sub-layer-0,512,512,0-labels:mask-#spatial-view#-labels'], + }); + expect(result?.sections).toHaveLength(2); + expect(getFeatureTooltip).toHaveBeenCalledWith('shapes:cells', expect.any(Object)); + expect(getFeatureTooltip).toHaveBeenCalledWith( + 'labels:mask', + expect.objectContaining({ object: { labelId: 7 } }) + ); + }); + + it('falls back to the original hover pick when filtered aggregation returns no picks', () => { + const getFeatureTooltip = vi.fn(() => ({ + elementKey: 'cells', + elementType: 'shapes' as const, + layerId: 'shapes:cells', + items: [{ label: 'element', value: 'shapes/cells' }], + })); + + const result = resolveHoverFeatureTooltip( + { picked: true, x: 10, y: 20, layer: { id: 'shapes:cells' }, index: 1, object: {} }, + getFeatureTooltip, + { + deck: { pickMultipleObjects: () => [] }, + pickLayerIds: ['shapes:cells'], + } + ); + + expect(result?.items).toHaveLength(1); + expect(getFeatureTooltip).toHaveBeenCalledWith('shapes:cells', expect.any(Object)); + }); +}); diff --git a/packages/vis/tests/shapeColorEncoding.spec.ts b/packages/vis/tests/shapeColorEncoding.spec.ts new file mode 100644 index 00000000..6679a4c4 --- /dev/null +++ b/packages/vis/tests/shapeColorEncoding.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + buildShapeFillColorByFeatureId, + resolveShapeFillColorMode, +} from '../src/SpatialCanvas/shapeColorEncoding.js'; + +describe('shape fill colour encoding', () => { + it('maps categorical values deterministically through feature row indices', () => { + const colors = buildShapeFillColorByFeatureId({ + featureIds: ['cell-a', 'cell-b', 'cell-c', 'cell-d'], + rowIndexByFeatureIndex: new Int32Array([1, 0, 1, 2]), + column: ['type-x', 'type-y', 'type-z'], + mode: 'categorical', + alpha: 180, + }); + + expect(colors).toEqual({ + 'cell-a': [0, 0, 255, 180], + 'cell-b': [0, 255, 0, 180], + 'cell-c': [0, 0, 255, 180], + 'cell-d': [255, 0, 255, 180], + }); + }); + + it('auto-detects numeric values and uses a continuous ramp', () => { + expect(resolveShapeFillColorMode('auto', ['0', '5', '10'])).toBe('continuous'); + + const colors = buildShapeFillColorByFeatureId({ + featureIds: ['low', 'mid', 'high'], + rowIndexByFeatureIndex: new Int32Array([0, 1, 2]), + column: ['0', '5', '10'], + mode: 'auto', + alpha: 99, + }); + + expect(colors).toEqual({ + low: [0, 64, 255, 99], + mid: [128, 142, 128, 99], + high: [255, 220, 0, 99], + }); + }); + + it('handles large numeric columns without spreading values into the call stack', () => { + const count = 150_000; + const featureIds = Array.from({ length: count }, (_, index) => `cell-${index}`); + const rowIndexByFeatureIndex = Int32Array.from({ length: count }, (_, index) => index); + const column = Array.from({ length: count }, (_, index) => index); + + const colors = buildShapeFillColorByFeatureId({ + featureIds, + rowIndexByFeatureIndex, + column, + mode: 'continuous', + alpha: 180, + }); + + expect(colors['cell-0']).toEqual([0, 64, 255, 180]); + expect(colors[`cell-${count - 1}`]).toEqual([255, 220, 0, 180]); + }); + + it('omits missing, unmatched, and empty values so defaults can render', () => { + const colors = buildShapeFillColorByFeatureId({ + featureIds: ['empty', 'unmatched', 'nullish', 'present'], + rowIndexByFeatureIndex: new Int32Array([0, -1, 2, 1]), + column: ['', '5', null], + mode: 'auto', + alpha: 180, + }); + + expect(Object.keys(colors).sort()).toEqual(['present']); + }); + + it('prefers feature-id table row mappings when render data row indices are unavailable', () => { + const colors = buildShapeFillColorByFeatureId({ + featureIds: ['circle-a', 'circle-b'], + rowIndexByFeatureIndex: new Int32Array([-1, -1]), + rowIndexByFeatureId: new Map([ + ['circle-a', 1], + ['circle-b', 0], + ]), + column: ['type-x', 'type-y'], + mode: 'categorical', + alpha: 180, + }); + + expect(colors).toEqual({ + 'circle-a': [0, 0, 255, 180], + 'circle-b': [0, 255, 0, 180], + }); + }); + + it('prefers feature-index row alignment over colliding numeric feature ids', () => { + const colors = buildShapeFillColorByFeatureId({ + featureIds: ['0', '1', '2'], + rowIndexByFeatureIndex: new Int32Array([0, 1, 2]), + rowIndexByFeatureId: new Map([ + ['1', 0], + ['5', 1], + ['99', 2], + ]), + column: ['type-a', 'type-b', 'type-c'], + mode: 'categorical', + alpha: 180, + }); + + expect(colors).toEqual({ + '0': [0, 0, 255, 180], + '1': [0, 255, 0, 180], + '2': [255, 0, 255, 180], + }); + }); + + it('treats mixed values as categorical in auto mode', () => { + expect(resolveShapeFillColorMode('auto', ['1', 'tumour'])).toBe('categorical'); + }); +});