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 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
Use Python spatialdata as the source of truth for association semantics. In
particular, mirror the upstream scverse/spatialdata implementation before
adding TypeScript-specific shortcuts:
-
Python source:
join_spatialelement_tabledocuments and implements element/table matching. Its docstring says matching is determined from the SpatialElement index plus the tableregion_keyandinstance_keycolumns. -
Python source:
get_element_instancesdefines what "instance" means for each element kind: shapes use theGeoDataFrame.index; labels use the unique label values, excluding background0unless requested. -
Python docs: Working with annotations in SpatialData states that
region,region_key, andinstance_keyare the table annotation metadata, and explicitly warns that the table index is not used for annotation matching. -
Python design doc: Table annotations for regions describes
region,region_key, andinstance_keyas the required metadata for mapping a table to spatial regions. -
Tables annotate regions through the
region,region_key,instance_keytriplet.region_keyidentifies which spatial element a row annotates, andinstance_keyidentifies which instance within that element the row annotates. -
Shapes are GeoDataFrames. Their semantic feature ids are the GeoDataFrame index values, and extra GeoDataFrame columns are valid shape annotations. The table
instance_keyshould match those shape index values; row order is not the association contract. -
Labels instances are raster label values, excluding background. Table
instance_keyvalues 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.
Current API shape
@spatialdata/core exposes FeatureTableAlignment and
createFeatureTableAlignment() as the shared row resolver shape for regions:
type FeatureTableAlignment = {
rowIndexByFeatureIndex: Int32Array;
rowIndexByFeatureId?: Map<string, number>;
resolveRowIndex(feature: {
featureId: string;
featureIndex: number;
rowIndex?: number;
}): number | undefined;
};
The exact type may still evolve as labels and annotation-column discovery
mature, but the principle should not: tooltip resolution, click/hover events,
table-driven fill colour, filtering, and downstream applications should all use
one shared resolver shape. Avoid adding one-off helpers such as
resolveShapeFillColorRowIndex in SpatialCanvas or @spatialdata/layers.
The resolver should:
- Be built from association metadata loaded from
region,region_key, andinstance_key. - Filter rows by the target region/element before building row mappings.
- Match table rows to canonical feature ids, not to render order, except for documented compatibility fallbacks.
- 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.
@spatialdata/layers colour encoders intentionally do not decide
feature-to-row precedence. They consume rowIndexByFeatureIndex that has
already been resolved by @spatialdata/core.
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. These encoders receive resolved row alignment; they do not interpret SpatialData table association semantics.
@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_keyandregion_key. - Extra annotation columns stored directly on shape elements.
- Future entries corresponding to
varsinX/layersfor 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 and label fill colour can be driven by a chosen table column,
through the same
fillColorByColumn/featureStateAPI on both kinds. - Tooltips can aggregate multiple visible layers under the cursor.
- Multiple layer configs may represent the same element with different visual properties.
The reusable foundation is partially in place:
@spatialdata/coreexposesFeatureTableAlignment/createFeatureTableAlignment(), with tests for feature-index precedence, feature-id fallback, and unresolved features.@spatialdata/layersowns the reusable column-to-colour encoders. The shared core (featureColorEncoding) holds the mode decision, palettes, ramps and missing-value policy;shapeColorEncodingandlabelColorEncodingare the per-kind surfaces over it. Both consume resolvedrowIndexByFeatureIndexrather than deciding feature/table association locally.@spatialdata/corenow reports aTableColumnKindper obs column (TableElement.getObsColumnKinds()), somode: 'auto'can trust what a column is rather than inferring it from decoded values.@spatialdata/visremains 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:
- Finish routing tooltip and pick-event row resolution through the shared core resolver shape.
Add labels-side association coverage using PythonDone 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.spatialdatasemantics for non-background label values.- Add annotation-column discovery for direct shape annotations and future 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.)
- Keep
SpatialCanvasas the consumer of these utilities, not the owner of semantic association rules. - Add fixture coverage for non-matching row order, missing rows, mixed-region tables, labels values, and shape annotation columns.