Skip to main content

Internals & Architecture

:::caution For Contributors & Advanced Users

This page documents internal implementation details of @spatialdata/core. These APIs are not part of the public interface and may change without notice.

If you're building an application with this library, you likely don't need anything on this page—see the other documentation sections instead.

:::

Module Architecture

The package is organized into these internal modules:

@spatialdata/core/
├── store/ # SpatialData class, readZarr entry point
│ ├── index.ts # Main store class
│ └── zarrUtils.ts # Zarr store parsing utilities
├── models/ # Element class implementations
│ ├── index.ts # Element classes and factory
│ ├── VShapesSource.ts # Shapes geometry loader
│ ├── VTableSource.ts # Table loader utilities
│ └── VZarrDataSource.ts # Zarr data source base
├── schemas/ # Zod validation schemas
│ └── index.ts # OME-NGFF and SpatialData schemas
├── transformations/
│ ├── transformations.ts # Transformation classes
│ └── operations.ts # Helper functions
└── types.ts # Core type definitions

Documented Divergences

Some internal loaders started from upstream Vitessce implementations but have already diverged in a few deliberate ways. We try to keep those divergences small, documented, and tied to higher-level API goals rather than ad hoc fixes.

VShapesSource.ts

VShapesSource.ts began as a Vitessce-derived shapes loader. The current intentional divergences are:

  • TypeScript cleanup and normalization.
  • Modern ngff:shapes metadata revisions are routed through the same parquet-backed path as the older supported modern format, instead of requiring one exact version string.

Why this exists:

  • ShapesElement.loadFeatureIds() is part of the high-level feature-association story used by vis tooltip / picked-feature workflows.
  • In practice, stable feature-id loading is the behavior we need to preserve, even when upstream metadata version strings move forward.

Planned evolution:

  • keep VShapesSource.ts narrowly scoped to geometry / feature-id loading
  • review how that relates to use of e.g. https://github.com/geoarrow/deck.gl-layers/ in future
  • avoid pushing more vis policy into this file
  • if the divergence grows, prefer moving compatibility logic into a more explicitly SpatialData.js-owned layer rather than silently accumulating it here

Table Access Helpers

TableElement.getAnnDataJS() is still exposed, but feature-association helpers such as loadObsIndex() and loadObsColumns() now prefer the direct zarr/parquet loader path in VTableSource.ts.

Why this exists:

  • these reads sit on the hot path for table-to-spatial-feature association
  • we want them to remain usable even when wrapper libraries lag behind the relevant zarr/string-dtype support

Planned evolution:

  • add clearer typed dataframe-column accessors for general table use
  • 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:

// Internal - do not use directly
import { createElement, loadElements } from '@spatialdata/core';

// createElement instantiates a single element from parsed metadata
const element = createElement('images', sdataProps, 'my_image');

// loadElements creates all elements of a type from the parsed store
const allImages = loadElements(sdataProps, 'images');

Application code should always obtain elements via readZarr():

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

Transformation Parsing

The transformation system parses NGFF coordinate transformations from zarr metadata into typed classes:

parseTransformEntry

Parses a single transformation object from metadata:

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

const entry = { type: 'scale', scale: [0.5, 0.5] };
const transform = parseTransformEntry(entry);
// Returns: Scale { scale: [0.5, 0.5] }

parseTransforms

Parses an array of transformations, returning a single BaseTransformation:

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

const transforms = parseTransforms([
{ type: 'scale', scale: [0.5, 0.5] },
{ type: 'translation', translation: [1000, 2000] },
]);
// Returns: Sequence containing Scale and Translation

buildMatrix4FromTransforms

Convenience function to get a Matrix4 directly from metadata:

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

const matrix = buildMatrix4FromTransforms([
{ type: 'scale', scale: [2, 2] },
]);

composeTransforms

Composes element-level and dataset-level transforms:

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

const fullMatrix = composeTransforms(elementTransforms, datasetTransforms);

Transformation Classes

These classes represent parsed transformations internally. They're returned by element methods but shouldn't be constructed directly in application code:

ClassConstructorDescription
Identitynew Identity(input?, output?)Identity matrix
Scalenew Scale(scale[], input?, output?)Scale transform
Translationnew Translation(translation[], input?, output?)Translation
Affinenew Affine(matrix[][], input?, output?)Full affine
Sequencenew Sequence(transforms[], input?, output?)Composed transforms
Rotationnew Rotation(matrix[][], input?, output?)Rotation matrix (square, no translation)
MapAxisnew MapAxis(mapAxis[], input?, output?)Axis remapping/permutation

Not yet implemented: byDimension, bijection, displacements, and affine/rotation "by path" (parameters stored in an external zarr array). See Coordinate Transformations for the current support summary, and dev_scripts/conformance-dingus/ for the RFC-5 conformance harness that tracks this backlog.

All classes extend BaseTransformation and provide:

abstract class BaseTransformation {
readonly input?: CoordinateSystemRef;
readonly output?: CoordinateSystemRef;

abstract toArray(): number[]; // 16-element column-major
toMatrix(): Matrix4; // Matrix4 from @math.gl/core
inverse(): Matrix4; // Inverted Matrix4; throws if singular
abstract get type(): string;
}

Type Utilities

Type aliases for working with elements generically:

import type {
ElementInstanceMap, // Maps element names to class types
SpatialElement, // Union of spatial element types
AnyElement, // Union of all element types
ElementName, // 'images' | 'shapes' | 'labels' | 'points' | 'tables'
} from '@spatialdata/core';

Zod Schemas

Validation schemas for metadata are defined in schemas/index.ts:

  • rasterAttrsSchema - Image/Labels OME-NGFF metadata
  • shapesAttrsSchema - Shapes element metadata
  • pointsAttrsSchema - Points element metadata
  • tableAttrsSchema - AnnData table metadata
  • coordinateTransformationSchema - Transformation arrays
  • spatialDataSchema - Root store metadata

These are used internally during element construction to validate and type metadata.

Store Parsing

Parsing zarr store contents lives in zarrextra, not in core:

  • openExtraConsolidated(source) - Resolves consolidated metadata and builds the ZarrTree, returning a Result
  • serializeZarrTree(tree) - Serializes for JSON output, converting the symbol-keyed attributes and array metadata to string keys

Element construction walks that tree with the guards and accessors described in tree nodesAbstractElement narrows an element's own node to a group once, so no element subclass has to discriminate group from array itself.

Result Type Implementation

The Result type and utilities are implemented in zarrextra and re-exported from @spatialdata/core for convenience:

// In zarrextra
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };

const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
const isOk = <T, E>(result: Result<T, E>): result is { ok: true; value: T } => result.ok;
const isErr = <T, E>(result: Result<T, E>): result is { ok: false; error: E } => !result.ok;
const unwrap = <T, E>(result: Result<T, E>): T => { ... };
const unwrapOr = <T, E>(result: Result<T, E>, defaultValue: T): T => { ... };

You can import Result and its utilities from either @spatialdata/core or zarrextra.

:::note Future considerations

This is currently a custom implementation for simplicity and to avoid dependencies. We may review using an existing Result library (such as neverthrow or ts-result) in the future, but for now this provides a lightweight, dependency-free solution.

:::