diff --git a/DESIGN.md b/DESIGN.md index 2778b7a..26f7cc1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1343,9 +1343,10 @@ A checkpoint is **directly readable by a browser** over HTTP Range, with no back (§14.2). `ZIP_STORED` is what makes that possible — a zarr chunk, and equally a shape parquet, is a contiguous byte span — and four write-time steps in `_write_browser_reader_support` serve it. The first three are what the serverless viewer -needs to exist at all, not an optimization: a checkpoint written before them is rejected -on open (§14.2), because a Zarr v3 store carries no child index and the reader could not -even name the table. +needs to exist at all, not an optimization: without them the reader falls back to reading +the store as plain SpatialData (§14.2), which draws no boundaries and reads genes from the +whole matrix, and without consolidated metadata it could not even name the table, because +a Zarr v3 store carries no child index. - **`_shard_rasters`** rewrites image/label arrays with the Zarr v3 sharding codec (inner chunk `_SHARD_INNER` 512, shard `_SHARD_SIZE` 4096), region-by-region so @@ -1435,6 +1436,18 @@ call `api.ts` directly — `useArrowField`, `useVivImageLayer`, `usePolygonBbox` Everything downstream (palettes, point styling, channel shaders, legends, minimap, invert axes, backdrop) already worked off plain typed arrays and is untouched. +The same reader opens a `.zarr/` folder (a URL whose path ends in `/`), and a store this +app never saved. A folder is read one object per key — `FetchStore` for a public folder, +or `HostSignedFolderStore` under an embed host whose bucket needs a presigned URL per +object (docs/EMBED_PROTOCOL.md "Folder stores"). A store without the `viewer/` sidecar +(nf-core/sopa or spatialdata-io output) gets one derived in the browser by +`plainSpatialData.deriveSidecar`: the first table with `obsm/spatial`, and each image's +manifest built from its OME metadata, placed against the cells the way `imaging.pixel_to_world` +reconciles them. Contrast defaults come from the coarsest level, as `_channel_norm` computes +them. Its default displays follow `manager.auto_displays`. What the backend bakes and +the browser cannot cheaply derive stays missing: the shapes spatial index (so no boundaries) +and the CSC gene mirror (so a gene reads the whole CSR matrix). + Details that make it work: - `checkpointSource` materializes the **same Arrow schemas** `transport/arrow.py` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5c96fcd..f3b09a2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -119,7 +119,11 @@ packages/viewer/ @cirrobio/spatial-viewer — the deck.gl canvases and the chec helpers a host's own controls need src/data/ the DataSource contract the canvas renders through, the DataSourceProvider, and checkpointSource (a .zarr.zip read directly with zarrita over HTTP - Range — the serverless viewer, DESIGN §14.2). parquetShapes.ts + + Range, or a .zarr/ folder — the serverless viewer, DESIGN §14.2). + folderStore.ts reads a folder whose objects an embed host signs one by + one (docs/EMBED_PROTOCOL.md "Folder stores"); plainSpatialData.ts derives + the viewer sidecar and default displays for a store this app did not + save (sopa, spatialdata-io output). parquetShapes.ts + wkbGeoArrow.ts are the boundary half: the shape file is GeoParquet, not zarr, so it is range-queried with hyparquet against its covering index src/types.ts the display model (DisplaySpec/DisplayEncoding/SessionFields/ImageInfo) @@ -181,6 +185,8 @@ Component-level notes: [`backend/README.md`](backend/README.md), | Change where a save writes, what the file is named, or what the session is called | `backend/app/main.py` (`_validated_destination`, `_validated_name`) + `deps.py` (`default_save_path`) + `sessions/session.py` (`rename`, `_run_load`) + `frontend/src/components/SaveCheckpointDialog.tsx` | below | | Change how rendered plot figures are stored, served or shown | `backend/app/persistence/store.py` (`_write_figures`, `read_figure`, `figure_index`) + `sessions/session.py` (`figure`, `figure_index`, `figures_to_persist`) + `frontend/src/lib/figures.ts` + `components/PlotGallery.tsx` / `FigureLightbox.tsx` / `PlotDetail.tsx` | below | | Change what the serverless viewer can read from a checkpoint | `backend/app/persistence/store.py` (`_write_viewer_sidecar`, the writer half) + `packages/viewer/src/data/checkpointSource.ts` (the reader half) — the two must move together | [DESIGN.md](DESIGN.md) §14.1–14.2, [docs/CHECKPOINT_FORMAT.md](docs/CHECKPOINT_FORMAT.md) §4 | +| Change how a store without the sidecar (plain SpatialData) is read — which table, where images sit, the default displays | `packages/viewer/src/data/plainSpatialData.ts` (its image placement ports `imaging.pixel_to_world`, its displays `manager.auto_displays`; keep them agreeing) + `plainSpatialData.test.ts` | [docs/CHECKPOINT_FORMAT.md](docs/CHECKPOINT_FORMAT.md) §9 | +| Change how a `.zarr/` folder is read under an embed host (signing, listing) | `packages/viewer/src/data/folderStore.ts` + `frontend/src/data/embedBridge.ts` (`embedFolderAccess`) + [docs/EMBED_PROTOCOL.md](docs/EMBED_PROTOCOL.md) | [docs/EMBED_PROTOCOL.md](docs/EMBED_PROTOCOL.md) | | Change how cell boundaries are indexed or range-queried | `backend/app/persistence/store.py` (`_index_shapes`, `_row_group_rows`, `_selectivity` — the writer half) + `packages/viewer/src/data/parquetShapes.ts` and `wkbGeoArrow.ts` (the reader half). A change to the on-disk index must keep `test_e2e.run_shape_index_check` passing: it re-derives the pruning from the file and compares it against a brute-force row scan | [DESIGN.md](DESIGN.md) §14.1–14.2, [docs/CHECKPOINT_FORMAT.md](docs/CHECKPOINT_FORMAT.md) §4.4 | | Change the shape of `app_state`, the `viewer/` sidecar, `X_csc`, or `index.json` | `backend/app/schemas/checkpoint/*.schema.json` (the JSON Schema is validated against on every write) + [docs/CHECKPOINT_FORMAT.md](docs/CHECKPOINT_FORMAT.md) in the same commit — `sds-governance/checks/check_checkpoint_schema_docs.py` fails the build otherwise | [docs/CHECKPOINT_FORMAT.md](docs/CHECKPOINT_FORMAT.md) | | Add a render-path call the canvas makes | `packages/viewer/src/data/types.ts` (the `DataSource` interface), then **both** `frontend/src/data/apiSource.ts` and `packages/viewer/src/data/checkpointSource.ts` | [DESIGN.md](DESIGN.md) §14.2 | diff --git a/docs/CHECKPOINT_FORMAT.md b/docs/CHECKPOINT_FORMAT.md index b275540..0174adb 100644 --- a/docs/CHECKPOINT_FORMAT.md +++ b/docs/CHECKPOINT_FORMAT.md @@ -647,11 +647,14 @@ granularities: a backend-less reader has no data to fall back to if the sidecar it finds is newer than the version it understands, so this app's own reader **refuses** to open a checkpoint whose `sidecar_version` exceeds what it was built - against, rather than guessing at an unknown shape. A checkpoint with **no** - `viewer/` group at all (written before the sidecar existed) is refused - outright by a backend-less reader for the same reason — Zarr v3 has no child - index, so without the sidecar's `table_keys` a reader can't even enumerate - what tables exist. Additive keys do **not** bump this version: `figures` + against, rather than guessing at an unknown shape. A store with **no** + `viewer/` group at all (written before the sidecar existed, or not written by + this app) is read as plain SpatialData: the backend-less reader derives what the + sidecar would have said from the consolidated metadata (the first table with + `obsm/spatial`, each image's manifest, identity `coords_transform`; see + `packages/viewer/src/data/plainSpatialData.ts`), and refuses the store only when + that finds nothing to draw. Consolidated metadata is then required, because Zarr + v3 has no child index to enumerate tables from otherwise. Additive keys do **not** bump this version: `figures` (§4.3) was added without one, because an older reader ignoring a key it does not know still renders the file correctly, while a bump would make it refuse a file it could have read. diff --git a/docs/EMBED_PROTOCOL.md b/docs/EMBED_PROTOCOL.md index 98ca706..78483d7 100644 --- a/docs/EMBED_PROTOCOL.md +++ b/docs/EMBED_PROTOCOL.md @@ -60,11 +60,17 @@ config (`SpatialDataDisplay` in the dashboard package) — same field names displays: Array<{ id: string; name: string } & DisplayPayload>, // saved displays in app_state order obsColumns: Array<{ name: string; kind: 'categorical' | 'numeric' }>, images: Array<{ element: string; channelNames: string[]; isRgb: boolean; - contrastRange: [number, number][] }>, + contrastRange: [number, number][]; // per channel [min, max] of the data + contrastLimits: [number, number][] }>, // per channel default contrast (1.1.0+) obsmKeys: Array<{ key: string; nComponents: number }>, + shapes: string[], // polygon shape elements the canvas can draw as boundaries (1.1.0+) } } ``` + `contrastLimits` is the contrast a channel shows when the display sets none, so a + host's contrast control can start where the canvas does. `shapes` lists the boundary + sets a display's `shapes_layer` may name. Both were added in 1.1.0; a host should treat + them as optional when it may talk to an older viewer. 2. `display-changed` — debounced (<=500ms) whenever the ACTIVE display's encoding or viewport changes in-iframe (user pans/zooms or uses in-canvas controls): @@ -124,6 +130,41 @@ not retried forever. Concurrent reads that all expire at the same moment share one re-sign rather than each asking for their own, and a request that goes unanswered for 15s rejects. +### Folder stores + +A checkpoint URL whose **path** ends in `/` names a `.zarr/` folder rather than a +`.zarr.zip` (1.1.0+). An object store has no single presigned URL for a folder, so in embed +mode the viewer asks the host to sign each object key and to list the folder. Keys and +prefixes are relative to the store root. The folder URL itself is never fetched, so any +URL under the folder with a trailing-slash path works (a presign of the folder key is +convenient). + +Viewer -> parent: +```ts +{ source: 'sds-embed', version: 1, type: 'sign-keys', requestId: string, keys: string[] } +{ source: 'sds-embed', version: 1, type: 'list-keys', requestId: string, prefix: string } +``` + +Parent -> viewer: +```ts +{ source: 'cirro-dashboard', version: 1, type: 'signed-keys', + requestId: string, urls: string[] | null } // same order as keys; null = failed +{ source: 'cirro-dashboard', version: 1, type: 'listed-keys', + requestId: string, keys: string[] | null } // every key under prefix; null = failed +``` + +The viewer lists the whole folder once (`prefix: ''`) when it opens, and answers a key +absent from that listing as missing without fetching it. That keeps S3's 403-for-a-missing-key +(a presigner without ListBucket) from reading as an expired signature. It batches the +keys requested in one tick into one `sign-keys`, reuses a signature for four minutes, and +re-signs a listed key once if a GET answers 401/403. Requests unanswered for 15s reject. + +A folder need not have been saved by this app. The viewer opens any consolidated Zarr v3 +SpatialData store and derives the table, image manifests and default displays itself +(`packages/viewer/src/data/plainSpatialData.ts`). When the store holds nothing it can show +(no table with `obsm/spatial`, Zarr v2, no consolidated metadata, not zarr at all), it +posts `error` with a message saying which. + ## Handshake order 1. Parent creates iframe with `embed=1`. @@ -136,26 +177,41 @@ unanswered for 15s rejects. ## Dashboard node contract (implemented in @cirrobio/dashboard) -- Node type id: `'spatialdata'` (NODE_TYPE.spatialdata). +The host side lives in Cirro-portal's `packages/dashboard/src/views/spatialdata/`. The +viewer itself is deployed as the Cirro-tools `spatialdata` tool (this repo's release +`viewer-dist.tar.gz`, served unmodified at `/tools/spatialdata/`). + +- Node type id: `'spatialdata'` (`NODE_TYPE.spatialData`). - Config type `SpatialDataConfig`: ```ts interface SpatialDataConfig { + title: string; datasetId: string; - datasetName?: string; - path: string; // dataset-relative path to the .zarr.zip - sizeBytes?: number; - title?: string; + datasetName: string; + path: string; // dataset-relative path to the .zarr.zip or .zarr folder display?: DisplayPayload & { id?: string }; // persisted display settings } ``` -- New OPTIONAL host capability in SqlHostCapabilities: + A node added from the dashboard's "+ Spatial" picker has no `display` until the viewer + first opens, and then saves the one it starts on. +- Optional host capability in `SqlHostCapabilities`: ```ts -/** Base URL of a deployed Spatial Data Studio serverless viewer build - * (directory containing index.html). When absent, spatialdata nodes render - * an explanatory placeholder instead of an iframe. */ -spatialViewerUrl?: () => Promise | string; +/** Directory of a deployed Spatial Data Studio viewer (holding index.html). When + * absent, or when datasetFileUrl is absent, spatialdata nodes render an explanatory + * placeholder instead of an iframe. */ +readonly spatialViewerUrl?: string; ``` -- File detection predicate (do NOT widen isTabularFile): + The checkpoint URL, and every `refresh-checkpoint-url` answer, is signed through the + existing `datasetFileUrl(projectId, datasetId, path)` capability. +- File and folder detection (do NOT widen isTabularFile): ```ts isSpatialDataFile(path) === /\.zarr\.zip$/i.test(path) // covers .sdata.zarr.zip +isSpatialDataFolder(path) === /\.zarr\/?$/i.test(path) // any .zarr folder ``` + A `.zarr` folder is offered whether or not it is SpatialData; the viewer's `error` says + when it is not something it can show. The host signs a folder's objects through + `datasetFileUrl` and lists them from the dataset's file manifest. +- Persistence: settings-panel edits are always saved to the node. Viewer-side + `display-changed` events (camera moves, display switches) are saved only while the + tile's settings panel is open, so exploring a tile that is not locked + (`lock_view`) leaves its saved framing alone. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ffa511f..59fce84 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -209,6 +209,17 @@ and save it again to add them. Any host that serves the file with HTTP range req will do — put the built app, your `.zarr.zip` files, and a small `index.json` listing them in one folder and the page becomes a browsable collection you can switch between. +The viewer also opens SpatialData stores it did not save: a `.zarr.zip`, or a `.zarr/` +folder (point `?checkpoint=` at the folder with a trailing `/`), such as nf-core/sopa or +spatialdata-io output. It finds the table, places the images against the cells, and starts +on the displays a new session would: the spatial view colored by the first categorical +column over the first image, and a UMAP (or other embedding) view when the table has one. +Such a store must be Zarr v3 with consolidated metadata (what spatialdata 0.3 and later +write), and its table needs `obsm["spatial"]`; when one of those is missing the viewer +says which instead of opening an empty view. Two things need a store saved by this app: +cell boundaries are not drawn, and coloring by a gene reads the table's whole expression +matrix rather than one gene's slice (a few seconds on a 2 GB sopa store). + The **Plots** view works here too: the figures saved with the checkpoint are in the file, so the grid, the fullscreen view and the SVG/PDF/PNG downloads all work with no backend. The left panel opens collapsed and holds one thing: the history of the analysis that diff --git a/frontend/package.json b/frontend/package.json index c446838..44566c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "spatial-data-studio-frontend", "private": true, - "version": "1.0.2", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45da400..559ba7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,7 @@ import { useCheckpointSession } from './data/useCheckpointSession'; import { checkpointUrlFromLocation, fetchCheckpointIndex, isEmbedMode, openCheckpointPath, } from './data/checkpointIndex'; -import { requestFreshCheckpointUrl, useEmbedBridge } from './data/embedBridge'; +import { embedFolderAccess, requestFreshCheckpointUrl, useEmbedBridge } from './data/embedBridge'; import CheckpointIndexPage from './components/CheckpointIndexPage'; import { useSSE } from './hooks/useSSE'; import { useUrlViewSync } from './hooks/useUrlViewSync'; @@ -53,7 +53,12 @@ export default function App() { const embed = useMemo(isEmbedMode, []) && checkpointUrl !== null; // An embed host signs checkpoint URLs for minutes at a time, so the reader // re-signs through the host rather than dying partway through a long session. - const checkpoint = useCheckpointSession(checkpointUrl, embed ? requestFreshCheckpointUrl : undefined); + // A `.zarr/` folder has no single URL to sign, so the host signs each of its objects. + const checkpoint = useCheckpointSession( + checkpointUrl, + embed ? requestFreshCheckpointUrl : undefined, + embed ? embedFolderAccess : undefined, + ); useEmbedBridge(embed, checkpoint); // Shareable view links, serverless only and never under an embed host — there the // dashboard owns display state over postMessage and a URL writer would race it. diff --git a/frontend/src/data/embedBridge.ts b/frontend/src/data/embedBridge.ts index b967c5f..8743564 100644 --- a/frontend/src/data/embedBridge.ts +++ b/frontend/src/data/embedBridge.ts @@ -7,6 +7,7 @@ import { useEffect, useRef } from 'react'; import { useAppStore } from '../store/sessionStore'; import { + SHAPE_ANNOTATIONS_ELEMENT, isEmbeddingDisplay, isSpatialDisplay, type DisplayEncoding, @@ -16,6 +17,7 @@ import { type SpatialDisplaySpec, type Viewport, } from '@cirrobio/spatial-viewer'; +import type { FolderAccess } from '@cirrobio/spatial-viewer'; import type { CheckpointSession } from './useCheckpointSession'; const EMBED_MESSAGE_SOURCE = 'sds-embed'; @@ -37,8 +39,11 @@ export interface EmbedInventory { channelNames: string[]; isRgb: boolean; contrastRange: [number, number][]; + contrastLimits: [number, number][]; }>; obsmKeys: Array<{ key: string; nComponents: number }>; + // Polygonal shape elements the canvas can draw as cell boundaries. + shapes: string[]; } type ViewerMessage = @@ -46,6 +51,8 @@ type ViewerMessage = | { type: 'display-changed'; display: EmbedDisplayPayload } | { type: 'search-vars-result'; requestId: string; names: string[] } | { type: 'refresh-checkpoint-url'; requestId: string } + | { type: 'sign-keys'; requestId: string; keys: readonly string[] } + | { type: 'list-keys'; requestId: string; prefix: string } | { type: 'error'; message: string }; type ParentMessage = @@ -71,48 +78,78 @@ function isParentMessage(data: unknown): data is ParentMessage { ); } -// How long the host gets to answer a re-sign request before the read that +// How long the host gets to answer a signing or listing request before the read that // triggered it gives up. Generous: the host may round-trip to its own API. -const CHECKPOINT_URL_TIMEOUT_MS = 15_000; +const HOST_REPLY_TIMEOUT_MS = 15_000; -let checkpointUrlRequests = 0; +let hostRequests = 0; -/** - * Ask the embed host for a freshly signed checkpoint URL. Wired into the - * checkpoint reader (`CheckpointUrlRefresher`), which calls this when a range - * read comes back expired — the host's URLs are short-lived and a session - * outlives them. Each call listens for its own `requestId` only, so concurrent - * requests can't cross-resolve. - */ -export function requestFreshCheckpointUrl(): Promise { - checkpointUrlRequests += 1; - const requestId = `checkpoint-url-${checkpointUrlRequests}`; +/** Post a request to the embed host and resolve with `pick(reply)` from the host's + * reply of `replyType` carrying the same `requestId`; each call listens for its own + * id only, so concurrent requests can't cross-resolve. `pick` returning undefined + * means the host could not answer. */ +function requestFromHost( + request: { type: 'refresh-checkpoint-url' | 'sign-keys' | 'list-keys' } & Record, + replyType: string, + pick: (reply: Record) => T | undefined, + failure: string, +): Promise { + hostRequests += 1; + const requestId = `${request.type}-${hostRequests}`; return new Promise((resolve, reject) => { const onMessage = (event: MessageEvent) => { - const msg = event.data as { - source?: unknown; version?: unknown; type?: unknown; requestId?: unknown; url?: unknown; - } | null; + const msg = event.data as Record | null; if ( !msg || msg.source !== PARENT_MESSAGE_SOURCE || msg.version !== PROTOCOL_VERSION || - msg.type !== 'checkpoint-url' || msg.requestId !== requestId + msg.type !== replyType || msg.requestId !== requestId ) return; cleanup(); - if (typeof msg.url === 'string' && msg.url) resolve(msg.url); - else reject(new Error('The dashboard could not refresh the checkpoint URL')); + const value = pick(msg); + if (value === undefined) reject(new Error(failure)); + else resolve(value); }; const timer = setTimeout(() => { cleanup(); - reject(new Error('Timed out waiting for a refreshed checkpoint URL')); - }, CHECKPOINT_URL_TIMEOUT_MS); + reject(new Error(`${failure} (timed out)`)); + }, HOST_REPLY_TIMEOUT_MS); function cleanup(): void { window.removeEventListener('message', onMessage); clearTimeout(timer); } window.addEventListener('message', onMessage); - postToParent({ type: 'refresh-checkpoint-url', requestId }); + postToParent({ ...request, requestId } as ViewerMessage); }); } +/** + * Ask the embed host for a freshly signed checkpoint URL. Wired into the + * checkpoint reader (`CheckpointUrlRefresher`), which calls this when a range + * read comes back expired — the host's URLs are short-lived and a session + * outlives them. + */ +export function requestFreshCheckpointUrl(): Promise { + return requestFromHost( + { type: 'refresh-checkpoint-url' }, 'checkpoint-url', + (reply) => (typeof reply.url === 'string' && reply.url ? reply.url : undefined), + 'The dashboard could not refresh the checkpoint URL', + ); +} + +/** A `.zarr/` folder has no single URL to sign: the host signs each object and lists + * the folder on request (docs/EMBED_PROTOCOL.md, "Folder stores"). */ +export const embedFolderAccess: FolderAccess = { + signKeys: (keys) => requestFromHost( + { type: 'sign-keys', keys }, 'signed-keys', + (reply) => (Array.isArray(reply.urls) && reply.urls.length === keys.length ? reply.urls as string[] : undefined), + 'The dashboard could not sign the store\'s files', + ), + listKeys: (prefix) => requestFromHost( + { type: 'list-keys', prefix }, 'listed-keys', + (reply) => (Array.isArray(reply.keys) ? reply.keys as string[] : undefined), + 'The dashboard could not list the store\'s files', + ), +}; + function payloadFromSpec(spec: DisplaySpec): EmbedDisplayPayload { return spec.type === 'spatial_canvas' ? { kind: 'spatial_canvas', encoding: spec.encoding, viewport: spec.viewport } @@ -232,13 +269,20 @@ export function useEmbedBridge(enabled: boolean, checkpoint: CheckpointSession): channelNames: info.channel_names, isRgb: info.is_rgb ?? false, contrastRange: info.contrast_range ?? info.contrast_limits ?? [], + contrastLimits: info.contrast_limits ?? [], }; } catch { // A broken image element degrades its inventory entry, not the handshake. - return { element, channelNames: [], isRgb: false, contrastRange: [] }; + return { element, channelNames: [], isRgb: false, contrastRange: [], contrastLimits: [] }; } }), ); + // The boundary sets the canvas can draw, picked the way its own overlay picks them. + const elements = source.getElements ? await source.getElements().catch(() => null) : null; + const shapes = (elements?.shapes ?? []) + .filter((s) => s.name !== SHAPE_ANNOTATIONS_ELEMENT) + .filter((s) => s.geometry.some((g) => g === 'Polygon' || g === 'MultiPolygon')) + .map((s) => s.name); if (cancelled) return; // Prime the echo guard so mounting the first display doesn't immediately // repeat what the inventory already carries. @@ -255,6 +299,7 @@ export function useEmbedBridge(enabled: boolean, checkpoint: CheckpointSession): obsColumns: state.fields.obs.map(({ name, kind }) => ({ name, kind })), images, obsmKeys: state.fields.obsm.map((f) => ({ key: f.name, nComponents: f.n_components })), + shapes, }, }); })(); diff --git a/frontend/src/data/useCheckpointSession.ts b/frontend/src/data/useCheckpointSession.ts index 52d4a3e..3146efe 100644 --- a/frontend/src/data/useCheckpointSession.ts +++ b/frontend/src/data/useCheckpointSession.ts @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { useAppStore } from '../store/sessionStore'; import { formatError, isEmbeddingDisplay, isSpatialDisplay, openCheckpoint, - type CheckpointUrlRefresher, type DataSource, + type CheckpointUrlRefresher, type DataSource, type FolderAccess, } from '@cirrobio/spatial-viewer'; import type { AppState, SessionState } from '../types'; import { @@ -17,7 +17,8 @@ import { } from '../lib/urlViewState'; function displayName(url: string): string { - const last = url.split('/').pop() ?? url; + // A `.zarr/` folder URL ends in `/`; name it by the folder. + const last = url.split('?')[0].replace(/\/+$/, '').split('/').pop() ?? url; return decodeURIComponent(last.split('?')[0]) || 'Checkpoint'; } @@ -29,10 +30,12 @@ export interface CheckpointSession { /** Open `target` and install it as the active (read-only) session. Returns the data * source the canvas should read through. `refreshUrl` (embed mode) re-signs the - * checkpoint URL when it expires mid-session. */ + * checkpoint URL when it expires mid-session, and `folder` (embed mode) signs and lists + * the objects of a `.zarr/` folder. */ export function useCheckpointSession( target: string | File | null, refreshUrl?: CheckpointUrlRefresher, + folder?: FolderAccess, ): CheckpointSession { const [source, setSource] = useState(null); const [error, setError] = useState(null); @@ -48,7 +51,7 @@ export function useCheckpointSession( setLoading(true); setError(null); - openCheckpoint(target, refreshUrl) + openCheckpoint(target, refreshUrl, folder) .then(({ source: opened, appState, fields, figures }) => { if (stale) return; const saved = applyBackgroundFromUrl(appState as unknown as AppState); diff --git a/package-lock.json b/package-lock.json index 4953a06..93f1f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spatial-data-studio", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spatial-data-studio", - "version": "1.0.2", + "version": "1.1.0", "workspaces": [ "packages/viewer", "frontend", @@ -30,7 +30,7 @@ }, "frontend": { "name": "spatial-data-studio-frontend", - "version": "1.0.2", + "version": "1.1.0", "dependencies": { "@cirrobio/spatial-viewer": "*", "@deck.gl/core": "^9.0.0", @@ -10409,7 +10409,7 @@ }, "packages/viewer": { "name": "@cirrobio/spatial-viewer", - "version": "1.0.2", + "version": "1.1.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "hyparquet": "^1.28.2", diff --git a/package.json b/package.json index 077adcd..3e5e378 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spatial-data-studio", "private": true, - "version": "1.0.2", + "version": "1.1.0", "workspaces": [ "packages/viewer", "frontend", diff --git a/packages/viewer/README.md b/packages/viewer/README.md index dc408d6..de45da7 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -12,8 +12,10 @@ instead of embedding the whole app in an iframe. **One source of truth for the c tools of `selectionShapes`) and shape-annotation editing. - `CanvasHostProvider` — the contract a host implements to drive them (see below). - `DataSourceProvider` + `openCheckpoint` — the read surface the canvases render - through, and the `.zarr.zip` reader that implements it over HTTP Range with zarrita - and no backend at all. `openCheckpoint` also returns the file's `app_state`, its field + through, and the reader that implements it with zarrita and no backend at all: a + `.zarr.zip` over HTTP Range, or a `.zarr/` folder (`HostSignedFolderStore` when each + object needs its own presigned URL). A store this app did not save is read as plain + SpatialData, with its table, image manifests and default displays derived on open. `openCheckpoint` also returns the file's `app_state`, its field inventory, and its `figures` index (which plots it carries a rendered figure for); `DataSource.getPlotFigure(plotId, format)` reads one as a blob, so a host can show the saved SVG/PDF/PNG figures without a backend. diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 9ccc2a1..bff5e0f 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -1,6 +1,6 @@ { "name": "@cirrobio/spatial-viewer", - "version": "1.0.2", + "version": "1.1.0", "description": "Host-agnostic WebGL spatial/embedding canvases and the .zarr.zip checkpoint reader from Spatial Data Studio.", "license": "SEE LICENSE IN LICENSE.md", "sideEffects": false, diff --git a/packages/viewer/src/data/checkpointSource.ts b/packages/viewer/src/data/checkpointSource.ts index 2acd4cd..50518b3 100644 --- a/packages/viewer/src/data/checkpointSource.ts +++ b/packages/viewer/src/data/checkpointSource.ts @@ -1,7 +1,11 @@ -// DataSource backed by a `.zarr.zip` checkpoint read directly over HTTP Range with -// zarrita — no backend (DESIGN §14). The zip is `ZIP_STORED`, so a zarr chunk is a -// contiguous byte span the reader can fetch on its own; `ZipFileStore` pulls the -// central directory once and range-reads entries after that. +// DataSource backed by a SpatialData store read directly over HTTP with zarrita — no +// backend (DESIGN §14). Two containers: +// - a `.zarr.zip` checkpoint, read by HTTP Range: the zip is `ZIP_STORED`, so a zarr +// chunk is a contiguous byte span, and `ZipFileStore` pulls the central directory once +// and range-reads entries after that; +// - a `.zarr/` folder, one object per key (`folderStore`). +// Either may be an app-saved checkpoint (with the `viewer/` sidecar) or a plain +// SpatialData store, whose sidecar is derived instead (`plainSpatialData`). // // Field data is materialized into the *same* Arrow schemas the live // `/data/{field_path}` route emits (`transport/arrow.py:resolve_field`), so @@ -9,6 +13,7 @@ import { loadOmeZarrFromStore } from '@vivjs/loaders'; import { Schema, Table, makeTable } from 'apache-arrow'; import type { AbsolutePath, AsyncReadable, RangeQuery } from '@zarrita/storage'; +import FetchStore from '@zarrita/storage/fetch'; import ZipFileStore from '@zarrita/storage/zip'; import * as zarr from 'zarrita'; import { @@ -19,6 +24,8 @@ import { import { SHAPE_ANNOTATIONS_ELEMENT } from '../lib/shapeAnnotations'; import type { ShapeIndexEntry, ShapeReader } from './parquetShapes'; import type { DataSource, ElementInventory, ImageLoader, LocalCategorical } from './types'; +import { type FolderAccess, HostSignedFolderStore } from './folderStore'; +import { autoDisplays, childrenOf, type Contents, deriveSidecar, UnrenderableStoreError } from './plainSpatialData'; // Highest `viewer/` sidecar layout this build understands. Mirrors // `persistence.store.VIEWER_SIDECAR_VERSION`; bumped only by a breaking layout change. @@ -209,6 +216,13 @@ async function readBytes(root: Root, path: string): Promise; } +// A string column that may be a plain array or AnnData's nullable string group (newer +// AnnData writes `obs`/`var` indexes and string columns this way; sopa's stores do). +async function readStringColumn(root: Root, path: string): Promise { + const node = await zarr.open.v3(root.resolve(path)); + return readStrings(root, node.kind === 'group' ? `${path}/values` : path); +} + async function readStrings(root: Root, path: string): Promise { const arr = await zarr.open.v3(root.resolve(path), { kind: 'array' }); const chunk = await zarr.get(arr); @@ -239,17 +253,46 @@ export interface CheckpointHandle { figures: FigureIndex; } -/** Open a checkpoint for reading. `source` is a `blob:`/`http(s):` URL, or a File the - * user picked (which `file://` pages need — range GETs don't work there). - * `refreshUrl` re-signs an expiring URL mid-session; a File needs none. */ +/** A URL whose path ends in `/` names a `.zarr/` folder rather than a `.zarr.zip`. */ +export function isFolderUrl(url: string): boolean { + return new URL(url, globalThis.location?.href).pathname.endsWith('/'); +} + +async function openRawStore( + target: string | File, refreshUrl?: CheckpointUrlRefresher, folder?: FolderAccess, +): Promise> { + if (typeof target !== 'string') return ZipFileStore.fromBlob(target); + if (!isFolderUrl(target)) return new ZipFileStore(new RangeGetReader(target, refreshUrl)); + return folder ? HostSignedFolderStore.open(folder) : new FetchStore(target); +} + +// Every store this reader opens is Zarr v3, rooted at `zarr.json`. Say what the store +// is instead when it is not, rather than surfacing zarrita's "Not found: group at /". +async function requireZarrV3Root(store: AsyncReadable): Promise { + if (await store.get('/zarr.json')) return; + if (await store.get('/.zgroup') ?? await store.get('/.zmetadata')) { + throw new UnrenderableStoreError( + 'This is a Zarr v2 store, and the viewer reads SpatialData stores written as Zarr v3 ' + + '(spatialdata 0.3 and later). If it is a SpatialData store, re-save it with a current ' + + 'spatialdata to view it.'); + } + throw new UnrenderableStoreError('This is not a zarr store: it has no zarr.json at its root.'); +} + +/** Open a SpatialData store for reading. `target` is a `blob:`/`http(s):` URL — of a + * `.zarr.zip`, or of a `.zarr/` folder when its path ends in `/` — or a File the user + * picked (which `file://` pages need — range GETs don't work there). `refreshUrl` + * re-signs an expiring zip URL mid-session; a File needs none. `folder` reads a folder + * whose objects must each be signed (an embed host's S3 bucket); without it a folder URL + * is fetched as-is. */ export async function openCheckpoint( target: string | File, refreshUrl?: CheckpointUrlRefresher, + folder?: FolderAccess, ): Promise { const url = typeof target === 'string' ? target : target.name; - const rawStore = typeof target === 'string' - ? new ZipFileStore(new RangeGetReader(target, refreshUrl)) - : ZipFileStore.fromBlob(target); + const rawStore = await openRawStore(target, refreshUrl, folder); + await requireZarrV3Root(rawStore); // Every open is pinned to v3 (`open.v3`) rather than letting zarrita auto-detect: // checkpoints are always Zarr v3, and the auto-detect path probes v2 first, which // costs a 404 per node and leaves the losing probe's rejection unhandled — surfacing @@ -261,19 +304,13 @@ export async function openCheckpoint( const contents = 'contents' in store ? store.contents() : []; const root = zarr.root(store); const rootAttrs = (await (await zarr.open.v3(root, { kind: 'group' })).attrs) as Record; - const appState = (rootAttrs.app_state ?? {}) as Record; - - const sidecar = (await readGroupAttrs(root, 'viewer')) as unknown as ViewerSidecar | null; - // Without the sidecar there is nothing to degrade to: a Zarr v3 store carries no - // child index, so with neither it nor consolidated metadata the reader cannot even - // name the table, and the viewer would present an empty session with no explanation. - // Fail with something the user can act on instead. - if (!sidecar) { - throw new Error( - 'This checkpoint was saved before the serverless viewer existed, so it carries none ' + - 'of the metadata the browser needs to read it. Open it in the app and save it again.', - ); - } + const savedAppState = (rootAttrs.app_state ?? {}) as Record; + + // A store without the sidecar is a plain SpatialData store (or a checkpoint saved + // before the sidecar existed): derive what the sidecar would have said, or explain why + // there is nothing to show. + const sidecar = ((await readGroupAttrs(root, 'viewer')) + ?? await deriveSidecar(root, contents, rootAttrs)) as unknown as ViewerSidecar; if (sidecar.sidecar_version > VIEWER_SIDECAR_VERSION) { throw new Error( `This checkpoint was written for a newer viewer (sidecar v${sidecar.sidecar_version}; ` + @@ -294,7 +331,7 @@ export async function openCheckpoint( // strings, read once. let varNames: string[] | null = null; const varNamesOnce = async (): Promise => { - if (varNames === null) varNames = await readStrings(root, `tables/${table}/var/_index`); + if (varNames === null) varNames = await readStringColumn(root, `tables/${table}/var/_index`); return varNames; }; @@ -459,21 +496,22 @@ export async function openCheckpoint( }, }; - return { - source, appState, figures: sidecar.figures ?? {}, - fields: await deriveFields(root, contents, table, sidecar), + const fields = await deriveFields(root, contents, table, sidecar); + // A store saved by anything but this app has no displays; give it the ones a live + // session would have started with. Like the backend, they color by the first pandas + // Categorical — not any string column, which is as likely an id with a level per cell. + const categoricals: string[] = []; + for (const { name } of fields.obs) { + const attrs = await readGroupAttrs(root, `tables/${table}/obs/${name}`).catch(() => null); + if (attrs?.['encoding-type'] === 'categorical') categoricals.push(name); + } + const savedDisplays = savedAppState.displays as unknown[] | undefined; + const appState = savedDisplays?.length ? savedAppState : { + schema_version: 3, compute_history: [], plots: [], regions: [], + ...savedAppState, + displays: autoDisplays(fields, categoricals), }; -} - -type Contents = { path: string; kind: 'array' | 'group' }[]; - -/** Immediate children of a group, from the consolidated listing. */ -function childrenOf(contents: Contents, group: string): string[] { - const prefix = `${group}/`; - return contents - .map((entry) => entry.path.replace(/^\//, '')) - .filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes('/')) - .map((path) => path.slice(prefix.length)); + return { source, appState, figures: sidecar.figures ?? {}, fields }; } // The inventory the Color By / obsm pickers read, in the shape `GET /api/sessions/{id}` @@ -494,7 +532,10 @@ async function deriveFields( for (const name of columnOrder.filter((n) => n !== indexName)) { const path = `tables/${table}/obs/${name}`; if (kindByPath.get(path) === 'group') { - obs.push({ name, kind: 'categorical' }); + // A group is a pandas Categorical or one of AnnData's nullable arrays; only the + // nullable numeric ones plot as numbers. + const encoding = (await readGroupAttrs(root, path))?.['encoding-type']; + obs.push({ name, kind: NULLABLE_NUMERIC.has(String(encoding)) ? 'numeric' : 'categorical' }); continue; } const arr = await zarr.open.v3(root.resolve(path), { kind: 'array' }); @@ -503,6 +544,9 @@ async function deriveFields( const obsm: ObsmField[] = []; for (const name of childrenOf(contents, `tables/${table}/obsm`)) { + // AnnData may store an obsm entry as a dataframe (a group of columns — sopa writes + // per-channel `intensities` that way); only an array is an embedding to plot. + if (kindByPath.get(`tables/${table}/obsm/${name}`) !== 'array') continue; const arr = await zarr.open.v3(root.resolve(`tables/${table}/obsm/${name}`), { kind: 'array' }); // A 1-D obsm array has one component, not zero — `arrow.py` reports 1 for the same // element, and 0 made the picker offer an embedding with no axes to choose. @@ -535,7 +579,10 @@ async function deriveFields( // answer about a healthy checkpoint. async function arrayLength(root: Root, path: string): Promise { try { - return (await zarr.open.v3(root.resolve(path), { kind: 'array' })).shape[0]; + const node = await zarr.open.v3(root.resolve(path)); + // A nullable column (a group of `values` + `mask`) is as long as its values. + const array = node.kind === 'group' ? await zarr.open.v3(root.resolve(`${path}/values`), { kind: 'array' }) : node; + return array.shape[0]; } catch (err) { if (zarr.isZarritaError(err, 'NotFoundError')) return 0; throw err; @@ -583,11 +630,32 @@ function applyAffineXy(xs: Float32Array, ys: Float32Array, [a, b, c, d, e, f]: n } } -// AnnData writes a categorical obs column as a group of `codes` + `categories`, and a -// plain column as an array. Mirrors `_obs_batch`'s two shapes. +// AnnData's nullable arrays: a group of `values` plus a boolean `mask` (true = missing). +const NULLABLE_NUMERIC = new Set(['nullable-integer', 'nullable-boolean']); +const NULLABLE_STRING = 'nullable-string-array'; + +// AnnData writes a categorical obs column as a group of `codes` + `categories`, a +// nullable column as a group of `values` + `mask`, and a plain column as an array. async function readObs(root: Root, table: string, key: string): Promise { const path = `tables/${table}/obs/${key}`; const node = await zarr.open.v3(root.resolve(path)); + const encoding = node.kind === 'group' ? String(((await node.attrs) as Record)['encoding-type']) : ''; + if (encoding === NULLABLE_STRING || NULLABLE_NUMERIC.has(encoding)) { + const [values, mask] = await Promise.all([ + encoding === NULLABLE_STRING ? readStrings(root, `${path}/values`) : readNumeric(root, `${path}/values`), + readNumeric(root, `${path}/mask`), + ]); + if (encoding === NULLABLE_STRING) { + // A missing value reads as its own level, as the backend's categorical read shows NaN. + const { codes, categories } = encodeCategories((values as string[]).map((v, i) => (mask[i] ? 'NaN' : v))); + return withMetadata(makeTable({ code: codes }), new Map([ + ['kind', 'categorical'], + ['categories', JSON.stringify(categories)], + ])); + } + const numbers = Float64Array.from(values as Float64Array, (v, i) => (mask[i] ? NaN : v)); + return withMetadata(makeTable({ value: numbers }), new Map([['kind', 'numeric']])); + } if (node.kind === 'group') { const categories = await readStrings(root, `${path}/categories`); const codes = Int32Array.from(await readNumeric(root, `${path}/codes`)); diff --git a/packages/viewer/src/data/folderStore.test.ts b/packages/viewer/src/data/folderStore.test.ts new file mode 100644 index 0000000..0b197f1 --- /dev/null +++ b/packages/viewer/src/data/folderStore.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HostSignedFolderStore } from './folderStore'; + +function access(keys: string[]) { + const signKeys = vi.fn(async (requested: readonly string[]) => requested.map((k) => `https://signed/${k}`)); + const listKeys = vi.fn(async () => keys); + return { signKeys, listKeys }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('HostSignedFolderStore', () => { + it('answers an unlisted key as absent without signing or fetching it', async () => { + const host = access(['zarr.json']); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const store = await HostSignedFolderStore.open(host); + expect(await store.get('/tables/zarr.json')).toBeUndefined(); + expect(host.signKeys).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('signs every key requested in the same tick in one batch', async () => { + const host = access(['a', 'b', 'c']); + vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1])))); + const store = await HostSignedFolderStore.open(host); + await Promise.all([store.get('/a'), store.get('/b'), store.get('/c')]); + expect(host.signKeys).toHaveBeenCalledTimes(1); + expect(host.signKeys).toHaveBeenCalledWith(['a', 'b', 'c']); + }); + + it('re-signs once when a listed key answers 403 (an expired signature)', async () => { + const host = access(['a']); + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(new Uint8Array([7]))); + vi.stubGlobal('fetch', fetchMock); + const store = await HostSignedFolderStore.open(host); + expect(await store.get('/a')).toEqual(new Uint8Array([7])); + expect(host.signKeys).toHaveBeenCalledTimes(2); + }); + + it('sends a Range header for a range read', async () => { + const host = access(['a']); + const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2]), { status: 206 })); + vi.stubGlobal('fetch', fetchMock); + const store = await HostSignedFolderStore.open(host); + await store.getRange('/a', { offset: 10, length: 2 }); + expect(fetchMock).toHaveBeenCalledWith('https://signed/a', { headers: { Range: 'bytes=10-11' } }); + }); +}); diff --git a/packages/viewer/src/data/folderStore.ts b/packages/viewer/src/data/folderStore.ts new file mode 100644 index 0000000..1a8a45c --- /dev/null +++ b/packages/viewer/src/data/folderStore.ts @@ -0,0 +1,108 @@ +// A zarr store for a SpatialData `.zarr/` FOLDER on object storage that is readable only +// through per-object presigned URLs (Cirro's S3 buckets). A folder has no single URL to +// sign, so the host signs keys on request and lists what the folder holds. +// +// The listing is what makes missing keys cheap and unambiguous: zarr probes for nodes +// that may not exist, and a presigned GET for an absent S3 key answers 403 — the same +// status as an expired signature — unless the signer may list the bucket. Answering +// "absent" from the listing means a 403 can only ever mean "re-sign". +import type { AbsolutePath, AsyncReadable, RangeQuery } from '@zarrita/storage'; + +/** What the host provides to read a folder store. Keys and prefixes are relative to the + * store root (no leading slash). */ +export interface FolderAccess { + /** Presigned GET URLs for `keys`, in the same order. */ + signKeys(keys: readonly string[]): Promise; + /** Every object key under `prefix`; `''` lists the whole store. */ + listKeys(prefix: string): Promise; +} + +// Signatures are reused for less than the shortest lifetime a Cirro host issues (five +// minutes), so a cached URL never goes stale mid-request. +const SIGNED_URL_TTL_MS = 4 * 60 * 1000; +const EXPIRED_URL_STATUSES = new Set([401, 403]); + +function rangeHeader(range: RangeQuery): string { + return 'suffixLength' in range + ? `bytes=-${range.suffixLength}` + : `bytes=${range.offset}-${range.offset + range.length - 1}`; +} + +export class HostSignedFolderStore implements Required { + private readonly signed = new Map(); + // Keys waiting for the next batch signature, with the callers waiting on each. + private pending = new Map void; reject: (err: unknown) => void }>>(); + private flushScheduled = false; + + private constructor(private readonly access: FolderAccess, private readonly keys: ReadonlySet) {} + + /** Lists the folder once, then serves reads from it. */ + static async open(access: FolderAccess): Promise { + return new HostSignedFolderStore(access, new Set(await access.listKeys(''))); + } + + /** The folder's keys, for callers that need to enumerate (e.g. parquet part files). */ + listing(): ReadonlySet { + return this.keys; + } + + async get(key: AbsolutePath): Promise { + return this.fetchKey(key.slice(1), undefined); + } + + async getRange(key: AbsolutePath, range: RangeQuery): Promise { + return this.fetchKey(key.slice(1), rangeHeader(range)); + } + + private async fetchKey(key: string, range: string | undefined): Promise { + if (!this.keys.has(key)) return undefined; + const init = range ? { headers: { Range: range } } : undefined; + let res = await fetch(await this.sign(key), init); + if (EXPIRED_URL_STATUSES.has(res.status)) { + // The key exists (it is listed), so this is an expired signature: re-sign once. + this.signed.delete(key); + res = await fetch(await this.sign(key), init); + } + if (!res.ok) throw new Error(`GET ${key} failed: ${res.status} ${res.statusText}`); + return new Uint8Array(await res.arrayBuffer()); + } + + private sign(key: string): Promise { + const cached = this.signed.get(key); + if (cached && Date.now() - cached.at < SIGNED_URL_TTL_MS) return Promise.resolve(cached.url); + return new Promise((resolve, reject) => { + const waiters = this.pending.get(key) ?? []; + waiters.push({ resolve, reject }); + this.pending.set(key, waiters); + if (!this.flushScheduled) { + this.flushScheduled = true; + // One round trip for every key requested in the same tick: opening an image + // level asks for dozens of chunks at once. + queueMicrotask(() => this.flush()); + } + }); + } + + private flush(): void { + this.flushScheduled = false; + const batch = this.pending; + this.pending = new Map(); + const keys = [...batch.keys()]; + this.access.signKeys(keys).then( + (urls) => { + const at = Date.now(); + keys.forEach((key, i) => { + const url = urls[i]; + const waiters = batch.get(key) ?? []; + if (!url) { + waiters.forEach((w) => w.reject(new Error(`the host could not sign ${key}`))); + return; + } + this.signed.set(key, { url, at }); + waiters.forEach((w) => w.resolve(url)); + }); + }, + (err: unknown) => batch.forEach((waiters) => waiters.forEach((w) => w.reject(err))), + ); + } +} diff --git a/packages/viewer/src/data/plainSpatialData.test.ts b/packages/viewer/src/data/plainSpatialData.test.ts new file mode 100644 index 0000000..2115212 --- /dev/null +++ b/packages/viewer/src/data/plainSpatialData.test.ts @@ -0,0 +1,192 @@ +// Deriving the viewer sidecar for a plain SpatialData store. The fixture copies the +// geometry of a spatialdata-io Xenium store: the spots are in microns, the image in +// pixels (identity to `global`), and only a shapes element's transform says how the two +// relate — the case the image reconciliation exists for. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as zarr from 'zarrita'; +import { + IDENTITY, autoDisplays, boxThrough, deriveSidecar, elementTransforms, invert, multiply, + reconcileImage, transformToAffine, UnrenderableStoreError, type Affine3, type Contents, +} from './plainSpatialData'; +import { openCheckpoint } from './checkpointSource'; + +const PX_PER_UM = 4.7; +const xy = (name: string) => ({ name, axes: [{ name: 'x' }, { name: 'y' }] }); + +/** A minimal consolidated Zarr v3 SpatialData store, in memory. */ +async function plainStore() { + const store = new Map(); + const root = zarr.root(store); + await zarr.create(root, { attributes: { spatialdata_attrs: { version: '0.2' } } }); + for (const g of ['tables', 'tables/table', 'tables/table/obsm', 'images', 'shapes']) { + await zarr.create(root.resolve(g)); + } + await zarr.create(root.resolve('tables/table/obs'), { attributes: { 'column-order': [], _index: '_index' } }); + const spots = await zarr.create(root.resolve('tables/table/obsm/spatial'), { shape: [2, 2], chunkShape: [2, 2], dtype: 'float64' }); + await zarr.set(spots, null, { data: new Float64Array([0, 0, 100, 100]), shape: [2, 2], stride: [2, 1] }); + await zarr.create(root.resolve('shapes/cells'), { + attributes: { axes: ['x', 'y'], coordinateTransformations: [{ type: 'scale', scale: [PX_PER_UM, PX_PER_UM], input: xy('xy'), output: xy('global') }] }, + }); + const cyx = { axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] }; + await zarr.create(root.resolve('images/dapi'), { + attributes: { + ome: { + omero: { channels: [{ label: 'DAPI' }] }, + multiscales: [{ datasets: [{ path: 's0' }], coordinateTransformations: [{ type: 'identity', input: { name: 'cyx', ...cyx }, output: { name: 'global', ...cyx } }] }], + }, + }, + }); + const size = 470; + const pixels = await zarr.create(root.resolve('images/dapi/s0'), { shape: [1, size, size], chunkShape: [1, size, size], dtype: 'uint16' }); + const ramp = Uint16Array.from({ length: size * size }, (_, i) => i % 1000); + await zarr.set(pixels, null, { data: ramp, shape: [1, size, size], stride: [size * size, size, 1] }); + + // Consolidate the way spatialdata does: every node's metadata inline in the root. + const decoder = new TextDecoder(); + const metadata: Record = {}; + for (const [key, bytes] of store) { + if (key.endsWith('/zarr.json') && key !== '/zarr.json') metadata[key.slice(1, -'/zarr.json'.length)] = JSON.parse(decoder.decode(bytes)); + } + const rootMeta = JSON.parse(decoder.decode(store.get('/zarr.json'))); + rootMeta.consolidated_metadata = { kind: 'inline', must_understand: false, metadata }; + store.set('/zarr.json', new TextEncoder().encode(JSON.stringify(rootMeta))); + + const readable = { get: async (key: `/${string}`) => store.get(key) }; + const consolidated = await zarr.withMaybeConsolidatedMetadata(readable, { format: 'v3' }); + const contents = ('contents' in consolidated ? consolidated.contents() : []) as Contents; + const opened = zarr.root(consolidated); + const rootAttrs = (await (await zarr.open.v3(opened, { kind: 'group' })).attrs) as Record; + return { store, root: opened, contents, rootAttrs }; +} + +function close(actual: readonly number[], expected: readonly number[], digits = 6): void { + expect(actual.length).toBe(expected.length); + actual.forEach((v, i) => expect(v).toBeCloseTo(expected[i], digits)); +} + +describe('transformToAffine', () => { + it('reads scale and translation by axis name, whatever the axis order', () => { + close(transformToAffine({ type: 'scale', scale: [1, 2, 3], input: { axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] } }, [])!, + [3, 0, 0, 0, 2, 0, 0, 0, 1]); + close(transformToAffine({ type: 'translation', translation: [5, 7] }, ['x', 'y'])!, [1, 0, 5, 0, 1, 7, 0, 0, 1]); + }); + + it('composes a sequence in order', () => { + const t = transformToAffine({ + type: 'sequence', + transformations: [{ type: 'scale', scale: [2, 2] }, { type: 'translation', translation: [10, 0] }], + }, ['x', 'y'])!; + // scale first, then translate: (1, 1) -> (2, 2) -> (12, 2) + close(boxThrough(t, [1, 1, 1, 1]), [12, 2, 12, 2]); + }); + + it('returns null for a transform no 2-D affine expresses', () => { + expect(transformToAffine({ type: 'byDimension' }, ['x', 'y'])).toBeNull(); + }); +}); + +describe('reconcileImage', () => { + const scale = (s: number): Affine3 => [s, 0, 0, 0, s, 0, 0, 0, 1]; + + it('places a pixel-space image against micron spots through the element that maps them', () => { + // Spots span 0..1000 µm; the image is 4700 px wide at 4.7 px/µm, identity to global; + // a shapes element maps µm -> px. + const image = new Map([['global', IDENTITY]]); + const candidates = new Map([['shapes/cells', new Map([['global', scale(4.7)]])]]); + const m = reconcileImage(image, candidates, [0, 0, 1000, 1000], 4700, 4700); + close(m, multiply(invert(scale(4.7)), IDENTITY)); + }); + + it('keeps the image in its own system when there are no spots to reconcile against', () => { + const image = new Map([['global', scale(2)]]); + close(reconcileImage(image, new Map(), null, 10, 10), scale(2)); + }); +}); + +describe('deriveSidecar on a plain store', () => { + it('names the table and places the pixel-space image against the micron spots', async () => { + const { root, contents, rootAttrs } = await plainStore(); + const sidecar = await deriveSidecar(root, contents, rootAttrs); + expect(sidecar.table_keys).toEqual(['table']); + expect(sidecar.world_key).toEqual({ table: 'spatial' }); + const info = sidecar.images.dapi.table; + expect(info.channel_names).toEqual(['DAPI']); + expect([info.width, info.height, info.channels]).toEqual([470, 470, 1]); + expect(info.is_rgb).toBe(false); + close(info.pixel_to_world, [1 / PX_PER_UM, 0, 0, 0, 1 / PX_PER_UM, 0]); + close(info.bounds, [0, 0, 100, 100]); + // A uint16 image's default upper contrast is the 99.9th percentile, inside its range. + // numpy.percentile(ramp, 99.9) for this ramp of 0..999. + close(info.contrast_limits![0], [0, 998]); + close(info.contrast_range![0], [0, 999]); + }); + + it('explains a store with no table instead of opening an empty session', async () => { + const { root, contents, rootAttrs } = await plainStore(); + const imagesOnly = contents.filter((e) => !e.path.startsWith('/tables')); + await expect(deriveSidecar(root, imagesOnly, rootAttrs)).rejects.toThrow(UnrenderableStoreError); + await expect(deriveSidecar(root, imagesOnly, rootAttrs)).rejects.toThrow(/no table/); + }); + + it('explains a store without consolidated metadata', async () => { + const { root, rootAttrs } = await plainStore(); + await expect(deriveSidecar(root, [], rootAttrs)).rejects.toThrow(/consolidated metadata/); + }); + + it('reads element transforms keyed by coordinate system', async () => { + const { root } = await plainStore(); + const attrs = (await (await zarr.open.v3(root.resolve('shapes/cells'), { kind: 'group' })).attrs) as Record; + close(elementTransforms(attrs).get('global')!, [PX_PER_UM, 0, 0, 0, PX_PER_UM, 0, 0, 0, 1]); + }); +}); + +describe('autoDisplays', () => { + it('makes a spatial display over the first image and an embedding display for UMAP', () => { + const displays = autoDisplays({ + obsm: [{ name: 'spatial' }, { name: 'X_pca' }, { name: 'X_umap' }], + images: ['he', 'dapi'], + }, ['leiden', 'region']); + expect(displays.map((d) => d.type)).toEqual(['spatial_canvas', 'embedding_canvas']); + // Re-derived on every open, so ids must not change between opens. + expect(displays.map((d) => d.id)).toEqual(['auto-spatial', 'auto-embedding']); + expect(displays[0].encoding).toMatchObject({ coords: 'obsm:spatial', color_by: 'obs:leiden', image_layer: 'he' }); + expect(displays[1].encoding).toMatchObject({ obsm_key: 'X_umap', color_by: 'obs:leiden' }); + }); + + it('makes no embedding display when the only obsm is spatial', () => { + const [spatial, ...rest] = autoDisplays({ obsm: [{ name: 'spatial' }], images: [] }, []); + expect(rest).toHaveLength(0); + expect(spatial.encoding).toMatchObject({ color_by: null, image_layer: null }); + }); +}); + +describe('openCheckpoint on a host-signed .zarr folder', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('opens a plain store through signed per-object URLs and starts on generated displays', async () => { + const { store } = await plainStore(); + const BASE = 'https://bucket.example/sample.zarr/'; + vi.stubGlobal('fetch', async (url: string) => { + const bytes = store.get(`/${url.slice('https://signed/'.length)}`); + return bytes ? new Response(new Uint8Array(bytes)) : new Response(null, { status: 404 }); + }); + const access = { + listKeys: async () => [...store.keys()].map((k) => k.slice(1)), + signKeys: async (keys: readonly string[]) => keys.map((k) => `https://signed/${k}`), + }; + const handle = await openCheckpoint(BASE, undefined, access); + expect(handle.fields.obsm).toEqual([{ name: 'spatial', n_components: 2 }]); + expect(handle.fields.images).toEqual(['dapi']); + const displays = handle.appState.displays as { type: string; encoding: Record }[]; + expect(displays.map((d) => d.type)).toEqual(['spatial_canvas']); + expect(displays[0].encoding).toMatchObject({ coords: 'obsm:spatial', image_layer: 'dapi', color_by: null }); + const info = await handle.source.getImageInfo('dapi'); + close(info.pixel_to_world, [1 / PX_PER_UM, 0, 0, 0, 1 / PX_PER_UM, 0]); + }); + + it('explains a folder that is not a zarr store', async () => { + const access = { listKeys: async () => ['notes.txt'], signKeys: async () => [] }; + await expect(openCheckpoint('https://bucket.example/x.zarr/', undefined, access)) + .rejects.toThrow(/not a zarr store/); + }); +}); diff --git a/packages/viewer/src/data/plainSpatialData.ts b/packages/viewer/src/data/plainSpatialData.ts new file mode 100644 index 0000000..f12d495 --- /dev/null +++ b/packages/viewer/src/data/plainSpatialData.ts @@ -0,0 +1,389 @@ +// Reading a plain SpatialData store — one written by spatialdata / spatialdata-io / a +// pipeline such as nf-core/sopa rather than saved by this app — without the backend. +// +// An app-saved checkpoint carries a `viewer/` sidecar the backend baked for the browser +// (docs/CHECKPOINT_FORMAT.md §4): the table to read, each image's manifest, where the +// spots sit. A plain store has none of that, so this module derives the same sidecar +// from the store's consolidated metadata, the way the backend would (`imaging.image_info`, +// `manager.auto_displays`), and the reader then treats it like any other checkpoint. +// +// What it does not derive: the shapes spatial index (plain shapes parquet has no +// `bbox` covering column), so a plain store's cell boundaries are not drawn, and the +// CSC gene mirror, so coloring by a gene reads the table's whole CSR matrix. +import * as zarr from 'zarrita'; +import type { AsyncReadable } from '@zarrita/storage'; +import type { ImageInfo } from '../types'; + +type Root = zarr.Location; +export type Contents = { path: string; kind: 'array' | 'group' }[]; + +/** A 3x3 affine over (x, y), row-major: [[a, b, c], [d, e, f], [0, 0, 1]]. */ +export type Affine3 = [number, number, number, number, number, number, number, number, number]; + +export const IDENTITY: Affine3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + +export function multiply(m: Affine3, n: Affine3): Affine3 { + const out = new Array(9).fill(0) as Affine3; + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + out[r * 3 + c] = m[r * 3] * n[c] + m[r * 3 + 1] * n[3 + c] + m[r * 3 + 2] * n[6 + c]; + } + } + return out; +} + +export function invert([a, b, c, d, e, f]: Affine3): Affine3 { + const det = a * e - b * d; + if (det === 0) throw new Error('singular coordinate transform'); + return [e / det, -b / det, (b * f - c * e) / det, -d / det, a / det, (c * d - a * f) / det, 0, 0, 1]; +} + +export function toAffine6([a, b, c, d, e, f]: Affine3): number[] { + return [a, b, c, d, e, f]; +} + +type Box = [number, number, number, number]; + +/** `box` pushed through `m`, as the axis-aligned box of all four corners (`bbox_aabb`). */ +export function boxThrough(m: Affine3, [x0, y0, x1, y1]: Box): Box { + const xs: number[] = []; + const ys: number[] = []; + for (const [x, y] of [[x0, y0], [x1, y0], [x0, y1], [x1, y1]]) { + xs.push(m[0] * x + m[1] * y + m[2]); + ys.push(m[3] * x + m[4] * y + m[5]); + } + return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; +} + +export function iou(a: Box, b: Box): number { + const inter = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])) + * Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1])); + const union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter; + return union > 0 ? inter / union : 0; +} + +interface NgffTransform { + type: string; + input?: { name?: string; axes?: { name: string }[] }; + output?: { name?: string }; + scale?: number[]; + translation?: number[]; + affine?: number[][]; + transformations?: NgffTransform[]; +} + +/** One NGFF/SpatialData coordinate transformation as a 2-D affine, or null for one no + * 2-D affine can express. `axes` names the input axes when the transform does not. */ +export function transformToAffine(t: NgffTransform, axes: readonly string[]): Affine3 | null { + const names = t.input?.axes?.map((a) => a.name) ?? axes; + const ix = names.indexOf('x'); + const iy = names.indexOf('y'); + switch (t.type) { + case 'identity': + return IDENTITY; + case 'scale': + if (!t.scale || ix < 0 || iy < 0) return null; + return [t.scale[ix], 0, 0, 0, t.scale[iy], 0, 0, 0, 1]; + case 'translation': + if (!t.translation || ix < 0 || iy < 0) return null; + return [1, 0, t.translation[ix], 0, 1, t.translation[iy], 0, 0, 1]; + case 'affine': { + // Rows are output axes, columns input axes plus the translation column. SpatialData + // writes outputs in the same axis names as inputs. + const m = t.affine; + if (!m || ix < 0 || iy < 0 || m.length < Math.max(ix, iy) + 1) return null; + const last = names.length; + return [m[ix][ix], m[ix][iy], m[ix][last], m[iy][ix], m[iy][iy], m[iy][last], 0, 0, 1]; + } + case 'sequence': { + let out = IDENTITY; + for (const step of t.transformations ?? []) { + const next = transformToAffine(step, names); + if (!next) return null; + out = multiply(next, out); + } + return out; + } + default: + return null; + } +} + +/** An element's intrinsic -> coordinate-system affines, keyed by system name. */ +export function elementTransforms(attrs: Record): Map { + const ome = (attrs.ome ?? attrs) as { multiscales?: { coordinateTransformations?: NgffTransform[]; axes?: { name: string }[] }[] }; + const multiscale = ome.multiscales?.[0]; + const list = multiscale?.coordinateTransformations + ?? (attrs.coordinateTransformations as NgffTransform[] | undefined) ?? []; + const axes = multiscale?.axes?.map((a) => a.name) ?? (attrs.axes as string[] | undefined) ?? ['x', 'y']; + const out = new Map(); + for (const t of list) { + const system = t.output?.name; + const affine = transformToAffine(t, axes); + if (system && affine) out.set(system, affine); + } + return out; +} + +/** Immediate children of a group, from the consolidated listing. */ +export function childrenOf(contents: Contents, group: string): string[] { + const prefix = `${group}/`; + return contents + .map((entry) => entry.path.replace(/^\//, '')) + .filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes('/')) + .map((path) => path.slice(prefix.length)); +} + +async function groupAttrs(root: Root, path: string): Promise> { + return (await (await zarr.open.v3(root.resolve(path), { kind: 'group' })).attrs) as Record; +} + +// Upper contrast bound from the coarsest level, as `imaging._channel_norm` computes it: +// 255 for uint8, else the 99.9th percentile. Sampled past this many values; the +// percentile of a strided sample of a smooth image is the same to display precision. +const MAX_PERCENTILE_SAMPLES = 1_000_000; + +function channelStats(values: ArrayLike, start: number, count: number, isUint8: boolean): { + limit: number; min: number; max: number; +} { + let min = Infinity; + let max = -Infinity; + const stride = Math.max(1, Math.floor(count / MAX_PERCENTILE_SAMPLES)); + const sample: number[] = []; + for (let i = 0; i < count; i++) { + const v = Number(values[start + i]); + if (v < min) min = v; + if (v > max) max = v; + if (i % stride === 0) sample.push(v); + } + if (isUint8) return { limit: 255, min, max }; + if (sample.length === 0) return { limit: 1, min, max }; + sample.sort((a, b) => a - b); + // numpy's default (linear) percentile, as the backend computes it. + const rank = 0.999 * (sample.length - 1); + const below = Math.floor(rank); + const p = sample[below] + (sample[Math.min(below + 1, sample.length - 1)] - sample[below]) * (rank - below); + return { limit: Math.max(p, 1), min, max }; +} + +interface ImageLayout { + info: Omit; + transforms: Map; +} + +async function readImageLayout(root: Root, element: string): Promise { + const base = `images/${element}`; + const attrs = await groupAttrs(root, base); + const ome = (attrs.ome ?? attrs) as { + multiscales?: { datasets?: { path: string }[] }[]; + // spatialdata writes a label per channel; for an image read without channel names + // they are the channel indices, as numbers. + omero?: { channels?: { label?: string | number }[] }; + }; + const datasets = ome.multiscales?.[0]?.datasets ?? []; + if (datasets.length === 0) throw new Error(`image "${element}" has no multiscale levels`); + const arrays = await Promise.all( + datasets.map((d) => zarr.open.v3(root.resolve(`${base}/${d.path}`), { kind: 'array' }))); + const level0 = arrays[0]; + // (c, y, x), or (y, x) for a single-channel image written without a channel axis. + const hasChannelAxis = level0.shape.length === 3; + const [nChannels, height, width] = hasChannelAxis ? level0.shape : [1, ...level0.shape]; + const labels = ome.omero?.channels?.map((c) => (c.label === undefined ? '' : String(c.label))) ?? []; + const channelNames = Array.from({ length: nChannels }, (_, i) => labels[i] || String(i)); + const isUint8 = level0.dtype === 'uint8'; + + const coarsest = arrays[arrays.length - 1]; + const pixels = await zarr.get(coarsest); + const perChannel = pixels.data.length / nChannels; + const stats = channelNames.map((_, c) => channelStats(pixels.data as ArrayLike, c * perChannel, perChannel, isUint8)); + const lower = channelNames.map((n) => n.toLowerCase()); + return { + info: { + element, + width, + height, + channels: nChannels, + channel_names: channelNames, + levels: arrays.map((a, level) => ({ level, width: a.shape[a.shape.length - 1], height: a.shape[a.shape.length - 2] })), + tile_size: level0.chunks[level0.chunks.length - 1], + contrast_limits: stats.map((s) => [0, s.limit]), + contrast_range: stats.map((s) => [s.min, s.max]), + is_rgb: nChannels === 3 && isUint8 + && (lower.join() === 'r,g,b' || lower.join() === '0,1,2'), + }, + transforms: elementTransforms(attrs), + }; +} + +// Systems to try, 'global' first — SpatialData's conventional shared system. +function systemOrder(systems: Iterable): string[] { + return [...systems].sort((a, b) => (a === 'global' ? -1 : b === 'global' ? 1 : 0)); +} + +/** + * Level-0 pixel -> spot-space affine for one image, reconciled the way + * `imaging.pixel_to_world` does it: the spots live in some element's intrinsic space, + * and which one is not reliably declared (a Xenium table names pixel-space labels as + * its region while its spots are in microns). For each system the image maps into, + * try identity and every shapes/labels element's transform as the spots -> system map, + * keep the one whose spot extent best overlaps the image, and compose image -> system + * with its inverse. + */ +export function reconcileImage( + image: Map, candidates: Map>, + spotBox: Box | null, width: number, height: number, +): Affine3 { + const systems = systemOrder(image.keys()); + const fallback = systems.length ? image.get(systems[0]) ?? IDENTITY : IDENTITY; + if (!spotBox) return fallback; + let best: { spots: Affine3; image: Affine3; score: number } = { spots: IDENTITY, image: fallback, score: -1 }; + for (const system of systems) { + const toSystem = image.get(system) ?? IDENTITY; + const extent = boxThrough(toSystem, [0, 0, width, height]); + const options = [IDENTITY, ...[...candidates.values()].flatMap((byElement) => { + const a = byElement.get(system); + return a ? [a] : []; + })]; + for (const spots of options) { + const score = iou(boxThrough(spots, spotBox), extent); + if (score > best.score) best = { spots, image: toSystem, score }; + } + } + return best.score > 0 ? multiply(invert(best.spots), best.image) : fallback; +} + +async function spotBoxOf(root: Root, table: string, worldKey: string): Promise { + const arr = await zarr.open.v3(root.resolve(`tables/${table}/obsm/${worldKey}`), { kind: 'array' }); + if (arr.shape.length < 2 || arr.shape[1] < 2) return null; + const { data, shape } = await zarr.get(arr); + const [n, d] = shape; + let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; + for (let i = 0; i < n; i++) { + const x = Number((data as ArrayLike)[i * d]); + const y = Number((data as ArrayLike)[i * d + 1]); + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + } + return n > 0 ? [x0, y0, x1, y1] : null; +} + +/** The sidecar fields `openCheckpoint` reads, derived for a plain store. */ +export interface DerivedSidecar { + sidecar_version: number; + table_keys: string[]; + images: Record>; + coords_transform: Record; + world_key: Record; +} + +/** Why a store cannot be shown, phrased for the person who opened it. */ +export class UnrenderableStoreError extends Error {} + +/** + * Derive the viewer sidecar for a plain SpatialData store, or explain why there is + * nothing to show. The table is the first under `tables/` that has spatial coordinates + * (`obsm/spatial`); images are every element under `images/`. + */ +export async function deriveSidecar(root: Root, contents: Contents, rootAttrs: Record): Promise { + if (contents.length === 0) { + throw new UnrenderableStoreError( + 'This store has no consolidated metadata, so the viewer cannot see what it contains. ' + + 'Write it with spatialdata (which consolidates by default), or run ' + + '`spatialdata.SpatialData.write_consolidated_metadata()` on it.'); + } + const tables = childrenOf(contents, 'tables'); + const table = tables.find((t) => childrenOf(contents, `tables/${t}/obsm`).includes('spatial')); + if (!table) { + const what = rootAttrs.spatialdata_attrs ? 'This SpatialData store' : 'This zarr store is not a SpatialData store and'; + throw new UnrenderableStoreError(tables.length === 0 + ? `${what} has no table, so there are no cells for the viewer to draw.` + : `${what} has no table with spatial coordinates (obsm "spatial"), so the viewer cannot place its cells. ` + + `Tables found: ${tables.join(', ')}.`); + } + const worldKey = 'spatial'; + const spotBox = await spotBoxOf(root, table, worldKey); + + const candidates = new Map>(); + for (const group of ['shapes', 'labels', 'points']) { + for (const element of childrenOf(contents, group)) { + candidates.set(`${group}/${element}`, elementTransforms(await groupAttrs(root, `${group}/${element}`))); + } + } + + const images: DerivedSidecar['images'] = {}; + for (const element of childrenOf(contents, 'images')) { + let layout: ImageLayout; + try { + layout = await readImageLayout(root, element); + } catch (err) { + // One unreadable image should not hide the cells; the rest of the store still opens. + console.warn('[plain store] skipping image "%s": %s', element, err instanceof Error ? err.message : err); + continue; + } + const pixelToWorld = reconcileImage(layout.transforms, candidates, spotBox, layout.info.width, layout.info.height); + images[element] = { + [table]: { + ...layout.info, + pixel_to_world: toAffine6(pixelToWorld) as ImageInfo['pixel_to_world'], + bounds: boxThrough(pixelToWorld, [0, 0, layout.info.width, layout.info.height]), + }, + }; + } + + return { + sidecar_version: 2, + table_keys: [table], + images, + coords_transform: { [table]: toAffine6(IDENTITY) }, + world_key: { [table]: worldKey }, + }; +} + +// obsm keys the default embedding display prefers, best first (`_PREFERRED_EMBEDDINGS`). +const PREFERRED_EMBEDDINGS = ['X_umap', 'X_tsne', 'X_diffmap']; + +/** + * Default displays for a store saved without any, as `manager.auto_displays` makes them: + * a spatial canvas colored by the first of `categoricals` (the table's pandas Categorical + * obs columns, in column order) over the first image, and an embedding canvas when the + * table has an embedding. + */ +export function autoDisplays(fields: { + obsm: { name: string }[]; + images: string[]; +}, categoricals: readonly string[]): Record[] { + const color = categoricals[0] ? `obs:${categoricals[0]}` : null; + const obsmNames = fields.obsm.map((f) => f.name); + const point = { point_size: 4, opacity: 0.85, colormap: 'viridis', legend_visible: true, legend_title: '' }; + // Stable ids, not per-open UUIDs: the store is re-derived on every open, and a saved + // view or shared link names its display by id. + const displays: Record[] = [{ + id: 'auto-spatial', + type: 'spatial_canvas', + encoding: { + coords: obsmNames.includes('spatial') ? 'obsm:spatial' : (obsmNames[0] ? `obsm:${obsmNames[0]}` : null), + color_by: color, + image_layer: fields.images[0] ?? null, + shapes_layer: null, + render_mode: 'points', + point_marker: 'circle', + ...point, + }, + viewport: null, + }]; + const embedding = PREFERRED_EMBEDDINGS.find((k) => obsmNames.includes(k)) ?? obsmNames.find((k) => k !== 'spatial'); + if (embedding) { + displays.push({ + id: 'auto-embedding', + type: 'embedding_canvas', + encoding: { + obsm_key: embedding, x_component: 0, y_component: 1, z_component: 2, is_3d: false, + color_by: color, ...point, + }, + viewport: null, + }); + } + return displays; +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9426efa..e78f522 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -13,7 +13,9 @@ export { // ---- Where the data comes from ---------------------------------------------- export { DataSourceProvider, useDataSource } from './data/context'; export type { DataSource, ElementInventory, ImageLoader, LocalCategorical } from './data/types'; -export { openCheckpoint, type CheckpointHandle, type CheckpointUrlRefresher } from './data/checkpointSource'; +export { isFolderUrl, openCheckpoint, type CheckpointHandle, type CheckpointUrlRefresher } from './data/checkpointSource'; +export { HostSignedFolderStore, type FolderAccess } from './data/folderStore'; +export { UnrenderableStoreError } from './data/plainSpatialData'; export { useArrowField } from './data/useArrowField'; // ---- The display model ------------------------------------------------------