diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bceb319e..60b3ef30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,19 +72,19 @@ jobs: # Wait for server to be ready (up to ~30s) # Check both root and a fixture path to ensure server is fully ready for i in {1..30}; do - if curl -sSf http://localhost:8080/ >/dev/null && \ - curl -sSf http://localhost:8080/v0.5.0/blobs.zarr/zmetadata >/dev/null; then + if curl -sSf http://localhost:38473/ >/dev/null && \ + curl -sSf http://localhost:38473/v0.5.0/blobs.zarr/zmetadata >/dev/null; then echo "Test server is up and serving fixtures" break fi - echo "Waiting for test server on http://localhost:8080/ ..." + echo "Waiting for test server on http://localhost:38473/ ..." sleep 1 done # Small additional delay to ensure server is fully ready sleep 2 - # Run integration tests (will hit http://localhost:8080/…) + # Run integration tests (will hit http://localhost:38473/…) pnpm test:integration # Clean up server diff --git a/.vscode/settings.json b/.vscode/settings.json index 481d1a4a..f153c808 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,8 @@ "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true }, - "python.analysis.extraPaths": ["${workspaceFolder}/python"] + "python.analysis.extraPaths": [ + "${workspaceFolder}/python" + ], + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/README.md b/README.md index 7b5243f6..00a7060c 100644 --- a/README.md +++ b/README.md @@ -195,14 +195,16 @@ pnpm test:fixtures:generate:0.7.2 The main Node integration tests now load fixtures directly from a `FileSystemStore`. This server is still useful for HTTP smoke tests and browser-oriented local development with `FetchStore`: ```bash -# Start the test fixture server (runs on http://localhost:8080) +# Start the test fixture server (default http://localhost:38473) pnpm test:server ``` +Override the port with `SPATIALDATA_FIXTURE_PORT` if needed. + Once running, fixtures are accessible at: -- `http://localhost:8080/test-fixtures/v0.5.0/blobs.zarr` -- `http://localhost:8080/test-fixtures/v0.6.1/blobs.zarr` -- `http://localhost:8080/test-fixtures/v0.7.2/blobs.zarr` +- `http://localhost:38473/test-fixtures/v0.5.0/blobs.zarr` +- `http://localhost:38473/test-fixtures/v0.6.1/blobs.zarr` +- `http://localhost:38473/test-fixtures/v0.7.2/blobs.zarr` The server provides directory listings and serves all zarr metadata files with appropriate CORS headers. diff --git a/docs/docs/core/overview.mdx b/docs/docs/core/overview.mdx index 34abfd7d..69f383e7 100644 --- a/docs/docs/core/overview.mdx +++ b/docs/docs/core/overview.mdx @@ -29,7 +29,7 @@ pnpm add @spatialdata/core ## Quick Start ```ts -import { readZarr } from '@spatialdata/core'; +import { readZarr, loadFeatureRowIndexByFeatureIndex } from '@spatialdata/core'; // Load a SpatialData store from a URL const sdata = await readZarr('https://example.com/my-spatialdata.zarr'); @@ -46,8 +46,23 @@ const points = sdata.points; // Record // Get coordinate systems const coordinateSystems = sdata.coordinateSystems; // ['global', ...] + +// Table-backed shape association (see also @spatialdata/layers encoders) +const shapesEl = sdata.shapes.cell_shapes; +const renderData = await shapesEl.loadRenderData(); +const rowIndexByFeatureIndex = await loadFeatureRowIndexByFeatureIndex({ + spatialData: sdata, + kind: 'shapes', + key: 'cell_shapes', + featureIds: renderData.featureIds, +}); ``` +Tables load through **`anndata.js`** on **`zarrita`** stores. Association helpers +today use targeted obs-column loaders; the roadmap is a unified AnnData.js / +zarrita **`DataLoader`** surface for richer `obs` / `var` / `X` / `obsm` access +(see [MDV integration — tables](../vis/mdv-integration#tables-anndatajs-and-zarrita-dataloader)). + ## Design Philosophy The library follows several key principles: @@ -75,6 +90,13 @@ The main entry points for application code are: | Element classes | `ImageElement`, `ShapesElement`, `LabelsElement`, `PointsElement`, `TableElement` | | `Result` utilities | `Ok`, `Err`, `isOk`, `isErr`, `unwrap`, `unwrapOr` | | `getTransformMatrix()` | Convenience function for getting Matrix4 transforms | +| Table association helpers | `loadAssociatedTableFeatureRows`, `loadFeatureRowIndexByFeatureIndex`, `createFeatureTableAlignment` | + +The table association helpers follow Python `spatialdata` semantics: regions +are matched through `region`, `region_key`, and `instance_key`, with feature ids +coming from SpatialElement instances such as `GeoDataFrame.index` for shapes. +Visual encoders in `@spatialdata/layers` consume the row alignment produced +here rather than reimplementing association rules. For internal architecture details, module organization, and APIs intended for contributors or advanced tooling, see [Internals & Architecture](./internals). @@ -101,4 +123,3 @@ These have coordinate transformations and can be rendered in shared coordinate s - [Transformations](./transformations) - Understanding coordinate systems and transforms - [Error Handling](./error-handling) - Using the Result type pattern - [Internals](./internals) - Architecture and internal APIs (for contributors) - diff --git a/docs/docs/intro.mdx b/docs/docs/intro.mdx index 6a9cee86..cadd2c3c 100644 --- a/docs/docs/intro.mdx +++ b/docs/docs/intro.mdx @@ -12,31 +12,162 @@ The approach taken to the design is to follow the structure of the original libr There are several packages that aim to facilitate the use and development of this functionality: -## `@spatialdata/core` +## Package map -This is a vanilla-JS library that provides a reasonably lightweight, clean and efficient way of loading data from SpatialData stores, reflecting the functionality of the Python library. +| Package | Role | React? | npm status | +|---------|------|--------|------------| +| `@spatialdata/core` | Load SpatialData Zarr stores; elements, transforms, table association | No | Not published yet | +| `@spatialdata/react` | Thin hooks around core (`useSpatialData`, provider) | Yes | Not published yet | +| `@spatialdata/layers` | deck.gl layers, shape/point renderers, versioned layer props | No | Not published yet | +| `@spatialdata/avivatorish` | Viv loaders, channel stats, image Zustand stores | No | Not published yet | +| `@spatialdata/vis` | `SpatialCanvas` UI + headless `SpatialCanvasViewer` | Yes | Not published yet; MDV-targeted prerelease in progress | -See the [Core Package documentation](./core/overview) for detailed API documentation, including: -- [Element Classes](./core/elements) - Working with images, shapes, labels, points, and tables -- [Transformations](./core/transformations) - Coordinate systems and spatial transforms -- [Error Handling](./core/error-handling) - The Result type pattern -- [Internals](./core/internals) - Architecture and internal APIs (for contributors) +Nothing is on npm yet. Clone the monorepo or `pnpm pack` workspace packages for local integration (see [Headless viewer guide](./vis/headless-viewer)). +## `@spatialdata/core` -## `@spatialdata/react` +Vanilla-JS library for loading SpatialData Zarr stores. Mirrors the Python +`spatialdata` API where practical: elements, coordinate systems, lazy data +loads, and table–feature association (`region` / `region_key` / `instance_key`). + +Built on **`zarrita`** for Zarr I/O and **`anndata.js`** for AnnData-backed +tables. Table obs-column helpers used today (`loadObsColumns`, association +resolvers) sit on a direct loader path; the roadmap is a coherent +**`TableElement` → AnnData.js / zarrita `DataLoader`** surface for `obs`, +`var`, `X`, `obsm`, and related AnnData views without every integrator calling +`getAnnDataJS()` directly. + +See [Core Package documentation](./core/overview): +- [Element Classes](./core/elements) — images, shapes, labels, points, tables +- [Transformations](./core/transformations) — coordinate systems and spatial transforms +- [Error Handling](./core/error-handling) — the `Result` type pattern +- [Internals](./core/internals) — architecture (for contributors) + +### Core-only example + +```ts +import { + readZarr, + createFeatureTableAlignment, + loadFeatureRowIndexByFeatureIndex, +} from '@spatialdata/core'; + +const sdata = await readZarr('https://example.com/data.zarr'); + +// Load shape geometry + stable feature ids (async per element) +const shapesEl = sdata.shapes.cell_shapes; +const renderData = await shapesEl.loadRenderData(); + +// Join shapes to an associated AnnData table row index +const rowIndexByFeatureIndex = await loadFeatureRowIndexByFeatureIndex({ + spatialData: sdata, + kind: 'shapes', + key: 'cell_shapes', + featureIds: renderData.featureIds, +}); +const alignment = createFeatureTableAlignment({ rowIndexByFeatureIndex }); + +// alignment.resolveRowIndex({ featureId, featureIndex }) → table row or undefined +``` + +## `@spatialdata/layers` + +**deck.gl–native, React-free** rendering package. This is the layer between +`core` data and any viewer (custom `DeckGL`, MDV, Vitessce, or `@spatialdata/vis`). + +What it owns today: + +- **`createShapesDeckLayer`** / **`buildShapesPrebuiltData`** — polygon, circle, and GeoArrow-table shape paths with feature-state styling (hide, fade, per-id colours) +- **`buildShapeFillColorByFeatureId`** — table column → per-feature colours (consumes core row alignment; does not resolve associations itself) +- **`SpatialLayer`** + **`spatialLayerPropsSchema`** / **`migrateSpatialLayerProps`** — versioned, JSON-serializable composite-layer contract (image/points sublayers still maturing) +- Pick/tooltip helpers keyed by stable **`featureId`** + +`@spatialdata/vis` calls into `@spatialdata/layers` for shapes; MDV can drive +the same contracts by updating layer config / feature state without importing vis +internals. + +See [Layers package overview](./layers/overview). + +### React-agnostic deck example + +```ts +import { createShapesDeckLayer, buildShapesPrebuiltData } from '@spatialdata/layers'; +import { Deck } from '@deck.gl/core'; + +// renderData from core (see above) +const prebuilt = buildShapesPrebuiltData(renderData); +const shapesLayer = createShapesDeckLayer( + renderData, + { + kind: 'shapes', + elementKey: 'cell_shapes', + visible: true, + defaultFillColor: [70, 130, 180, 200], + featureState: { hiddenFeatureIds: ['42'] }, + }, + { id: 'my-shapes', prebuilt } +); + +const deck = new Deck({ + canvas: document.getElementById('deck-canvas'), + initialViewState: { longitude: 0, latitude: 0, zoom: 1 }, + layers: shapesLayer ? [shapesLayer] : [], +}); +``` -React hooks for SpatialData. This package should have a minimal set of dependencies, such that it should be easy to integrate into other React applications. It is mostly focused on providing react-idiomatic ways of accessing the data itself, with appropriate abstractions around the core vanilla API, managing the `async` nature of fetching data etc. +## `@spatialdata/react` +Minimal React hooks for SpatialData: `SpatialDataProvider` + `useSpatialData` +wrap `readZarr` and expose `{ spatialData, loading, error }`. No deck/Viv +dependencies — use with `@spatialdata/vis` or your own renderer. ## `@spatialdata/vis` -High-level React-components and deck.gl layers for visualising SpatialData. This package is explicitly less stable and more experimental than the related `core` and `react` packages - the dependency on the `viv`/`deck.gl`/`luma.gl` stack means that installing it into an app (particularly one that already uses anything from this ecosystem) entails a certain amount of care around potential conflicts or breaking changes between versions. +React components and the SpatialCanvas stack (Viv images + deck vectors). +**2D only today** — orthographic pan/zoom via Viv `DetailView`; no 3D orbit or volume rendering. + +**Two entry modes:** + +1. **Batteries included** — `SpatialCanvas` with coordinate-system picker, layer list, properties panels, tooltips. +2. **Headless / controlled** — `SpatialCanvasViewer` or `useSpatialCanvasRenderer`: same render path, **your** state for `layers`, `layerOrder`, `viewState`, plus optional `deckLayers` for app overlays. + +The vis package is more experimental than `core`/`layers` because of the +Viv/deck/luma dependency stack. The near-term goal is an MDV-consumable +prerelease with headless embedding first; API stability guarantees come after +that smoke test. + +- [Headless viewer guide](./vis/headless-viewer) — practical guide for controlled / MDV-style embedding +- [SpatialCanvas status and roadmap](./vis/spatial-canvas-status) — what works, known gaps +- [Visualization overview](./vis/overview) — package topology (`vis`, `layers`, `avivatorish`) +- [MDV integration](./vis/mdv-integration) — phased MDV/Vitessce alignment +- [MDV release checklist](./vis/mdv-release-checklist) — branch shipping criteria + +### Headless vis example + +```tsx +import { SpatialCanvasViewer, type LayerConfig } from '@spatialdata/vis'; + + +``` + +### Full UI example -It is hoped that it will be robust and of a high quality, but especially at the early stage of development, the goal is not to have a lean bundle size, guarantees of API stability between releases, etc. +```tsx +import { SpatialCanvas } from '@spatialdata/vis'; -It is used to provide working examples for displaying in the `docs` site, as well as a sample app primarily for prototyping while developing features. + +``` -- [SpatialCanvas + images — status and roadmap](./vis/spatial-canvas-status) — what works today, known gaps, and planned work -- [Visualization package overview](./vis/overview) — packages topology and direction (`vis`, `layers`, `avivatorish`) -- [Layers package overview](./layers/overview) — `SpatialLayer`, versioned `SpatialLayerProps`, migrations -- [MDV integration notes](./vis/mdv-integration) — how this repo aligns with MDV over time \ No newline at end of file +`SpatialCanvas` manages its own zustand store (layer visibility, order, channel +UI). Use it for demos and exploration; MDV and other hosts should prefer +`SpatialCanvasViewer` with externally owned state. \ No newline at end of file diff --git a/docs/docs/layers/overview.mdx b/docs/docs/layers/overview.mdx index cec9bde9..97a91503 100644 --- a/docs/docs/layers/overview.mdx +++ b/docs/docs/layers/overview.mdx @@ -31,6 +31,9 @@ See also the [visualization overview](../vis/overview): deck-only integrators ca - shapes rendering is owned primarily at the `@spatialdata/layers` tier rather than `@spatialdata/vis` - the current shipping path is mixed-mode: polygon fallback for JS/WKB payloads and a shared columnar backend branch for `geoarrow-table` runtime data - row alignment, picking, and tooltip resolution are now driven by stable feature identity plus shared row-index metadata +- `buildShapeFillColorByFeatureId()` turns a table column plus already-resolved + `rowIndexByFeatureIndex` into per-feature colour maps; it deliberately does + not resolve SpatialData table associations itself - the public shapes config stays representation-agnostic so a stronger future `deck.gl-geoarrow` backend can slot in without changing saved props 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. @@ -51,3 +54,44 @@ load-bearing: The migration test is the same as shapes: MDV or Vitessce should be able to drive hide/fade/color/radius state without knowing whether the renderer used JS arrays, deck binary attributes, or GeoArrow batches underneath. + +## React-agnostic usage + +`@spatialdata/layers` has no React dependency. A minimal deck-only path: + +```ts +import { + buildShapesPrebuiltData, + createShapesDeckLayer, + buildShapeFillColorByFeatureId, +} from '@spatialdata/layers'; +import { + createFeatureTableAlignment, + loadFeatureRowIndexByFeatureIndex, +} from '@spatialdata/core'; + +// After loading renderData + table column from core: +const fillColorByFeatureId = buildShapeFillColorByFeatureId({ + featureIds: renderData.featureIds, + rowIndexByFeatureIndex: alignment.rowIndexByFeatureIndex, + column: obsColumn, + mode: 'categorical', + alpha: 200, +}); + +const layer = createShapesDeckLayer( + renderData, + { + kind: 'shapes', + elementKey: 'cell_shapes', + visible: true, + featureState: { fillColorByFeatureId }, + }, + { id: 'shapes', prebuilt: buildShapesPrebuiltData(renderData) } +); +``` + +For Viv images and async SpatialData layer orchestration without the +`SpatialCanvas` UI, use `@spatialdata/vis` headless APIs +([Headless viewer guide](../vis/headless-viewer)) or compose +`createShapesDeckLayer` output with your own `Deck` / `DeckGL` stack. diff --git a/docs/docs/vis/feature-table-associations.mdx b/docs/docs/vis/feature-table-associations.mdx index 1bf34c98..7eb37ef8 100644 --- a/docs/docs/vis/feature-table-associations.mdx +++ b/docs/docs/vis/feature-table-associations.mdx @@ -7,21 +7,39 @@ 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, -but some of that logic still lives too close to the demo UI. Before publishing -the first stable visualization API, these responsibilities should be pulled -into reusable core/layer utilities. - -## Reference semantics - -Use Python `spatialdata` as the source of truth for association semantics. +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`](https://github.com/scverse/spatialdata/blob/main/src/spatialdata/_core/query/relational_query.py) + 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`](https://github.com/scverse/spatialdata/blob/main/src/spatialdata/_core/query/relational_query.py) + 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](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html) + 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](https://github.com/scverse/spatialdata/blob/main/docs/design_doc.md#table-table-of-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. See the upstream - [table annotations tutorial](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/notebooks/examples/tables.html) - and - [SpatialData design document](https://github.com/scverse/spatialdata/blob/main/docs/design_doc.md). + 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 @@ -38,10 +56,10 @@ 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. -## Desired API shape +## Current API shape -`@spatialdata/core` should expose a single canonical alignment helper for -regions: +`@spatialdata/core` exposes `FeatureTableAlignment` and +`createFeatureTableAlignment()` as the shared row resolver shape for regions: ```ts type FeatureTableAlignment = { @@ -55,20 +73,27 @@ type FeatureTableAlignment = { }; ``` -The exact type may change, but the principle should not: tooltip resolution, -click/hover events, table-driven fill colour, filtering, and downstream -applications should all use one shared resolver. Avoid adding one-off helpers -such as `resolveShapeFillColorRowIndex` in `SpatialCanvas`. +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: -- Load association metadata from `region`, `region_key`, and `instance_key`. -- Filter rows by the target region/element. -- Match table rows to canonical feature ids, not to render order. +- 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: @@ -84,7 +109,8 @@ The resolver should: - 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. + 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: @@ -110,7 +136,7 @@ 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 +## Current Branch Status The current `SpatialCanvas` behaviour is acceptable as demo functionality: @@ -119,17 +145,28 @@ The current `SpatialCanvas` behaviour is acceptable as demo functionality: - Multiple layer configs may represent the same element with different visual properties. -However, these are not yet the desired foundations for a first stable -library-facing API. Before publishing, revisit the implementation with this -checklist: - -1. Move feature-to-row association into a core helper with tests against the - SpatialData table semantics above. -2. Replace local row-index precedence rules in tooltip, fill-colour, and pick - event paths with that helper. -3. Move reusable colour encoders and feature-state helpers out of the demo UI - when downstream apps need them. +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/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. + +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. +3. Add annotation-column discovery for direct shape annotations and future + matrix-backed values. 4. Keep `SpatialCanvas` as the consumer of these utilities, not the owner of - the semantics. + semantic association rules. 5. Add fixture coverage for non-matching row order, missing rows, mixed-region tables, labels values, and shape annotation columns. diff --git a/docs/docs/vis/headless-viewer.mdx b/docs/docs/vis/headless-viewer.mdx new file mode 100644 index 00000000..a2020cf6 --- /dev/null +++ b/docs/docs/vis/headless-viewer.mdx @@ -0,0 +1,250 @@ +--- +sidebar_position: 5 +--- + +# Headless viewer guide + +This page is a practical guide for experimenting with the **headless** rendering +API in `@spatialdata/vis`. Use it when you want deck/Viv output without +`SpatialCanvas` sidebars, layer-order panels, or the built-in properties UI. + +## What "headless" means here + +Headless mode is **not** a separate WebGL backend. It reuses the same +`useLayerData` → deck/Viv stack as `SpatialCanvas`, but exposes only: + +- **`SpatialCanvasViewer`** — a measured viewport + `SpatialViewer` (deck/Viv) +- **`useSpatialCanvasRenderer`** — the same loading/composition logic without UI +- **`composeSpatialDeckLayers`**, **`shouldAutoFitSpatialView`**, **`shouldRenderInternalTooltip`** — small helpers for integrators + +**2D only:** same limitation as `SpatialCanvas` — orthographic pan/zoom, no 3D +orbit or volume views. Image `z`/`c`/`t` slice selection is supported; 3D scene +navigation is not. + +MDV, Vitessce, and local experiments should import these from the **public** +`@spatialdata/vis` entry point, not from `packages/vis/src/...` paths. + +## Minimal controlled viewer + +```tsx +import { useState } from 'react'; +import { readZarr } from '@spatialdata/core'; +import { + SpatialCanvasViewer, + type LayerConfig, + type ViewState, +} from '@spatialdata/vis'; + +const [spatialData, setSpatialData] = useState> | null>(null); +const [coordinateSystem, setCoordinateSystem] = useState(null); +const [viewState, setViewState] = useState(null); + +const layers: Record = { + image: { + id: 'image', + type: 'image', + elementKey: 'my_image', + visible: true, + opacity: 1, + }, + shapes: { + id: 'shapes', + type: 'shapes', + elementKey: 'cell_shapes', + visible: true, + opacity: 1, + fillColor: [100, 149, 237, 180], + fillColorByColumn: { columnName: 'cell_type', mode: 'categorical' }, + tooltipFields: ['cell_type'], + }, +}; +const layerOrder = ['image', 'shapes']; + +// After loading: + +``` + +**You own all state.** Change `layers`, `layerOrder`, or `viewState` from your +app store (MobX, zustand, React state, Leva, etc.) and the viewer re-renders. + +## Hook-only composition (custom layout) + +When you already have a `DeckGL` shell or need to split loading from the viewport: + +```tsx +import { useMeasure } from '@uidotdev/usehooks'; +import { useSpatialCanvasRenderer } from '@spatialdata/vis'; +import { SpatialViewer } from '@spatialdata/vis'; + +function MyViewport({ spatialData, coordinateSystem, layers, layerOrder, viewState, onViewStateChange }) { + const [ref, { width, height }] = useMeasure(); + const renderer = useSpatialCanvasRenderer({ + spatialData, + coordinateSystem, + layers, + layerOrder, + viewState, + onViewStateChange, + width: width ?? 0, + height: height ?? 0, + deckLayers: myCustomScatterLayer ? [myCustomScatterLayer] : undefined, + }); + + return ( +
+ +
+ ); +} +``` + +`useSpatialCanvasRenderer` returns `deckLayers`, `vivLayerProps`, loading +flags, bounds helpers, and shape pick/tooltip resolvers — same as inside +`SpatialCanvasViewer`. + +## MDV-style overlays + +Pass app-built deck layers **above** SpatialData-generated layers: + +```tsx +import { ScatterplotLayer } from 'deck.gl'; + +const mdvScatter = new ScatterplotLayer({ + id: 'mdv-scatter', + data: scatterData, + getPosition: (d) => d.position, + getRadius: 2, +}); + + (isDragging ? 'grabbing' : 'default'), + }} +/> +``` + +Composition order is always: **SpatialData layers first**, then `deckLayers`, +then scale bar (inside `SpatialViewer`). + +## Tooltips: internal, external, or off + +| `renderTooltip` | Behaviour | +|-----------------|-----------| +| `undefined` (default) | Built-in `SpatialFeatureTooltip` on hover | +| `false` | No internal tooltip; use `onHover` / `onShapeHover` | +| `(props) => ` | Custom renderer; optional `tooltipContainer` for portals | + +```tsx + { + // event.featureId, event.rowIndex, event.pickInfo, ... + }} + onHover={(info) => { + // raw deck PickingInfo for custom layers + }} +/> +``` + +For MDV-style outer-container tooltips, set `renderTooltip={false}` and route +picks through your existing portal hook. + +## Shapes: table-driven colour and filter state + +MDV should drive styling by updating `layers[layerId]` — not by reaching into +vis internals: + +```ts +layers[shapesId] = { + ...layers[shapesId], + fillColorByColumn: { columnName: 'leiden', mode: 'categorical' }, + featureState: { + hiddenFeatureIds: filteredOutIds, + fadedFeatureIds: dimmedIds, + fillColorByFeatureId: selectionColors, + filteredOpacityMultiplier: 0.35, + }, +}; +``` + +Row alignment comes from `@spatialdata/core` (`createFeatureTableAlignment`); +colour encoding from `@spatialdata/layers` (`buildShapeFillColorByFeatureId`). +`SpatialCanvasViewer` wires these when `fillColorByColumn` is set. + +## Auto-fit and view state + +- Pass `viewState={null}` on first render to let the viewer compute an initial + fit from visible layer bounds (`autoFit` defaults to `true`). +- After the user pans/zooms, keep `viewState` controlled and pass updates through + `onViewStateChange`. +- Set `autoFit={false}` if your app restores saved view state and must never + overwrite it. + +## Try it locally + +From the repo root: + +```bash +pnpm install +pnpm test:fixtures:generate:0.7.2 # once, if test-fixtures/ is missing +pnpm --filter @spatialdata/vis dev +``` + +Open **http://127.0.0.1:5173/headless** for the headless demo route. It loads +the local **`blobs.zarr`** fixture (`v0.7.2`) via `SpatialCanvasViewer` with +externally controlled layer state — no `SpatialCanvas` sidebars. + +The dev script starts the fixture server on port **38473** (override with +`SPATIALDATA_FIXTURE_PORT`) and proxies `/test-fixtures` through Vite on +**5173**. The default Sketch UI remains at **http://127.0.0.1:5173/**. + +Suggested experiments (matching the [MDV integration roadmap](./mdv-integration)): + +1. **Fixed stack** — hard-code `layers` / `layerOrder` for one fixture URL. +2. **External controls** — Leva panel for coordinate system, visibility, opacity, channel colours. +3. **Custom deck layer** — one `ScatterplotLayer` above the image. +4. **Controlled view** — save/restore `viewState` in `sessionStorage`. +5. **External tooltips** — `renderTooltip={false}` + log `onShapeHover` payloads. + +Pack for MDV smoke tests: + +```bash +pnpm build +pnpm --filter @spatialdata/vis pack +# install the resulting .tgz in the MDV workspace +``` + +## Public API checklist + +Before treating the API as stable for MDV: + +- [x] `SpatialCanvasViewer` exported from `@spatialdata/vis` +- [x] `useSpatialCanvasRenderer`, `composeSpatialDeckLayers`, `shouldAutoFitSpatialView`, `shouldRenderInternalTooltip` exported +- [x] Controlled `coordinateSystem`, `layers`, `layerOrder`, `viewState` +- [x] `deckLayers` / `deckProps` passthrough +- [x] `renderTooltip={false}` for external tooltip ownership +- [x] `demo/headless` route with local `blobs.zarr` fixture +- [ ] Additional `demo/headless-*` variants (Leva controls, custom deck layers) +- [ ] Tooltip/pick row resolution fully on shared `FeatureTableAlignment` (in progress) +- [ ] npm prerelease published and smoke-tested in MDV + +See also [MDV release checklist](./mdv-release-checklist) and +[Feature table associations](./feature-table-associations). diff --git a/docs/docs/vis/layer-prop-flow.mdx b/docs/docs/vis/layer-prop-flow.mdx index 51be7a05..dff5bc45 100644 --- a/docs/docs/vis/layer-prop-flow.mdx +++ b/docs/docs/vis/layer-prop-flow.mdx @@ -223,6 +223,10 @@ frame. - **Merged feature-state objects must not be allocated each frame** when only cosmetic layer props change. Table-driven fill colours are merged inside `getStableShapeFeatureStateRuntime` only when the signature changes. +- **Table-driven fill colours** should be computed from + `@spatialdata/core`-resolved `rowIndexByFeatureIndex` and the shared + `@spatialdata/layers` colour encoder. Do not add local feature-to-row + precedence rules in `SpatialCanvas` or layer modules. ### Layer contract (`@spatialdata/layers`) @@ -233,6 +237,9 @@ frame. caller may still pass records (tests, `SpatialLayer`). - **`updateTriggers` for `getFillColor` / `getLineColor`**: list the specific Maps/Sets/scalars that affect colour, not the whole `featureState` object. +- **`buildShapeFillColorByFeatureId`**: converts a table column plus resolved + row alignment into per-feature colours. It is renderer-agnostic and does not + load tables or decide SpatialData feature/table association semantics. - Prefer **constant accessors** and layer `opacity` over per-feature callbacks when the visual change is uniform. @@ -242,6 +249,7 @@ frame. |---|---| | `new Map(Object.entries(fillColorByFeatureId))` on every `getLayers()` | O(n) allocation and GC pressure on large feature sets | | Spreading `featureState` each render for table fill colours | Defeats WeakMap identity caches; triggers full accessor rebuilds | +| Resolving feature/table row precedence inside a colour encoder | Duplicates `@spatialdata/core` association semantics and drifts from Python `spatialdata` | | Putting `featureState` wholesale in `updateTriggers` | Any new object identity invalidates all colour attributes | | Per-feature accessors for layer-wide opacity | Use deck `opacity` on the layer instead | diff --git a/docs/docs/vis/mdv-integration.mdx b/docs/docs/vis/mdv-integration.mdx index 301843ee..ae9c4c87 100644 --- a/docs/docs/vis/mdv-integration.mdx +++ b/docs/docs/vis/mdv-integration.mdx @@ -8,19 +8,33 @@ This page explains architecture and phased context. For the branch-level shippin This library is intended to align with **[MDV](https://github.com/Taylor-CCB-Group/MDV)** and **[Vitessce](https://vitessce.io/)** so both projects can render SpatialData images, labels, shapes, and points from shared packages instead of relying on diverging local implementations. -The near-term target is not a full replacement for every MDV spatial feature. The target is a baseline `SpatialCanvas`-backed MDV chart that can stand in for `VivScatterComponent` where appropriate, accept MDV-controlled state, compose MDV custom deck.gl layers, and avoid showing this repo's demo/editor UI inside MDV. MDV is the first "use it in anger" sanity check; Vitessce compatibility remains a priority design target rather than a later afterthought. +The near-term target is not a full replacement for every MDV spatial feature. The target is a baseline **2D** `SpatialCanvas`-backed MDV chart that can stand in for `VivScatterComponent` where appropriate, accept MDV-controlled state, compose MDV custom deck.gl layers, and avoid showing this repo's demo/editor UI inside MDV. **3D orbit/volume rendering is out of scope** for this integration path. MDV is the first "use it in anger" sanity check; Vitessce compatibility remains a priority design target rather than a later afterthought. ## Current state -`@spatialdata/vis` already exports `SpatialCanvas`, `SpatialViewer`, and `VivSpatialViewer`. The rendering path is close to MDV's existing `MDVivViewer` pattern: +`@spatialdata/vis` exports both the full **`SpatialCanvas`** UI and a **headless** +**`SpatialCanvasViewer`** (plus **`useSpatialCanvasRenderer`**, **`composeSpatialDeckLayers`**, and related helpers) from the public package entry. The rendering path matches MDV's existing `MDVivViewer` pattern: - image layers are rendered through Viv `DetailView` - extra deck.gl layers are composed above images - non-image spatial layers use the same deck.gl view model - shape, point, and labels renderers exist -- `SpatialCanvas` has a zustand store and can persist layer config externally +- shapes support table-driven fill colour and per-feature state on `ShapesLayerConfig` +- `@spatialdata/core` exposes `FeatureTableAlignment` / `createFeatureTableAlignment()` +- `@spatialdata/layers` owns shape deck rendering and `buildShapeFillColorByFeatureId()` -The blocker for MDV use is mostly API shape. `SpatialCanvas` currently owns a full UI shell: coordinate-system selector, layer selector, layer-order panel, properties panel, fullscreen button, loading overlays, tooltip rendering, and local layer creation. MDV needs the rendering core without that UI, and it needs to drive view state, layers, style, filtering, highlighting, and custom overlays from chart/data-store state. +**Remaining MDV blockers** are packaging and polish, not the absence of a headless component: + +- packages are not yet published to npm (pack / link / prerelease for MDV smoke tests) +- tooltip/pick row resolution should finish converging on the shared core resolver +- points layer is minimal scatter only (v1.1 for MDV parity) + +**Viv/deck stack:** this repo pins `@hms-dbmi/viv@0.21.0` and deck.gl `9.2.9`. MDV's +matching upgrade is almost ready to merge — treat shared Viv/deck/luma versions as +**effectively aligned** for the first `@spatialdata/vis` smoke test. Residual risk is +extension-specific visual regression, not version skew between the two codebases. + +`SpatialCanvas` still owns a full UI shell for demos. MDV should embed **`SpatialCanvasViewer`** and drive view state, layers, style, filtering, highlighting, and custom overlays from chart/data-store state. ## Priority: headless first @@ -30,15 +44,15 @@ The first implementation should use a **bridge path** rather than a full layers- The layers-first direction is still likely the cleaner long-term architecture, especially for MDV and Vitessce. For now, `@spatialdata/layers` should mature through narrow renderer slices rather than becoming the blocking path for headless mode: the current `SpatialLayer` package defines a schema/contract, but real image, labels, shapes, and points sublayer factories still need to be ported deliberately. -Useful in-repo validation demos: +Useful in-repo validation demos (see [Headless viewer guide](./headless-viewer)): -- [ ] `demo/headless-basic`: render a fixed SpatialData image/labels/shapes stack with all props owned by the demo component, no `SpatialCanvas` sidebars. -- [ ] `demo/headless-leva`: use Leva or a similar external control panel to drive coordinate system, layer visibility, opacity, channel colors, labels style, and view reset. +- [x] `demo/headless`: local `blobs.zarr` fixture via `SpatialCanvasViewer` (no `SpatialCanvas` sidebars). +- [ ] `demo/headless-leva`: external control panel variant of the above. - [ ] `demo/headless-custom-layers`: pass arbitrary deck.gl layers into the viewer, matching the way MDV will pass scatter, gates, contours, and ROI JSON. - [ ] `demo/headless-controlled-view`: keep view state fully controlled by an outer component, including programmatic pan/zoom/reset and saved/restored state. - [ ] `demo/headless-tooltips`: disable internal tooltip UI and route picking into an externally owned tooltip renderer. -Acceptance signal: the demos should import the same public API that MDV would use. If a demo needs internal imports from `packages/vis/src/SpatialCanvas/*`, the API is not ready. +Acceptance signal: demos should import the same public API MDV uses (`SpatialCanvasViewer` from `@spatialdata/vis`). That bar is met; the gap is example apps, not missing exports. ## Phase 0: rendering stack and package sanity @@ -50,27 +64,21 @@ Acceptance signal: the demos should import the same public API that MDV would us - optional: `@spatialdata/avivatorish` - [ ] Decide whether MDV first consumes via local `npm link` / packed tarballs / workspace path / published prerelease. - [ ] Treat this repo as the place where Viv/deck/luma versions are selected. MDV should follow those versions, not constrain them. -- [ ] Track upgrading to `@hms-dbmi/viv@0.21.0` / deck.gl `9.2.9` as a first-class migration. Viv PR [hms-dbmi/viv#924](https://github.com/hms-dbmi/viv/pull/924) is merged there (uniform-buffer-backed shader props, `model.shaderInputs`, and variable channel counts). -- [ ] Update shader extensions here and in MDV in the same wave: - - replace deprecated `setUniforms` paths with `model.shaderInputs.setProps(...)` - - prefer `updateState()` for shader input updates where appropriate - - move custom extension uniforms to UBO/module prop style - - audit assumptions around `MAX_CHANNELS = 6` - - use Viv's `NUM_CHANNELS` / shader preprocessing conventions where relevant - - refactor props passed into Viv image layers so extension props are explicit and serializable -- [ ] Align dependency versions before testing in MDV. This repo currently uses Viv `0.20.x` and deck.gl `9.1.x`; MDV currently declares Viv `0.19.x`. The expected integration target is likely newer than both, so the first MDV smoke test should use the same upgraded Viv/deck/luma stack as this repo. +- [x] Upgrade to `@hms-dbmi/viv@0.21.0` / deck.gl `9.2.9` in this repo (Viv PR [hms-dbmi/viv#924](https://github.com/hms-dbmi/viv/pull/924): uniform-buffer-backed shader props, `model.shaderInputs`, variable channel counts). +- [x] MDV Viv/deck/luma upgrade to the same stack — **almost ready to merge**; treat as done for integration planning. +- [ ] Residual extension audit on both sides after MDV merge (shader-input paths, channel-count assumptions, extension prop passthrough through `deckProps` / `SpatialCanvasViewer`). - [ ] Run a clean `pnpm build` and pack the packages, then install them into `~/code/www/MDV`. - [ ] Add one minimal MDV smoke chart that imports the package and renders a known fixture before attempting a full chart replacement. ## Phase 1: headless `SpatialCanvas` API -Add a controlled/headless rendering API to `@spatialdata/vis` so MDV can embed the viewer without this repo's UI. +**Mostly landed.** `@spatialdata/vis` exports **`SpatialCanvasViewer`** as a separate controlled component (not a `mode` prop on `SpatialCanvas`). -Proposed surface: +Current surface: ```tsx - ``` -Likely implementation steps: +Implementation status: -- [ ] Split the current component into a UI wrapper and a reusable viewer core. -- [ ] Export a stable `SpatialCanvasViewer` or `SpatialCanvasCore` that renders only the measured viewport and `SpatialViewer`. -- [ ] Support controlled `coordinateSystem`, `layers`, `layerOrder`, and `viewState` props. -- [ ] Keep the existing zustand-driven UI as `SpatialCanvas` or `SpatialCanvasEditor`. -- [ ] Add `extraLayers` / `deckLayers` / `deckProps` so MDV can pass scatter points, gates, selection overlays, contours, ROI JSON, and custom tooltips. -- [ ] Allow overlays and loading indicators to be disabled or replaced by MDV. -- [ ] Allow tooltip handling to be fully external, including MDV's outer-container portal behavior. -- [ ] Allow a stable externally supplied Viv/deck view id or layer id suffix, so MDV can keep its layer-filtering conventions. -- [ ] Keep Viv image-layer props and non-image layer props separate enough that shader-extension migrations do not leak through the MDV adapter. +- [x] Split viewer core (`SpatialCanvasViewer` / `useSpatialCanvasRenderer`) from `SpatialCanvas` UI shell. +- [x] Export `SpatialCanvasViewer` from public `@spatialdata/vis` entry. +- [x] Controlled `coordinateSystem`, `layers`, `layerOrder`, and `viewState`. +- [x] `deckLayers` / `deckProps` for MDV scatter, gates, contours, ROI overlays. +- [x] `showLoadingOverlay` and `renderTooltip={false}` for external tooltip ownership. +- [x] `onShapeHover` / `onShapeClick` for feature-aware picking. +- [ ] Stable externally supplied Viv/deck view id or layer id suffix (`getVivId` compatibility). +- [ ] Viv extension prop audit once MDV merge lands (low risk; versions already aligned). -Open design choice: either make `SpatialCanvas` support both controlled and uncontrolled modes, or introduce a separate `SpatialCanvasViewer` for embedding. A separate component is probably cleaner for MDV because it avoids accidental UI/state coupling. +See [Headless viewer guide](./headless-viewer) for experimentation steps. ## Phase 2: MDV adapter chart @@ -122,6 +130,34 @@ Adapter responsibilities: The first-pass chart can deliberately avoid replacing the channel dialog and most image editing UI. It only needs enough layer config to render an image/labels/shapes baseline and enough view-state synchronization to sanity-check against the current Viv chart. +## Tables, AnnData.js, and zarrita `DataLoader` + +MDV chart config ultimately drives **table-backed** shape colour, tooltips, and +filters. The shared contract spans three packages: + +| Concern | Owner | +|---------|--------| +| `region` / `region_key` / `instance_key` semantics, row alignment | `@spatialdata/core` | +| Column → per-feature colour maps, deck feature state | `@spatialdata/layers` | +| Which column to show, loading columns for UI, passing config into the viewer | `@spatialdata/vis` / MDV | + +**Today:** `TableElement` loads AnnData stores via **`anndata.js`** on top of +**`zarrita`** (`readZarr` on the table's zarr subtree). Association helpers +(`loadFeatureRowIndexByFeatureIndex`, `loadAssociatedTableFeatureRows`) use +targeted obs-column loaders rather than a single high-level DataLoader API. + +**Near-term roadmap:** expose coherent **`tables`** access through +**AnnData.js** (and zarrita-backed **`DataLoader`** where appropriate) so +integrators can read `obs`, `var`, selected `X` columns, `obsm`, and future +`uns` / `obsp` surfaces without ad hoc string-column lookups or dropping to +`getAnnDataJS()` for every chart. Richer query patterns should push upstream +into `anndata.js` where possible; SpatialData.js should thin-wrap table +elements and preserve Python association semantics. + +MDV should pass resolved **`featureState`** and **`fillColorByColumn`** on +`ShapesLayerConfig` when selection or colour mappings change — not re-load +geometry. See [Feature table associations](./feature-table-associations). + ## Phase 3: layer styling, filtering, and highlighting The current SpatialData renderers mostly accept constant style values: @@ -434,8 +470,7 @@ Open questions: ## Compatibility risks -- **Viv/deck version skew:** MDV uses Viv `0.19.x`; this repo uses Viv `0.20.x`. Mixed deck/luma packages can fail in subtle WebGL ways. -- **Viv/deck migration (Viv `0.21.0` / PR `#924`):** Merged upstream in `@hms-dbmi/viv@0.21.0` with deck.gl `9.2.9`; shader props use UBOs and `model.shaderInputs`. This repo and MDV still need to adopt that stack and migrate custom shader extensions deliberately, not piecemeal. +- **Viv/deck version skew:** largely resolved — both codebases target Viv `0.21.0` / deck.gl `9.2.9`; MDV's upgrade PR is almost ready to merge. Remaining risk is extension-specific regression, not mismatched package versions. - **Arrow/Parquet churn:** loaders.gl Parquet and deck.gl-community Arrow layers are promising but still moving targets. We should avoid locking public `core` APIs to one loader implementation too early. - **Raster backend churn:** `deck.gl-raster` may absorb image/Zarr responsibilities that currently sit in Viv or custom labels layers. Public props should leave room for a backend swap. - **Image format/codecs uncertainty:** OME-TIFF, JP2K-compressed TIFF, OME-Zarr, SpatialData Zarr, and future Zarr image codecs may all matter. Avoid encoding one storage format too deeply into viewer props. @@ -444,7 +479,7 @@ Open questions: - **Layer ids:** MDV relies on `getVivId(...)` tokens for layer filtering. `VivSpatialViewer` has its own generated ids. MDV embedding may need a supplied `viewId` / `layerIdSuffix`. - **UI ownership:** current `SpatialCanvas` resets its local store when coordinate systems change. In controlled MDV mode this could wipe chart-driven layers if not separated. - **Tooltip ownership:** both libraries have tooltip systems. MDV should own tooltip portals for now. -- **Feature ids:** shapes currently load polygon arrays without an obvious stable id in the render path. Labels expose picked label ids, but table association must be explicit and tested. +- **Feature ids:** shapes now carry stable `featureId` values in render data and layer picking; labels association and tests still need to catch up to the shapes path. - **Performance:** shape/label styling from MDV filters must avoid expensive observable lookups inside render accessors. - **Data model mismatch:** MDV's regions/image metadata is not the same as a full SpatialData object. The first adapter may need a compatibility layer while MDV projects transition. - **CSS/layout:** `SpatialCanvas` has hard-coded dark UI styles and minimum height. Headless mode should render with parent-owned layout and no border/sidebar styles. diff --git a/docs/docs/vis/mdv-release-checklist.mdx b/docs/docs/vis/mdv-release-checklist.mdx index ad08d439..36cdb42c 100644 --- a/docs/docs/vis/mdv-release-checklist.mdx +++ b/docs/docs/vis/mdv-release-checklist.mdx @@ -74,9 +74,21 @@ Over time, patterns that repeat in MDV (shared shape styling, common overlays) m For v1, shapes must support the same *effect* as MDV table-row colouring/filtering, without MDV re-implementing SpatialData loading: -- [ ] `ShapesLayerConfig` accepts MDV-driven per-feature style (accessors or equivalent state on the config object MDV updates when selection changes) -- [ ] Feature index / `instance_key` alignment with the associated table is stable for picking and styling (same join used for tooltips) -- [ ] Document that MDV updates `layers[layerId]` when datastore selection or colour columns change +- [x] `ShapesLayerConfig` accepts MDV-driven per-feature style (`featureState`, `fillColorByColumn` on the config object MDV updates when selection changes) +- [x] Feature index / `instance_key` alignment with the associated table is stable for picking and styling (core `FeatureTableAlignment` + layers encoders) +- [x] Document that MDV updates `layers[layerId]` when datastore selection or colour columns change ([Headless viewer guide](./headless-viewer), [Feature table associations](./feature-table-associations)) + +Current local state: + +- `@spatialdata/core` exposes `FeatureTableAlignment` / + `createFeatureTableAlignment()` as the shared row resolver shape, following + Python `spatialdata` `region_key` / `instance_key` semantics. +- `@spatialdata/layers` owns `buildShapeFillColorByFeatureId()` for converting + a resolved `rowIndexByFeatureIndex` plus a table column into per-feature + colours. +- Remaining release risk: tooltip and pick-event row resolution should finish + converging on the shared core resolver shape before treating the association + contract as stable. Optional later: MDV-only `PolygonLayer` built from exported geometry helpers; not required if config-driven styling is sufficient. @@ -95,25 +107,25 @@ Practical checks for this branch: ### 1) API and export sanity -- [ ] `@spatialdata/vis` exports `SpatialCanvasViewer` as public API. -- [ ] Viewer helper exports are public and documented: +- [x] `@spatialdata/vis` exports `SpatialCanvasViewer` as public API. +- [x] Viewer helper exports are public and documented: - `useSpatialCanvasRenderer` - `composeSpatialDeckLayers` - `shouldAutoFitSpatialView` - `shouldRenderInternalTooltip` -- [ ] `SpatialViewer` supports passthrough `deckProps` safely. -- [ ] No MDV integration requires importing from `packages/vis/src/...` internals. +- [x] `SpatialViewer` supports passthrough `deckProps` safely. +- [x] No MDV integration requires importing from `packages/vis/src/...` internals (public entry only). ### 2) Headless behavior parity checks -- [ ] `SpatialCanvasViewer` renders with externally controlled: +- [x] `SpatialCanvasViewer` renders with externally controlled: - `coordinateSystem` - `layers` - `layerOrder` - `viewState` -- [ ] It composes caller deck layers above SpatialData-rendered layers. -- [ ] It allows MDV-owned tooltip flow (internal tooltip disabled or overridden). -- [ ] It supports MDV-owned controller settings through `deckProps`. +- [x] It composes caller deck layers above SpatialData-rendered layers. +- [x] It allows MDV-owned tooltip flow (`renderTooltip={false}`, `onShapeHover`). +- [x] It supports MDV-owned controller settings through `deckProps`. ### 3) MDV touchpoint checks @@ -132,12 +144,13 @@ Practical checks for this branch: ### 5) Non-goals (explicit) - [ ] No attempt to replace Viv in this release. +- [ ] No 3D rendering (orbit views, volume rendering, pitch/bearing navigation) in this release — 2D Cartesian only. - [ ] No in-repo implementation of broad GIS support in this release. - [ ] No hard coupling of `core` API to deck-specific loaders for this release. -## Viv extensions and near-term Viv upgrade +## Viv extensions and Viv `0.21.0` stack -With Viv PR [#924](https://github.com/hms-dbmi/viv/pull/924) merged in `@hms-dbmi/viv@0.21.0` (deck.gl `9.2.9`, uniform-buffer-backed shader props, `model.shaderInputs`, variable channel counts), we should treat extension compatibility as a release gate when adopting that stack. +This repo pins `@hms-dbmi/viv@0.21.0` and deck.gl `9.2.9` (Viv PR [#924](https://github.com/hms-dbmi/viv/pull/924): uniform-buffer-backed shader props, `model.shaderInputs`, variable channel counts). MDV's matching Viv/deck upgrade is **almost ready to merge** — treat version alignment as done; the remaining gate is extension behaviour under the shared stack. ### Why this matters now @@ -147,6 +160,7 @@ With Viv PR [#924](https://github.com/hms-dbmi/viv/pull/924) merged in `@hms-dbm ### Branch checklist for Viv-upgrade readiness +- [x] Align Viv `0.21.0` / deck.gl `9.2.9` in this repo and MDV (MDV merge imminent). - [ ] Audit `@spatialdata/vis` Viv paths for extension prop preservation: - `packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx` - ensure layer cloning/composition does not drop non-enumerable extension/default props. @@ -201,4 +215,3 @@ Design constraints to decide before building: - Whether edits target **Zarr/SpatialData on disk**, **in-memory session state**, or **MDV’s own project files**. - How editable layers share **picking ids** and **coordinate systems** with read-only SpatialData layers. - Whether Vitessce/MDV both need the same editable-layer API or app-specific extensions remain sufficient. - diff --git a/docs/docs/vis/overview.mdx b/docs/docs/vis/overview.mdx index 634df2ce..937a5f82 100644 --- a/docs/docs/vis/overview.mdx +++ b/docs/docs/vis/overview.mdx @@ -8,6 +8,8 @@ This document describes how **`@spatialdata/vis`** fits into the wider visualiza ## Today: `@spatialdata/vis` +**2D only** — no 3D orbit/volume rendering yet (see [SpatialCanvas status](./spatial-canvas-status#known-limitations)). + - **SpatialCanvas** — Main React surface: coordinate system, layer toggles, pan/zoom, OME-Zarr images (Viv) with shapes/points (deck.gl). - **ImageView** — Smaller demo-oriented viewer; long-term we expect **SpatialCanvas with a single image layer** to cover the same use case. - **Sketch / demo app** — Prototyping and docs examples. diff --git a/docs/docs/vis/spatial-canvas-status.mdx b/docs/docs/vis/spatial-canvas-status.mdx index fecc44ce..1eb3cb27 100644 --- a/docs/docs/vis/spatial-canvas-status.mdx +++ b/docs/docs/vis/spatial-canvas-status.mdx @@ -6,6 +6,8 @@ sidebar_position: 1 `@spatialdata/vis` includes **SpatialCanvas**, a React UI for picking a coordinate system, toggling layers, and panning/zooming. It can render **OME-Zarr images** together with **shapes** and **points** in one deck.gl + Viv view. +**Scope today: 2D only.** There is no 3D scene rendering — no orbit navigation, pitch/bearing, or volume views. See [Known limitations](#known-limitations). + ## What works - **Combined canvas:** Image layers (via Viv `DetailView` and `loadOmeZarr`) draw under vector layers; composition follows the same pattern as MDV’s Viv + deck stacking. @@ -14,9 +16,9 @@ sidebar_position: 1 ## Known limitations +- **No 3D rendering:** `SpatialCanvas`, `SpatialCanvasViewer`, and the underlying Viv/deck stack are **2D Cartesian only**. The viewer uses Viv `DetailView` (orthographic pan/zoom), not `OrbitView` or volume rendering. You can select an image **`z`** slice (or `c` / `t`) through `LayerConfig.channels`, but that is OME axis indexing — not 3D scene navigation. `ViewState` includes a `ViewState3D` type stub, but pitch, bearing, and orbit state are not wired through to rendering. **3D is not supported for MDV v1 integration, but a high priority following that.** - **Channel UI is basic:** the properties pane exposes per-channel RGB (0–255), contrast min/max, `z`/`c`/`t`, and visibility (up to six channels). There is **no histogram** or brush yet; defaults from automatic stats can still look wrong until you tune values. - **Single primary image** path in the Viv viewer composition (first enabled image layer drives `DetailView.getLayers`). -- **View state** conversion between SpatialCanvas and Viv is still **2D-oriented**; full 3D orbit state is not fully round-tripped. - **`useLayerData`** prefers explicit `LayerConfig.channels` when arrays are non-empty; further **override flags** may still be useful for edge cases. - **Points are still a minimal scatter path:** `PointsElement.loadPoints()` loads coordinate columns into an ndarray-ish object and @@ -37,21 +39,33 @@ sidebar_position: 1 - [`packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx) — Viv + deck layer composition - [`packages/vis/src/SpatialCanvas/useLayerData.ts`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/vis/src/SpatialCanvas/useLayerData.ts) — Loaders and Viv layer props - [`packages/vis/src/SpatialCanvas/types.ts`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/vis/src/SpatialCanvas/types.ts) — `LayerConfig`, `ChannelConfig` +- [`packages/core/src/tableAssociations.ts`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/core/src/tableAssociations.ts) — feature/table row alignment following Python `spatialdata` semantics +- [`packages/layers/src/shapeColorEncoding.ts`](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/packages/layers/src/shapeColorEncoding.ts) — renderer-agnostic shape column-to-colour encoding over resolved row alignment ## Recently landed (check repo for details) - **`@spatialdata/avivatorish`** — shared loaders and Zustand stores; SpatialCanvas / ImageView import from the package. - **`@spatialdata/layers`** — **`SpatialLayer`** + Zod **`SpatialLayerProps`** + **`migrateSpatialLayerProps`** (sublayer wiring still to grow). +- **Feature/table split** — `@spatialdata/core` now exposes a + `FeatureTableAlignment` resolver shape, and `@spatialdata/layers` owns the + reusable shape column-to-colour encoder. `SpatialCanvas` consumes these + helpers rather than owning the fill-colour association rule. - **SpatialCanvas** — loader registry context (**`VivLoaderRegistryProvider`** / **`useVivLoaderRegistry`**), **layers / properties** sidebars, **DnD** layer order, **fullscreen** toggle, **channel** controls for image layers. - **Demo / Sketch** — full-height shell for the vis demo. +## Headless embedding + +`SpatialCanvasViewer` and `useSpatialCanvasRenderer` are exported for +controlled, UI-free embedding (MDV, Vitessce, local experiments). See +[Headless viewer guide](./headless-viewer). + ## Near-term roadmap - **Histogram** (optional) and richer channel UX (MDV parity). - **Fold or replace `ImageView`** once SpatialCanvas covers the single-image case well. -- **Feature/table foundations:** keep current shape colour and aggregate - tooltip behaviour as demo functionality, then move canonical feature-to-row - association and annotation column discovery into reusable core/layer APIs. +- **Feature/table foundations:** finish routing tooltip and pick-event row + resolution through the shared core resolver shape, then add annotation column + discovery for direct shape annotations and future matrix-backed values. See [Feature table associations and annotation columns](./feature-table-associations). ## Medium-term roadmap diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 4c97f1c6..dbe7d142 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -6,7 +6,7 @@ import { themes as prismThemes } from 'prism-react-renderer'; const config: Config = { title: 'SpatialData.js', - tagline: 'A library for interfacing with SpatialData stores in TS/JS', + tagline: 'A library for interfacing with and visualizing SpatialData stores in TS/JS', favicon: 'img/favicon.ico', // Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 6d335e7e..5fdd5767 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -23,7 +23,7 @@ export default function Home() { const { siteConfig } = useDocusaurusContext(); return ( diff --git a/package.json b/package.json index 5cfe3d94..8d259733 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "spatialdata-monorepo", "version": "0.0.1", "private": true, - "description": "A library for interfacing with SpatialData stores in TS/JS", + "description": "A library for interfacing with and visualizing SpatialData stores in TS/JS", "license": "MIT", "repository": { "type": "git", diff --git a/packages/core/README.md b/packages/core/README.md index 47ebc3e5..0880a04e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -40,7 +40,7 @@ Pass the store URL directly: ```js const { readZarr } = await import('./packages/core/dist/index.js'); -const sdata = await readZarr('http://localhost:8080/v0.7.2/blobs.zarr'); +const sdata = await readZarr('http://localhost:38473/v0.7.2/blobs.zarr'); sdata.url; Object.keys(sdata.images ?? {}); diff --git a/packages/core/src/tableAssociations.ts b/packages/core/src/tableAssociations.ts index 4e13e65d..beb5f0ac 100644 --- a/packages/core/src/tableAssociations.ts +++ b/packages/core/src/tableAssociations.ts @@ -1,5 +1,5 @@ -import type { ElementName, TableColumnData } from './types'; import type { SpatialData } from './store'; +import type { ElementName, TableColumnData } from './types'; type SpatialAssociationKind = Exclude; @@ -10,12 +10,57 @@ export interface AssociatedTableFeatureRows { extraColumns?: Array; } +/** + * Alignment follows Python spatialdata association semantics: + * - SpatialElements match by their instance index (`GeoDataFrame.index` for + * shapes; non-background label values for labels). + * - Tables match by `region_key` and `instance_key` columns, not `obs.index`. + * + * Source: scverse/spatialdata `join_spatialelement_table` and + * `get_element_instances` in `spatialdata/_core/query/relational_query.py`. + */ +export type FeatureTableAlignmentFeature = { + featureId: string; + featureIndex: number; + rowIndex?: number; +}; + +export type FeatureTableAlignment = { + rowIndexByFeatureIndex: Int32Array; + rowIndexByFeatureId?: Map; + resolveRowIndex(feature: FeatureTableAlignmentFeature): number | undefined; +}; + function createDefaultRowIndexByFeatureIndex(length: number): Int32Array { const indices = new Int32Array(length); indices.fill(-1); return indices; } +export function createFeatureTableAlignment({ + rowIndexByFeatureIndex, + rowIndexByFeatureId, +}: { + rowIndexByFeatureIndex: Int32Array; + rowIndexByFeatureId?: Map; +}): FeatureTableAlignment { + return { + rowIndexByFeatureIndex, + rowIndexByFeatureId, + resolveRowIndex(feature) { + if (feature.rowIndex !== undefined && feature.rowIndex >= 0) { + return feature.rowIndex; + } + const byFeatureIndex = rowIndexByFeatureIndex[feature.featureIndex]; + if (byFeatureIndex !== undefined && byFeatureIndex >= 0) { + return byFeatureIndex; + } + const byFeatureId = rowIndexByFeatureId?.get(feature.featureId); + return byFeatureId !== undefined && byFeatureId >= 0 ? byFeatureId : undefined; + }, + }; +} + function normalizeCellValue(value: TableColumnData | undefined, rowIndex: number): string { if (value === undefined) return ''; const row = value[rowIndex]; diff --git a/packages/core/tests/tableAssociations.spec.ts b/packages/core/tests/tableAssociations.spec.ts index 26f00ed4..3513c79c 100644 --- a/packages/core/tests/tableAssociations.spec.ts +++ b/packages/core/tests/tableAssociations.spec.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from 'vitest'; import { ATTRS_KEY } from '@spatialdata/zarrextra'; +import type { ConsolidatedStore } from '@spatialdata/zarrextra'; +import { describe, expect, it } from 'vitest'; import { getTableKeys } from '../src/models/index.js'; import { SpatialData } from '../src/store/index.js'; -import { loadAssociatedTableFeatureRows } from '../src/tableAssociations.js'; +import { + createFeatureTableAlignment, + loadAssociatedTableFeatureRows, +} from '../src/tableAssociations.js'; function createMockSpatialData() { const rootStore = { @@ -62,7 +66,10 @@ function createMockSpatialData() { zarritaStore: {}, }; - return new SpatialData('https://example.com/mock.zarr', rootStore as any, ['shapes', 'tables']); + return new SpatialData('https://example.com/mock.zarr', rootStore as ConsolidatedStore, [ + 'shapes', + 'tables', + ]); } describe('getTableKeys', () => { @@ -131,7 +138,11 @@ describe('SpatialData table associations', () => { describe('loadAssociatedTableFeatureRows', () => { it('maps cell_circles features through a shared table tagged with region cells', async () => { const sdata = createMockSpatialData(); - const [, table] = sdata.getAssociatedTable('shapes', 'cell_circles')!; + const associated = sdata.getAssociatedTable('shapes', 'cell_circles'); + if (!associated) { + throw new Error('Expected mock cell_circles association'); + } + const [, table] = associated; table.loadObsIndex = async () => ['48022', '48023']; table.loadObsColumns = async () => [ ['cells', 'cells'], @@ -159,3 +170,43 @@ describe('loadAssociatedTableFeatureRows', () => { expect(rows.extraColumns?.[0]?.[0]).toBe('10.5'); }); }); + +describe('createFeatureTableAlignment', () => { + it('resolves rows from precomputed feature-index alignment', () => { + const alignment = createFeatureTableAlignment({ + rowIndexByFeatureIndex: new Int32Array([1, 0, -1]), + }); + + expect(alignment.resolveRowIndex({ featureId: 'cell-a', featureIndex: 0 })).toBe(1); + expect(alignment.resolveRowIndex({ featureId: 'cell-b', featureIndex: 1 })).toBe(0); + expect(alignment.resolveRowIndex({ featureId: 'missing', featureIndex: 2 })).toBeUndefined(); + }); + + it('uses feature-id alignment as a compatibility fallback only when index alignment is absent', () => { + const alignment = createFeatureTableAlignment({ + rowIndexByFeatureIndex: new Int32Array([-1, -1]), + rowIndexByFeatureId: new Map([ + ['circle-a', 1], + ['circle-b', 0], + ]), + }); + + expect(alignment.resolveRowIndex({ featureId: 'circle-a', featureIndex: 0 })).toBe(1); + expect(alignment.resolveRowIndex({ featureId: 'circle-b', featureIndex: 1 })).toBe(0); + }); + + it('does not let colliding numeric feature ids override resolved feature-index alignment', () => { + const alignment = createFeatureTableAlignment({ + rowIndexByFeatureIndex: new Int32Array([0, 1, 2]), + rowIndexByFeatureId: new Map([ + ['1', 0], + ['5', 1], + ['99', 2], + ]), + }); + + expect(alignment.resolveRowIndex({ featureId: '0', featureIndex: 0 })).toBe(0); + expect(alignment.resolveRowIndex({ featureId: '1', featureIndex: 1 })).toBe(1); + expect(alignment.resolveRowIndex({ featureId: '2', featureIndex: 2 })).toBe(2); + }); +}); diff --git a/packages/layers/src/index.ts b/packages/layers/src/index.ts index c91846cb..ef4428ac 100644 --- a/packages/layers/src/index.ts +++ b/packages/layers/src/index.ts @@ -31,6 +31,18 @@ export { type ShapeTooltipRuntimeData, type GeoarrowTableLike, } from './shapesLayer'; +export { + buildShapeFillColorByFeatureId, + DEFAULT_SHAPE_CATEGORICAL_PALETTE, + DEFAULT_SHAPE_NUMERIC_RAMP, + resolveShapeFillColorMode, +} from './shapeColorEncoding'; +export type { + BuildShapeFillColorByFeatureIdOptions, + ShapeFillColorMode, + ShapeRgbColor, + ShapeRgbaColor, +} from './shapeColorEncoding'; export { spatialLayerPropsSchema, spatialSublayerSchema, diff --git a/packages/vis/src/SpatialCanvas/shapeColorEncoding.ts b/packages/layers/src/shapeColorEncoding.ts similarity index 59% rename from packages/vis/src/SpatialCanvas/shapeColorEncoding.ts rename to packages/layers/src/shapeColorEncoding.ts index 12d47d96..f48f53b2 100644 --- a/packages/vis/src/SpatialCanvas/shapeColorEncoding.ts +++ b/packages/layers/src/shapeColorEncoding.ts @@ -1,20 +1,32 @@ -import { COLOR_PALLETE } from '@spatialdata/avivatorish'; -import type { TableColumnData } from '@spatialdata/core'; -import type { ShapesLayerConfig } from './types'; +export type ShapeFillColorMode = 'auto' | 'categorical' | 'continuous'; -export type ShapeFillColorMode = NonNullable['mode']; +export type ShapeRgbaColor = [number, number, number, number]; +export type ShapeRgbColor = [number, number, number]; export interface BuildShapeFillColorByFeatureIdOptions { - featureIds: string[]; + featureIds: readonly string[]; + /** Table row index per feature index, resolved by @spatialdata/core association helpers. */ rowIndexByFeatureIndex: Int32Array; - rowIndexByFeatureId?: Map; - column: TableColumnData | undefined; + column: ArrayLike | undefined; mode: ShapeFillColorMode; alpha: number; + categoricalPalette?: readonly ShapeRgbColor[]; + numericRamp?: readonly [ShapeRgbColor, ShapeRgbColor]; } -const NUMERIC_LOW: [number, number, number] = [0, 64, 255]; -const NUMERIC_HIGH: [number, number, number] = [255, 220, 0]; +export const DEFAULT_SHAPE_CATEGORICAL_PALETTE: readonly ShapeRgbColor[] = [ + [0, 0, 255], + [0, 255, 0], + [255, 0, 255], + [255, 0, 0], + [0, 255, 255], + [255, 255, 0], +]; + +export const DEFAULT_SHAPE_NUMERIC_RAMP: readonly [ShapeRgbColor, ShapeRgbColor] = [ + [0, 64, 255], + [255, 220, 0], +]; function normalizeCellValue(value: unknown): string { if (value === null || value === undefined) return ''; @@ -27,10 +39,7 @@ function numericValue(value: string): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -function rgba( - rgb: readonly [number, number, number], - alpha: number -): [number, number, number, number] { +function rgba(rgb: readonly [number, number, number], alpha: number): ShapeRgbaColor { return [rgb[0], rgb[1], rgb[2], alpha]; } @@ -38,7 +47,7 @@ function interpolateRgb( low: readonly [number, number, number], high: readonly [number, number, number], t: number -): [number, number, number] { +): ShapeRgbColor { const clamped = Math.max(0, Math.min(1, t)); return [ Math.round(low[0] + (high[0] - low[0]) * clamped), @@ -66,45 +75,19 @@ export function resolveShapeFillColorMode( return values.every((value) => numericValue(value) !== undefined) ? 'continuous' : 'categorical'; } -function resolveShapeFillColorRowIndex({ - featureId, - featureIndex, - rowIndexByFeatureIndex, - rowIndexByFeatureId, -}: Pick & { - featureId: string; - featureIndex: number; -}): number | undefined { - const fromFeatureIndex = rowIndexByFeatureIndex[featureIndex]; - if (fromFeatureIndex !== undefined && fromFeatureIndex >= 0) { - return fromFeatureIndex; - } - const fromFeatureId = rowIndexByFeatureId?.get(featureId); - return fromFeatureId !== undefined && fromFeatureId >= 0 ? fromFeatureId : undefined; -} - export function buildShapeFillColorByFeatureId({ featureIds, rowIndexByFeatureIndex, - rowIndexByFeatureId, column, mode, alpha, -}: BuildShapeFillColorByFeatureIdOptions): Record { + categoricalPalette = DEFAULT_SHAPE_CATEGORICAL_PALETTE, + numericRamp = DEFAULT_SHAPE_NUMERIC_RAMP, +}: BuildShapeFillColorByFeatureIdOptions): Record { if (!column) return {}; const valuesByFeature = featureIds.map((featureId, featureIndex) => { - // why do we end up with a special function for this? - // there should be a clear and consistent way of associating feature-ids with rows. - // this is also a hot-path in terms of performance, some trepidation around that as well. - // also, I'm not convinced this should be in vis/SpatialCanvas; - // this should be more of a common layer method. - const rowIndex = resolveShapeFillColorRowIndex({ - featureId, - featureIndex, - rowIndexByFeatureIndex, - rowIndexByFeatureId, - }); + const rowIndex = rowIndexByFeatureIndex[featureIndex]; const value = rowIndex !== undefined ? normalizeCellValue(column[rowIndex]) : ''; return { featureId, value }; }); @@ -114,7 +97,7 @@ export function buildShapeFillColorByFeatureId({ if (nonEmptyValues.length === 0) return {}; const resolvedMode = resolveShapeFillColorMode(mode, nonEmptyValues); - const colors: Record = {}; + const colors: Record = {}; if (resolvedMode === 'continuous') { const numericValues = valuesByFeature.map(({ value }) => numericValue(value)); @@ -127,7 +110,7 @@ export function buildShapeFillColorByFeatureId({ const value = numericValues[featureIndex]; if (value === undefined) continue; const t = range === 0 ? 0.5 : (value - min) / range; - colors[featureId] = rgba(interpolateRgb(NUMERIC_LOW, NUMERIC_HIGH, t), alpha); + colors[featureId] = rgba(interpolateRgb(numericRamp[0], numericRamp[1], t), alpha); } return colors; } @@ -140,7 +123,7 @@ export function buildShapeFillColorByFeatureId({ index = categoryIndexByValue.size; categoryIndexByValue.set(value, index); } - const paletteColor = COLOR_PALLETE[index % COLOR_PALLETE.length]; + const paletteColor = categoricalPalette[index % categoricalPalette.length]; colors[featureId] = rgba(paletteColor, alpha); } diff --git a/packages/vis/tests/shapeColorEncoding.spec.ts b/packages/layers/tests/shapeColorEncoding.spec.ts similarity index 79% rename from packages/vis/tests/shapeColorEncoding.spec.ts rename to packages/layers/tests/shapeColorEncoding.spec.ts index 6679a4c4..e452579f 100644 --- a/packages/vis/tests/shapeColorEncoding.spec.ts +++ b/packages/layers/tests/shapeColorEncoding.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildShapeFillColorByFeatureId, resolveShapeFillColorMode, -} from '../src/SpatialCanvas/shapeColorEncoding.js'; +} from '../src/shapeColorEncoding'; describe('shape fill colour encoding', () => { it('maps categorical values deterministically through feature row indices', () => { @@ -70,14 +70,10 @@ describe('shape fill colour encoding', () => { expect(Object.keys(colors).sort()).toEqual(['present']); }); - it('prefers feature-id table row mappings when render data row indices are unavailable', () => { + it('uses already-resolved row alignment from core association helpers', () => { const colors = buildShapeFillColorByFeatureId({ featureIds: ['circle-a', 'circle-b'], - rowIndexByFeatureIndex: new Int32Array([-1, -1]), - rowIndexByFeatureId: new Map([ - ['circle-a', 1], - ['circle-b', 0], - ]), + rowIndexByFeatureIndex: new Int32Array([1, 0]), column: ['type-x', 'type-y'], mode: 'categorical', alpha: 180, @@ -89,28 +85,37 @@ describe('shape fill colour encoding', () => { }); }); - it('prefers feature-index row alignment over colliding numeric feature ids', () => { + it('does not invent rows for unresolved features', () => { const colors = buildShapeFillColorByFeatureId({ - featureIds: ['0', '1', '2'], - rowIndexByFeatureIndex: new Int32Array([0, 1, 2]), - rowIndexByFeatureId: new Map([ - ['1', 0], - ['5', 1], - ['99', 2], - ]), + featureIds: ['matched', 'unmatched'], + rowIndexByFeatureIndex: new Int32Array([1, -1]), column: ['type-a', 'type-b', 'type-c'], mode: 'categorical', alpha: 180, }); expect(colors).toEqual({ - '0': [0, 0, 255, 180], - '1': [0, 255, 0, 180], - '2': [255, 0, 255, 180], + matched: [0, 0, 255, 180], }); }); it('treats mixed values as categorical in auto mode', () => { expect(resolveShapeFillColorMode('auto', ['1', 'tumour'])).toBe('categorical'); }); + + it('allows callers to supply their own categorical palette', () => { + const colors = buildShapeFillColorByFeatureId({ + featureIds: ['a', 'b'], + rowIndexByFeatureIndex: new Int32Array([0, 1]), + column: ['x', 'y'], + mode: 'categorical', + alpha: 200, + categoricalPalette: [[1, 2, 3]], + }); + + expect(colors).toEqual({ + a: [1, 2, 3, 200], + b: [1, 2, 3, 200], + }); + }); }); diff --git a/packages/vis/demo/src/App.tsx b/packages/vis/demo/src/App.tsx index 1db5af71..1f376796 100644 --- a/packages/vis/demo/src/App.tsx +++ b/packages/vis/demo/src/App.tsx @@ -1,14 +1,43 @@ import Sketch from '../../src/Sketch'; +import HeadlessBlobsDemo from './HeadlessBlobsDemo'; + +function getDemoRoute(): 'sketch' | 'headless' { + if (typeof window === 'undefined') { + return 'sketch'; + } + return window.location.pathname.replace(/\/+$/, '').endsWith('/headless') ? 'headless' : 'sketch'; +} + +function DemoNav({ route }: { route: 'sketch' | 'headless' }) { + const linkStyle = (active: boolean) => ({ + color: active ? '#fff' : '#8af', + fontWeight: active ? 600 : 400, + textDecoration: 'none', + fontSize: 13, + }); + + return ( + + ); +} function App() { + const route = getDemoRoute(); + return (

@spatialdata/vis Demo

+
-
- -
+
{route === 'headless' ? : }
); } diff --git a/packages/vis/demo/src/HeadlessBlobsDemo.tsx b/packages/vis/demo/src/HeadlessBlobsDemo.tsx new file mode 100644 index 00000000..f383e55b --- /dev/null +++ b/packages/vis/demo/src/HeadlessBlobsDemo.tsx @@ -0,0 +1,198 @@ +import { SpatialDataProvider, useSpatialData } from '@spatialdata/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SpatialCanvasViewer, type LayerConfig, type ViewState } from '../../src/index'; +import type { ShapesLayerPickEvent } from '../../src/SpatialCanvas/types'; +import { buildHeadlessLayersForCoordinateSystem } from './buildHeadlessLayers'; +import { getLocalBlobsFixtureUrl } from './fixtureUrls'; + +const panelStyle = { + flexShrink: 0, + padding: '10px 12px', + borderBottom: '1px solid #333', + background: '#1e1e1e', + fontSize: 12, +} as const; + +const viewerShellStyle = { + flex: 1, + minHeight: 0, + position: 'relative' as const, +}; + +function HeadlessBlobsViewer({ fixtureUrl }: { fixtureUrl: string }) { + const { spatialData, loading, error } = useSpatialData(); + const coordinateSystems = useMemo(() => spatialData?.coordinateSystems ?? [], [spatialData]); + const tables = spatialData?.getAssociatedTables("shapes", "blobs_multipolygons"); + console.log(tables); + + const [coordinateSystem, setCoordinateSystem] = useState(null); + const [layers, setLayers] = useState>({}); + const [layerOrder, setLayerOrder] = useState([]); + const [viewState, setViewState] = useState(null); + const [lastShapeHover, setLastShapeHover] = useState(null); + + useEffect(() => { + if (!spatialData || coordinateSystems.length === 0) { + return; + } + setCoordinateSystem((prev) => prev ?? coordinateSystems[0] ?? null); + }, [spatialData, coordinateSystems]); + + useEffect(() => { + if (!spatialData || !coordinateSystem) { + return; + } + const built = buildHeadlessLayersForCoordinateSystem(spatialData, coordinateSystem); + setLayers(built.layers); + setLayerOrder(built.layerOrder); + setViewState(null); + }, [spatialData, coordinateSystem]); + + const toggleLayerVisibility = useCallback((layerId: string) => { + setLayers((prev) => { + const existing = prev[layerId]; + if (!existing) return prev; + return { ...prev, [layerId]: { ...existing, visible: !existing.visible } }; + }); + }, []); + + const statusMessage = useMemo(() => { + if (loading) return 'Loading blobs fixture…'; + if (error) return `Failed to load fixture: ${error.message}`; + if (!spatialData) return 'No SpatialData loaded.'; + if (!coordinateSystem) return 'No coordinate system available.'; + if (layerOrder.length === 0) return 'No layers in this coordinate system.'; + return null; + }, [loading, error, spatialData, coordinateSystem, layerOrder.length]); + + return ( +
+
+
+ Headless SpatialCanvasViewer — local{' '} + blobs.zarr (v0.7.2) +
+
+ Fixture: + + {fixtureUrl} + +
+ {coordinateSystems.length > 1 ? ( + + ) : ( +
+ Coordinate system: {coordinateSystem ?? '—'} +
+ )} + {layerOrder.length > 0 ? ( +
+ Layers + {layerOrder.map((layerId) => { + const config = layers[layerId]; + if (!config) return null; + return ( + + ); + })} +
+ ) : null} + {lastShapeHover ? ( +
+            {JSON.stringify(
+              {
+                featureId: lastShapeHover.featureId,
+                featureIndex: lastShapeHover.featureIndex,
+                rowIndex: lastShapeHover.rowIndex,
+              },
+              null,
+              2
+            )}
+          
+ ) : null} +
+ +
+ {statusMessage ? ( +
+ {statusMessage} + {error ? ( +
+ Ensure fixtures exist:{' '} + pnpm test:fixtures:generate:0.7.2 +
+ The dev script also starts a fixture server proxied at{' '} + /test-fixtures. +
+ ) : null} +
+ ) : ( + + )} +
+
+ ); +} + +export default function HeadlessBlobsDemo() { + const fixtureUrl = useMemo(() => getLocalBlobsFixtureUrl(), []); + + return ( + +
+ +
+
+ ); +} diff --git a/packages/vis/demo/src/buildHeadlessLayers.ts b/packages/vis/demo/src/buildHeadlessLayers.ts new file mode 100644 index 00000000..47d7baa8 --- /dev/null +++ b/packages/vis/demo/src/buildHeadlessLayers.ts @@ -0,0 +1,52 @@ +import type { SpatialData } from '@spatialdata/core'; +import type { LayerConfig, LayerType } from '../../src/SpatialCanvas/types'; +import { generateLayerId, getAvailableElements } from '../../src/SpatialCanvas/utils'; + +const STACK_ORDER: LayerType[] = ['image', 'labels', 'shapes', 'points']; + +const COLLECTION_BY_TYPE = { + image: 'images', + labels: 'labels', + shapes: 'shapes', + points: 'points', +} as const; + +export function buildHeadlessLayersForCoordinateSystem( + spatialData: SpatialData, + coordinateSystem: string +): { layers: Record; layerOrder: string[] } { + const available = getAvailableElements(spatialData, coordinateSystem); + const layers: Record = {}; + const layerOrder: string[] = []; + + for (const type of STACK_ORDER) { + const collection = COLLECTION_BY_TYPE[type]; + for (const element of available[collection]) { + const layerId = generateLayerId(element.type, element.key); + const base = { + id: layerId, + elementKey: element.key, + visible: true, + opacity: 1, + }; + // in future we might have some more type-helpers + const config: LayerConfig = + type === 'shapes' + ? { + ...base, + type: 'shapes', + fillColor: [70, 130, 180, 180], + strokeColor: [255, 255, 255, 220], + strokeWidth: 1, + } + : { + ...base, + type, + }; + layers[layerId] = config; + layerOrder.push(layerId); + } + } + + return { layers, layerOrder }; +} diff --git a/packages/vis/demo/src/fixtureUrls.ts b/packages/vis/demo/src/fixtureUrls.ts new file mode 100644 index 00000000..24da73d7 --- /dev/null +++ b/packages/vis/demo/src/fixtureUrls.ts @@ -0,0 +1,13 @@ +/** spatialdata `blobs()` fixture version served locally during vis demo dev. */ +export const LOCAL_BLOBS_FIXTURE_VERSION = '0.7.2'; + +/** + * URL for the local `blobs.zarr` fixture. + * + * During `pnpm --filter @spatialdata/vis dev`, Vite proxies `/test-fixtures` + * to the fixture server (started alongside the demo on the host, default port 38473). + */ +export function getLocalBlobsFixtureUrl(origin?: string): string { + const base = origin ?? (typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1:5173'); + return `${base}/test-fixtures/v${LOCAL_BLOBS_FIXTURE_VERSION}/blobs.zarr`; +} diff --git a/packages/vis/scripts/dev.mjs b/packages/vis/scripts/dev.mjs index 844cf335..68686844 100644 --- a/packages/vis/scripts/dev.mjs +++ b/packages/vis/scripts/dev.mjs @@ -56,7 +56,8 @@ function shutdown(code) { process.on('SIGINT', () => shutdown(130)); process.on('SIGTERM', () => shutdown(143)); -console.log('Starting vis build watch and demo server...'); +console.log('Starting fixture server, vis build watch, and demo server...'); +start('fixtures', ['node', '../../scripts/test-server.js']); start('watch', ['vite', 'build', '--watch']); start('demo', [ 'vite', diff --git a/packages/vis/src/SpatialCanvas/public.ts b/packages/vis/src/SpatialCanvas/public.ts index 27550bf2..e183f059 100644 --- a/packages/vis/src/SpatialCanvas/public.ts +++ b/packages/vis/src/SpatialCanvas/public.ts @@ -16,6 +16,8 @@ export { useSpatialViewState, useViewStateUrl } from './hooks'; export { createSpatialCanvasStore } from './stores'; export type { SpatialCanvasStoreApi } from './stores'; export type * from './types'; +export { SpatialViewer } from './SpatialViewer'; +export type { SpatialViewerProps } from './SpatialViewer'; export { VivSpatialViewer } from './VivSpatialViewer'; export { composeSpatialDeckLayers, diff --git a/packages/vis/src/SpatialCanvas/types.ts b/packages/vis/src/SpatialCanvas/types.ts index deedf64b..e133dbf4 100644 --- a/packages/vis/src/SpatialCanvas/types.ts +++ b/packages/vis/src/SpatialCanvas/types.ts @@ -4,7 +4,11 @@ import type { Matrix4 } from '@math.gl/core'; import type { AnyElement, SpatialElement } from '@spatialdata/core'; -import type { ShapeStrokeWidthUnits, ShapesLayerPickEvent } from '@spatialdata/layers'; +import type { + ShapeFillColorMode, + ShapeStrokeWidthUnits, + ShapesLayerPickEvent, +} from '@spatialdata/layers'; // ============================================ // View State Types @@ -66,7 +70,7 @@ export interface ShapesLayerConfig extends BaseLayerConfig { fillColor?: [number, number, number, number]; fillColorByColumn?: { columnName: string; - mode: 'auto' | 'categorical' | 'continuous'; + mode: ShapeFillColorMode; }; strokeColor?: [number, number, number, number]; strokeWidth?: number; diff --git a/packages/vis/src/SpatialCanvas/useLayerData.ts b/packages/vis/src/SpatialCanvas/useLayerData.ts index af2ccd78..4590c152 100644 --- a/packages/vis/src/SpatialCanvas/useLayerData.ts +++ b/packages/vis/src/SpatialCanvas/useLayerData.ts @@ -42,12 +42,14 @@ import { unionBoundsList, } from '@spatialdata/core'; import { + EMPTY_SHAPE_FEATURE_STATE_RUNTIME, type ShapeFeatureRenderDatum, + type ShapeFeatureStateRuntime, + type ShapeFillColorMode, type ShapesPrebuiltData, buildShapeFeatureStateRuntime, + buildShapeFillColorByFeatureId, buildShapesPrebuiltData, - EMPTY_SHAPE_FEATURE_STATE_RUNTIME, - type ShapeFeatureStateRuntime, resolveShapeFeatureFromPick, resolveShapeTooltipFromPickInfo, resolveShapeTooltipRowIndex, @@ -63,7 +65,6 @@ import { createImageLoader } from './renderers/imageRenderer'; import { renderLabelsLayer } from './renderers/labelsRenderer'; import { type PointData, renderPointsLayer } from './renderers/pointsRenderer'; import { loadShapesData, renderShapesLayer } from './renderers/shapesRenderer'; -import { type ShapeFillColorMode, buildShapeFillColorByFeatureId } from './shapeColorEncoding'; import type { AvailableElement, ElementsByType, LayerConfig, ShapesLayerConfig } from './types'; export interface ImageLoaderData { @@ -306,7 +307,6 @@ async function loadShapeFillColorData({ fillColorByFeatureId: buildShapeFillColorByFeatureId({ featureIds: renderData.featureIds, rowIndexByFeatureIndex: renderData.rowIndexByFeatureIndex, - rowIndexByFeatureId: rows.rowIndexByFeatureId, column: rows.extraColumns?.[0], mode: fillColorByColumn.mode, alpha: getShapeFillColorAlpha(config), @@ -750,7 +750,9 @@ export function useLayerData( if (metadata?.channels) { const Channels = metadata.channels; const isRgb = guessRgb({ - Pixels: { Channels: Channels.map((c: any) => ({ Name: c.label })) }, + Pixels: { + Channels: Channels.map((c: { label?: string }) => ({ Name: c.label })), + }, }); if (isRgb) { diff --git a/packages/vis/src/index.ts b/packages/vis/src/index.ts index c518cff0..28a8082e 100644 --- a/packages/vis/src/index.ts +++ b/packages/vis/src/index.ts @@ -23,6 +23,7 @@ export { createSpatialCanvasStore, useSpatialViewState, useViewStateUrl, + SpatialViewer, composeSpatialDeckLayers, shouldRenderInternalTooltip, shouldAutoFitSpatialView, @@ -39,6 +40,7 @@ export type { AvailableElement, ElementsByType, SpatialCanvasProps, + SpatialViewerProps, SpatialCanvasViewerProps, SpatialCanvasViewerRenderTooltip, SpatialFeatureTooltipData, diff --git a/packages/vis/tests/index.spec.tsx b/packages/vis/tests/index.spec.tsx index 8f6fd77c..cd9e6ceb 100644 --- a/packages/vis/tests/index.spec.tsx +++ b/packages/vis/tests/index.spec.tsx @@ -8,6 +8,8 @@ describe('@spatialdata/vis', () => { expect(typeof VisExports.SpatialCanvas).toBe('function'); expect(VisExports.SpatialCanvasViewer).toBeDefined(); expect(typeof VisExports.SpatialCanvasViewer).toBe('function'); + expect(VisExports.SpatialViewer).toBeDefined(); + expect(typeof VisExports.SpatialViewer).toBe('function'); }); it('should export named components', () => { diff --git a/packages/vis/vite.config.demo.ts b/packages/vis/vite.config.demo.ts index ad6d1ffb..291727fa 100644 --- a/packages/vis/vite.config.demo.ts +++ b/packages/vis/vite.config.demo.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'; import path from 'node:path'; import { createRequire } from 'node:module'; import { createWorkspaceSourceAliases } from '../../vite.config.base'; +import { fixtureServerOrigin } from '../../scripts/fixture-server-port.mjs'; // https://vitejs.dev/config/ const workspaceRoot = path.resolve(__dirname, '../..'); @@ -26,5 +27,11 @@ export default defineConfig({ port: 5173, strictPort: true, open: false, + proxy: { + '/test-fixtures': { + target: fixtureServerOrigin(), + changeOrigin: true, + }, + }, }, }); diff --git a/scripts/fixture-server-defaults.mjs b/scripts/fixture-server-defaults.mjs new file mode 100644 index 00000000..b1efec9e --- /dev/null +++ b/scripts/fixture-server-defaults.mjs @@ -0,0 +1,2 @@ +/** Default port for the local test-fixture static server (browser-safe constant). */ +export const DEFAULT_FIXTURE_SERVER_PORT = 38473; diff --git a/scripts/fixture-server-port.mjs b/scripts/fixture-server-port.mjs new file mode 100644 index 00000000..f53def47 --- /dev/null +++ b/scripts/fixture-server-port.mjs @@ -0,0 +1,21 @@ +import { DEFAULT_FIXTURE_SERVER_PORT } from './fixture-server-defaults.mjs'; + +/** + * Fixture server port for Node tooling (test server, Vite proxy, integration tests). + * + * Override with SPATIALDATA_FIXTURE_PORT (or PORT for compatibility). + */ +export const FIXTURE_SERVER_PORT = Number( + process.env.SPATIALDATA_FIXTURE_PORT ?? process.env.PORT ?? DEFAULT_FIXTURE_SERVER_PORT +); + +if (!Number.isInteger(FIXTURE_SERVER_PORT) || FIXTURE_SERVER_PORT < 1 || FIXTURE_SERVER_PORT > 65535) { + throw new Error( + `Invalid fixture server port: ${FIXTURE_SERVER_PORT}. ` + + 'Set SPATIALDATA_FIXTURE_PORT (or PORT) to an integer between 1 and 65535.' + ); +} + +export function fixtureServerOrigin(host = '127.0.0.1') { + return `http://${host}:${FIXTURE_SERVER_PORT}`; +} diff --git a/scripts/test-server.js b/scripts/test-server.js index 83f26d7d..829056ac 100755 --- a/scripts/test-server.js +++ b/scripts/test-server.js @@ -11,13 +11,14 @@ import { readFile, stat, readdir } from 'node:fs/promises'; import { join, extname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; +import { FIXTURE_SERVER_PORT } from './fixture-server-port.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const projectRoot = resolve(__dirname, '..'); const fixturesDir = join(projectRoot, 'test-fixtures'); -const PORT = process.env.PORT || 8080; +const PORT = FIXTURE_SERVER_PORT; /** * Get MIME type for a file based on extension diff --git a/tests/integration/fixtures.test.ts b/tests/integration/fixtures.test.ts index 0b90df79..5116c00b 100644 --- a/tests/integration/fixtures.test.ts +++ b/tests/integration/fixtures.test.ts @@ -9,6 +9,7 @@ import { execSync } from 'node:child_process'; import { existsSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { fixtureServerOrigin } from '../../scripts/fixture-server-port.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -129,7 +130,7 @@ describe('Integration Tests - HTTP smoke test', () => { beforeAll(() => { ensureFixtures(version); - fixtureUrl = `http://localhost:8080/v${version}/blobs.zarr`; + fixtureUrl = `${fixtureServerOrigin('localhost')}/v${version}/blobs.zarr`; }); it('should still load a spatialdata store over HTTP', async () => {