Skip to main content

Element Classes

Each element type in a SpatialData store is represented by a typed class that provides access to metadata, coordinate transformations, and data loading methods.

:::info Elements are loaded, not constructed

Elements are obtained by loading a store via readZarr(). You don't create element instances directly—they're populated from the store's metadata and provide a read-only interface to the underlying data.

const sdata = await readZarr('https://example.com/data.zarr');
const image = sdata.images?.['my_image']; // ImageElement from the store

:::

Common Properties

All element classes share these properties:

interface BaseElement {
readonly kind: ElementName; // 'images' | 'shapes' | 'labels' | 'points' | 'tables'
readonly key: string; // The element's name within its category
readonly url: string; // Full URL to the element's zarr group
readonly attrs: object; // Validated attributes (type varies by element)
}

Spatial Elements

Spatial elements (everything except tables) inherit from AbstractSpatialElement and provide coordinate transformation methods:

interface SpatialElement {
// Get all coordinate systems this element can transform to
readonly coordinateSystems: string[];

// Get transformation to a specific coordinate system (Result type)
getTransformation(toCoordinateSystem?: string): Result<BaseTransformation, CoordinateSystemNotFoundError>;

// Get all transformations as a Map
getAllTransformations(): Map<string, BaseTransformation>;
}

ImageElement

Images are multiscale raster data following the OME-NGFF specification.

const sdata = await readZarr('https://example.com/data.zarr');
const image = sdata.images?.['my_image'];

// Metadata access
image.attrs.multiscales[0].axes; // Axis definitions
image.channels; // Channel metadata from omero
image.ndim; // 2 or 3
image.isMultiscale; // true if multiple resolution levels
image.scaleLevels; // ['0', '1', '2', ...] paths to each level

// Spatial properties
image.spatialAxes; // Only space-type axes

// Get transformation including per-resolution-level transforms
const transforms = image.getTransformationForLevel(0, 'global');
// Returns { element: Result<BaseTransformation>, dataset: BaseTransformation }

Attributes Schema

type RasterAttrs = {
multiscales: Array<{
name?: string;
axes: Array<{ name: string; type: 'space' | 'time' | 'channel' }>;
datasets: Array<{
path: string;
coordinateTransformations?: CoordinateTransformation;
}>;
coordinateTransformations?: CoordinateTransformation;
}>;
omero?: {
channels: Array<{
window?: { start: number; end: number; min: number; max: number };
label?: string;
color?: string;
active?: boolean;
}>;
};
spatialdata_attrs?: { version: string };
};

LabelsElement

Labels are segmentation masks stored as OME-NGFF multiscale rasters, identical in structure to images.

Conceptually, labels and shapes are both ways of encoding spatial features. For labels, the picked feature identity will come from raster values such as ObjectID-style segment ids. For shapes, feature identity comes from the element's index / instance ids.

const labels = sdata.labels?.['my_segmentation'];

// Same properties as ImageElement
labels.scaleLevels;
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.

const shapes = sdata.shapes?.['cell_boundaries'];

// Load geometry data
const renderData = await shapes.loadRenderData(); // Auto-detects polygons, circles (cell_circles), points (xenium_landmarks)
const polygons = await shapes.loadPolygonShapes(); // For polygon geometries only
const circles = await shapes.loadCircleShapes(); // For circle/point geometries only
const featureIds = await shapes.loadFeatureIds();

// Access metadata
shapes.attrs.axes; // e.g., ['x', 'y']
shapes.attrs['encoding-type']; // e.g., 'ngff:shapes'
shapes.attrs.coordinateTransformations;

loadFeatureIds() is the method to use when you want to associate picked shapes with table rows.

Implementation note: ShapesElement.loadFeatureIds() currently relies on a lightly adapted Vitessce-derived loader under the hood. We intentionally allow newer ngff:shapes metadata revisions to use the same parquet-backed feature-id path rather than keying support to one exact version string.

Attributes Schema

type ShapesAttrs = {
'encoding-type'?: string;
axes?: string[];
coordinateTransformations?: CoordinateTransformation;
spatialdata_attrs?: { version: string };
};

PointsElement

Points represent point cloud data such as transcript locations.

const points = sdata.points?.['transcripts'];

// Access metadata
points.attrs.axes; // e.g., ['x', 'y']
points.attrs.coordinateTransformations;
points.coordinateSystems; // Available coordinate systems

TableElement

Tables are AnnData objects that can annotate spatial elements via region keys.

const table = sdata.tables?.['annotations'];

// Get as AnnData.js object for full access
const adata = await table.getAnnDataJS();

// Load row ids / selected obs columns through SpatialData.js loaders
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();

// Find tables that annotate a spatial element
const associated = sdata.getAssociatedTable('shapes', 'cell_boundaries');
const [tableName, associatedTable] = associated ?? [];

// Raw attrs are also available; association keys may be absent on some tables
table.attrs.instance_key; // Column name for feature IDs / instance IDs
table.attrs.region; // Element(s) this table annotates
table.attrs.region_key; // Column name linking to region

getTableKeys() always returns normalized keys: region is a string array, and when association metadata is missing region is [] while regionKey / instanceKey are empty strings. Prefer this method over reading table.attrs directly when matching tables to spatial elements. SpatialData.getAssociatedTables() / getAssociatedTable() use the same normalization and ignore tables without association metadata.

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:

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 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 AnnData access story here to grow beyond obs columns:

  • typed access to obs and var columns
  • access to selected X columns for chosen var rows
  • access to obsm entries
  • access to obsp entries for future graph/network-style visualisation
  • access to uns

In principle, the richer APIs here should probably live in anndata.js rather than becoming a permanently separate query surface in this package. If we find anndata.js is currently inadequate or awkward for these use cases, we should treat that as upstream pressure and try to clarify or improve it there. For now, the intent is still that SpatialData.js should expose coherent access patterns for these AnnData surfaces without forcing integrators to drop immediately to getAnnDataJS() or encode everything as ad hoc string column lookups. Value-to-string formatting should still remain at the rendering boundary rather than in the query layer.

Attributes Schema

type TableAttrs = {
instance_key?: string | null;
region?: string | string[] | null;
region_key?: string | null;
'spatialdata-encoding-type': 'ngff:regions_table';
};

Association metadata is optional at the schema level: some tables only carry the ngff:regions_table encoding marker, and real stores may persist missing keys as JSON null. Use getTableKeys() to read the normalized association contract rather than assuming all three keys are present strings.

Working with Coordinate Systems

All spatial elements share a common interface for coordinate transformations:

import { unwrap } from '@spatialdata/core';

const image = sdata.images?.['my_image'];

// Check available coordinate systems
console.log(image.coordinateSystems); // ['global', 'anatomical', ...]

// Get a specific transformation (returns Result type)
const result = image.getTransformation('global');

if (result.ok) {
const matrix = result.value.toMatrix(); // Matrix4 from @math.gl/core
const array = result.value.toArray(); // 16-element column-major array
} else {
console.log('Available:', result.error.availableCoordinateSystems);
}

// Or unwrap to throw on error
const transform = unwrap(image.getTransformation('global'));

// Get all transformations at once
const allTransforms = image.getAllTransformations();
for (const [csName, transform] of allTransforms) {
console.log(`${csName}:`, transform.toMatrix());
}