Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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`.
55 changes: 55 additions & 0 deletions docs/docs/core/elements.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: '<fixed-region-name>'` 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
Expand Down
26 changes: 26 additions & 0 deletions docs/docs/core/internals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>/<id>` and `shapes/<key>/<id>`). 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:
Expand Down
98 changes: 98 additions & 0 deletions docs/docs/vis/layer-prop-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 46 additions & 1 deletion packages/core/src/tableAssociations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>
): 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' &&
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpatialFeatureTooltipData, 'title' | 'items'>,
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[];
Expand Down
Loading
Loading