Skip to main content

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_table documents and implements element/table matching. Its docstring says matching is determined from the SpatialElement index plus the table region_key and instance_key columns.

  • Python source: get_element_instances defines what "instance" means for each element kind: shapes use the GeoDataFrame.index; labels use the unique label values, excluding background 0 unless requested.

  • Python docs: Working with annotations in SpatialData states that region, region_key, and instance_key are 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, and instance_key as the required metadata for mapping a table to spatial regions.

  • 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.

  • 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.

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, and instance_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_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 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.

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 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. 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. (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 tables, labels values, and shape annotation columns.