Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 25 additions & 0 deletions .changeset/column-kinds-and-missing-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@spatialdata/core': minor
'@spatialdata/layers': minor
'@spatialdata/vis': minor
---

Decide continuous vs categorical from the column's declared kind, and let callers
configure missing values.

`TableElement.getObsColumnKinds` reports what the store says each obs column is —
`numeric`, `categorical`, `string` or `boolean` — and `loadAssociatedTableFeatureRows`
carries it alongside the values as `extraColumnKinds`. It is **synchronous**: opening a
store already reads every node's attributes and array metadata into the tree, so a caller
can ask what a column is before deciding whether to load it. Both zarr generations are
read (v3 `data_type`, v2 numpy typestrings). `'auto'` mode now trusts that in
preference to sniffing stringified values, which was wrong at both edges: one `NaN` made a
float column look non-numeric, and integer cluster codes looked like a continuum. Value
sniffing remains only as the fallback when no kind is available.

`fillColorByColumn.missingValues` configures the rest: `treatAsMissing` adds
store-specific sentinel strings (`'NA'`, `'unknown'`, …) that only the caller can
recognise, and `render` chooses whether a feature with no value keeps the layer default,
is hidden, or takes an explicit colour. `null` and `NaN` are always missing and are not
configurable. Sentinels are excluded before the mode decision, the numeric extent and the
category set, so a sentinel never becomes a category or drags a ramp.
24 changes: 24 additions & 0 deletions .changeset/feature-color-buffer-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@spatialdata/layers': minor
'@spatialdata/vis': minor
---

Let a host hand in precomputed feature colours as a buffer, for shapes and labels.

`SpatialCanvasViewer` takes a `featureColorResolver` — a runtime attachment alongside
`hostLayerResolver` / `vivImagePropsResolver` — returning a `FeatureColorBuffer`
(`{ colors: Uint8Array; count: number }`) for a layer. Use it when colour comes from data
a config cannot carry: a computed column, an annotation from outside the table, a live
selection.

Previously the only route was `featureState.fillColorByFeatureId`, which makes the host
stringify integers it already had and costs a Map copy plus (for labels) a parse per
entry, all to produce the buffer the renderer wanted anyway.

The index means different things per kind, and the resolver context says which: for labels
it is the raster's own pixel value; for shapes it is the position in the loaded geometry,
so the context supplies the `featureIds` ordering to build against. A buffer wins over
`featureState` rather than merging with it — bake hide and fade into the alpha.

Also: `LabelColorLut` is now `FeatureColorBuffer` (`labelCount` → `count`) so both kinds
share one currency.
17 changes: 17 additions & 0 deletions .changeset/feature-colour-schemes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@spatialdata/layers': minor
'@spatialdata/vis': minor
---

Let `fillColorByColumn` carry a colour scheme, and default categorical colouring to the
unbounded OkLab scheme.

`fillColorByColumn` on both shapes and labels layers now takes `categoricalPalette`
(`'oklab'`, or your own RGB list, which cycles) and `numericRamp`. Both are
JSON-serializable, so they survive a saved Render Stack.

**Behaviour change:** the categorical default is now `'oklab'` — the same golden-angle
OKLCh scheme `@spatialdata/layers` already used for points colour-by-feature. The previous
six-colour palette cycled, so a column with more than six categories silently drew two
categories in the same colour; the OkLab scheme is a pure function of the category index
and has no length. Pass an explicit RGB list to pin specific colours.
21 changes: 21 additions & 0 deletions .changeset/labels-feature-filtering-and-colouring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@spatialdata/layers': minor
'@spatialdata/vis': minor
---

Filter and colour annotated labels elements with the same API as shapes.

A labels layer now accepts `fillColorByColumn` (colour every label by an obs column of
its associated table) and a `featureState` with the same fields and meanings a shapes
layer's takes — `fillColorByFeatureId`, `hiddenFeatureIds`, `fadedFeatureIds`,
`filteredOpacityMultiplier` — keyed by the label's integer instance id as a string.

The mechanism mirrors shapes: the palette, numeric ramp and `'auto'` mode detection are
now shared (`featureColorEncoding`), so the same column reads the same way on a shapes
layer and on a labels layer over the same table. Where a shape resolves its colour from a
per-feature texture indexed by feature index, a label resolves its colour from a
per-label lookup table indexed by the raster's own pixel value, sampled in the bitmask
fragment shader. Hidden labels are discarded, faded labels scale the channel's fill and
outline opacities, and a hidden label is no longer pickable. The lookup table is owned by
`LabelsLayer` and shared across tile sublayers, and is re-uploaded only when the
feature-state it encodes actually changes.
15 changes: 15 additions & 0 deletions .changeset/labels-sublayer-feature-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@spatialdata/core': minor
'@spatialdata/layers': minor
---

Add `featureState` to the labels sublayer schema, and export `SpatialLabelsSublayer`.

`spatialLabelsSublayerSchema` now carries `fillColorByFeatureId`, `hiddenFeatureIds`,
`fadedFeatureIds` and `filteredOpacityMultiplier` — the same field names and meanings
`spatialShapesSublayerSchema` already had, keyed by the label's integer instance id as a
string. It omits `strokeColorByFeatureId`: a label's outline is derived from its fill in
the bitmask shader, so there is no per-label stroke to override.

This closes the last place where a labels layer could not express what a shapes layer
could.
8 changes: 8 additions & 0 deletions .changeset/luma-core-dependency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@spatialdata/layers': patch
---

Depend on `@luma.gl/core` directly, so GPU resource types come from the library rather
than being restated locally. `LabelsLayer` now types its LUT texture as luma's `Texture`;
the package already depends on `@luma.gl/engine` and cannot realistically be used without
luma core.
14 changes: 14 additions & 0 deletions .changeset/nan-is-a-missing-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@spatialdata/layers': patch
---

Fix numeric columns being coloured as categorical when they contain `NaN`.

`'auto'` mode asks whether every non-empty value parses as a finite number, and a
non-finite number stringified to `"NaN"` — a non-empty value that does not parse. So a
single failed embedding in a `UMAP1` column made the whole column categorical, and
categorical mode then gave every distinct float its own colour.

A non-finite `number` now normalises as missing, the way `null` already did: it does not
influence the mode, and the cell keeps the layer's default colour. The *string* `"NaN"` in
a string column is untouched — there it may be a real category.
16 changes: 16 additions & 0 deletions docs/docs/core/elements.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ const adata = await table.getAnnDataJS();
const rowIds = await table.loadObsIndex();
const regionColumns = await table.loadObsColumns(['region']);

// What those columns ARE, without loading their values. Synchronous: opening the
// store already read the metadata this is derived from.
const kinds = table.getObsColumnKinds(['leiden', 'area']);
// -> ['categorical', 'numeric'] (also 'string' | 'boolean', or undefined if unknown)

// Normalized association metadata (Python `get_table_keys()` equivalent)
const { region, regionKey, instanceKey } = table.getTableKeys();

Expand Down Expand Up @@ -260,6 +265,17 @@ helpers and currently stay on the direct zarr/parquet loader path rather than
depending on `anndata.js`. `getAnnDataJS()` remains available for higher-level
AnnData access.

`getObsColumnKinds()` is deliberately **synchronous**. Opening a store already
reads every node's attributes and array metadata into the tree — the same tree
`getObsColumnNames()` reads names from — so the `encoding-type` that separates
an AnnData categorical from a string array, and the dtype that separates a float
from a bool, are in memory before anyone asks for values. That lets a caller ask
what a column *is* before deciding whether to load it, which is what a
"colour by" UI needs in order to offer the right affordance up front. The
classifier reads both zarr generations (v3 spells dtypes out, v2 uses numpy
typestrings). Kinds are best-effort: a source that cannot report one yields
`undefined` and consumers fall back to inspecting values.

Planned API evolution:
the current `loadObsColumns()` helper is optimized for association / tooltip
lookups and should be treated as a convenience API. We expect the coherent
Expand Down
19 changes: 19 additions & 0 deletions docs/docs/layers/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ See also the [visualization overview](../vis/overview): deck-only integrators ca

For shapes, the important contract is stable feature identity plus table-join-driven styling/filtering. `@spatialdata/core` loads render-oriented shape data, `@spatialdata/layers` turns that into deck layers, and `@spatialdata/vis` consumes the shared behavior for viewer use.

**Labels** now follow that same contract. The colour-encoding core is shared
rather than duplicated per kind:

- `featureColorEncoding` holds what is common — the `auto`/`categorical`/
`continuous` mode decision, `resolveCategoricalPalette()`, numeric ramps, and
the missing-value policy — plus `FeatureColorBuffer`, the precomputed-RGBA
currency both kinds render from.
- `shapeColorEncoding` and `labelColorEncoding` are the thin per-kind surfaces:
`buildShapeFillColorByFeatureId()` and `buildLabelFillColorByFeatureId()`.
- Shapes index colour by feature index; labels have no geometry, so
`buildLabelColorLut()` produces a lookup table indexed by the label's own
integer instance id that the bitmask fragment shader samples. Either way a
feature-state change re-uploads only the small table, never the tiles.

The default categorical palette is `DEFAULT_FEATURE_CATEGORICAL_PALETTE`
(`'oklab'`), whose colour is a pure function of the category index and therefore
cannot repeat — see the
[behaviour-change note](../vis/headless-viewer#choosing-the-colour-scheme).

Points should follow the same package split before we make a GeoArrow migration
load-bearing:

Expand Down
34 changes: 24 additions & 10 deletions docs/docs/vis/feature-table-associations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ sidebar_position: 6

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,
and this page tracks which parts have moved into reusable core/layer utilities
and which parts are still demo-facing.
demo supports table-driven fill colour for **both shapes and labels**, plus
aggregated feature tooltips, and this page tracks which parts have moved into
reusable core/layer utilities and which parts are still demo-facing.

## Reference Semantics

Expand Down Expand Up @@ -140,7 +140,8 @@ obs, a GeoDataFrame column, or a matrix-backed feature.

The current `SpatialCanvas` behaviour is acceptable as demo functionality:

- Shape fill colour can be driven by a chosen table column.
- Shape **and label** fill colour can be driven by a chosen table column,
through the same `fillColorByColumn` / `featureState` API on both kinds.
- Tooltips can aggregate multiple visible layers under the cursor.
- Multiple layer configs may represent the same element with different visual
properties.
Expand All @@ -150,22 +151,35 @@ The reusable foundation is partially in place:
- `@spatialdata/core` exposes `FeatureTableAlignment` /
`createFeatureTableAlignment()`, with tests for feature-index precedence,
feature-id fallback, and unresolved features.
- `@spatialdata/layers` owns the reusable shape column-to-colour encoder, and
that encoder consumes resolved `rowIndexByFeatureIndex` rather than deciding
feature/table association locally.
- `@spatialdata/layers` owns the reusable column-to-colour encoders. The shared
core (`featureColorEncoding`) holds the mode decision, palettes, ramps and
missing-value policy; `shapeColorEncoding` and `labelColorEncoding` are the
per-kind surfaces over it. Both consume resolved `rowIndexByFeatureIndex`
rather than deciding feature/table association locally.
- `@spatialdata/core` now reports a `TableColumnKind` per obs column
(`TableElement.getObsColumnKinds()`), so `mode: 'auto'` can trust what a
column *is* rather than inferring it from decoded values.
- `@spatialdata/vis` remains the UI/producer layer: it chooses the column, loads
the associated table column through core helpers, and passes resolved
feature-state into deck layers.

Labels use the label's integer instance id as the feature id, consistent with
Python `get_element_instances`; background `0` is never drawn.

Before publishing a stable library-facing API, revisit the implementation with
this checklist:

1. Finish routing tooltip and pick-event row resolution through the shared core
resolver shape.
2. Add labels-side association coverage using Python `spatialdata` semantics
for non-background label values.
2. ~~Add labels-side association coverage using Python `spatialdata` semantics
for non-background label values.~~ Done for the colour/filter encode path:
labels resolve per-feature colour through a label-id-indexed LUT keyed by
instance id, excluding background. Row-resolution parity with shapes is
still item 1.
3. Add annotation-column discovery for direct shape annotations and future
matrix-backed values.
matrix-backed values. *(Partly advanced: obs columns now report their kind,
which is the discovery metadata a "colour by" UI needs to offer the right
affordance. Direct shape annotations and matrix-backed values remain.)*
4. Keep `SpatialCanvas` as the consumer of these utilities, not the owner of
semantic association rules.
5. Add fixture coverage for non-matching row order, missing rows, mixed-region
Expand Down
Loading
Loading