diff --git a/docs/docs/vis/mdv-integration.mdx b/docs/docs/vis/mdv-integration.mdx index b8f69ee0..781af422 100644 --- a/docs/docs/vis/mdv-integration.mdx +++ b/docs/docs/vis/mdv-integration.mdx @@ -4,6 +4,8 @@ sidebar_position: 3 # MDV integration roadmap +This page explains architecture and phased context. For the branch-level shipping checklist, see [MDV release checklist](./mdv-release-checklist). + 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. @@ -24,6 +26,10 @@ The blocker for MDV use is mostly API shape. `SpatialCanvas` currently owns a fu The top priority is a headless viewer path. Before MDV or Vitessce integration work gets clever, this repository should prove that the renderer can be driven by external state and external controls. +The first implementation should use a **bridge path** rather than a full layers-first rewrite: expose a public React `SpatialCanvasViewer` that reuses the existing Viv/deck rendering stack, accepts externally controlled state, and composes caller-provided deck.gl layers above SpatialData-rendered layers. This keeps MDV progress unblocked while preserving a migration path toward `@spatialdata/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: - [ ] `demo/headless-basic`: render a fixed SpatialData image/labels/shapes stack with all props owned by the demo component, no `SpatialCanvas` sidebars. @@ -44,7 +50,7 @@ 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 the next Viv/deck upgrade as a first-class migration. Viv PR [hms-dbmi/viv#924](https://github.com/hms-dbmi/viv/pull/924) is currently open and targets deck.gl `9.2.9`, uniform-buffer-backed shader props, `model.shaderInputs`, and variable channel counts. +- [ ] 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 @@ -365,7 +371,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. -- **Upcoming Viv/deck migration:** Viv PR `924` moves shader props toward UBOs and `model.shaderInputs` while targeting deck.gl `9.2.9`. Custom shader extensions in this repo and MDV should be migrated deliberately, not patched piecemeal. +- **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. - **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. diff --git a/docs/docs/vis/mdv-release-checklist.mdx b/docs/docs/vis/mdv-release-checklist.mdx new file mode 100644 index 00000000..ad08d439 --- /dev/null +++ b/docs/docs/vis/mdv-release-checklist.mdx @@ -0,0 +1,204 @@ +--- +sidebar_position: 4 +--- + +# MDV release checklist + +Goal for this PR branch: release a `@spatialdata/vis` version that MDV can consume now, without replacing Viv, but using this in-place where possible. + +Scope constraints: + +- keep Viv as the raster/image foundation +- keep this repo focused on SpatialData-oriented rendering/library contracts +- ensure MDV can compose its own deck layers and state cleanly alongside this library +- avoid app-specific abstractions leaking into `@spatialdata/*` packages + +## What "ready for MDV" means + +A version is MDV-ready when MDV can embed `SpatialCanvasViewer` as a headless renderer while preserving existing chart behavior from: + +- `~/code/www/MDV/src/react/components/VivScatterComponent.tsx` +- `~/code/www/MDV/src/react/scatter_state.ts` +- `~/code/www/MDV/src/react/spatial_context.tsx` (current replacement for expected `SpatialLayers.ts`) + +## Integration contract for this release + +Use this as the contract boundary between this repo and MDV. + +### MDV-controlled state (primary model) + +MDV is the **source of truth** for viewer state. `SpatialCanvasViewer` is a **controlled renderer**: it accepts MDV state and produces deck/Viv output; it does not own chart config or selection semantics. + +MDV passes and updates (from chart / datastore / MobX): + +- `spatialData`, `coordinateSystem` +- `layers` (`Record`) — visibility, opacity, per-layer style, and (for shapes v1) filter/colour driven by table rows +- `layerOrder` +- `viewState` + `onViewStateChange` +- optional `deckLayers` / `deckProps` for overlays MDV builds directly + +When MDV selection or colour mappings change, MDV updates `layers` (and/or `deckLayers`) and React re-renders the viewer. This repo loads SpatialData geometry/images and maps **MDV’s layer config** to deck.gl layers. + +### What `@spatialdata/vis` owns + +- SpatialData-backed discovery and async loading (images, shapes, labels) +- Viv image path and labels bitmask path +- Turning **caller-supplied** `LayerConfig` into deck layers (`useLayerData` / renderers) +- Composing generated layers with caller `deckLayers` / `deckProps.layers` +- Stable picking/tooltip hooks keyed by layer id (for MDV tooltip portals) + +### What MDV owns (v1) + +- Chart config and app state (MobX / zustand) +- Filtering and highlighting semantics (same as table rows today) +- Scatter, gates, contours, ROI JSON, and other bespoke deck layers +- View linking (`useViewStateLink`), outer tooltip (`useOuterContainerDeckTooltip`) +- Viv **extensions** on image channels (passed through layer config / `deckProps`; regressions here are bugs) + +### v1 layer scope + +| Layer kind | v1 | +|------------|----| +| Images | Yes (Viv) | +| Shapes | Yes — MDV drives style/filter via `layers` state | +| Labels | Render when configured; segmentation vs shapes experiment is not a release gate | +| Points | v1.1 | + +### Custom deck layers (additive, not a replacement for state) + +MDV can still pass layers it constructs entirely in app code (for example from `spatial_context.ts`) via `deckLayers` or `deckProps.layers`. Those sit **above** SpatialData-generated layers in the composed stack. + +Over time, patterns that repeat in MDV (shared shape styling, common overlays) may move into `@spatialdata/vis` or `@spatialdata/layers`; v1 does not require that migration. + +### Shapes v1: filter and colour via `layers` state + +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 + +Optional later: MDV-only `PolygonLayer` built from exported geometry helpers; not required if config-driven styling is sufficient. + +### Bespoke layer extension path + +- Primary extension for **state-owned** SpatialData layers: MDV-controlled `layers` + `layerOrder` +- Primary extension for **fully custom** deck layers: `deckLayers` / `deckProps.layers` on `SpatialCanvasViewer` +- Composition order: SpatialData-generated layers, then MDV `deckLayers`, with scale bar last (see `composeSpatialDeckLayers`) + +Practical checks for this branch: + +- [ ] MDV can drive a shapes layer only by updating `layers` props (no `SpatialCanvas` zustand store) +- [ ] An MDV-style custom layer from `spatial_context.ts` can be supplied via `deckLayers` without importing `packages/vis/src/*` internals + +## Release checklist (this branch) + +### 1) API and export sanity + +- [ ] `@spatialdata/vis` exports `SpatialCanvasViewer` as public API. +- [ ] 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. + +### 2) Headless behavior parity checks + +- [ ] `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`. + +### 3) MDV touchpoint checks + +- [ ] `VivScatterComponent.tsx` style embedding is possible without sidebars/editor UI from `SpatialCanvas`. +- [ ] `scatter_state.ts`-style scatter/filter/highlight layers can be passed as external deck layers. +- [ ] `spatial_context.tsx`-style selection and editable overlay layers can be passed through unchanged. +- [ ] View ID and layer ID conventions remain compatible with MDV filtering assumptions (current `getVivId(...)` patterns). + +### 4) Version and packaging hygiene + +- [ ] `pnpm -r --filter @spatialdata/vis test` passes. +- [ ] package build succeeds for `@spatialdata/vis` and dependent workspace packages. +- [ ] produce installable package(s) for MDV testing (pack or prerelease). +- [ ] smoke-test install in MDV workspace and verify viewer renders. + +### 5) Non-goals (explicit) + +- [ ] No attempt to replace Viv in this release. +- [ ] 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 + +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. + +### Why this matters now + +- MDV relies on Viv extensions and deck composition behavior today. +- Extension breakage can look subtle (missing colormap behavior, stale shader props, or dropped extension props during cloning/spreading). +- The move from deprecated uniform paths to shader-input paths can break custom layers/extensions if we do not audit deliberately. + +### Branch checklist for Viv-upgrade readiness + +- [ ] 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. +- [ ] Audit channel-count assumptions and constants: + - `packages/avivatorish/src/constants.ts` + - `packages/vis/src/SpatialCanvas/ImageChannelPanel.tsx` + - `packages/vis/src/SpatialCanvas/LabelsChannelPanel.tsx` + - ensure behavior is coherent if Viv supports wider channel ranges. +- [ ] Audit shader/uniform update paths in custom deck/Viv-adjacent layers: + - `packages/layers/src/LabelsBitmaskTileLayer.ts` + - confirm `shaderInputs.setProps(...)` path is primary and any `setUniforms(...)` usage is fallback-only. +- [ ] Verify app-level extension passthrough remains intact with `deckProps` and `SpatialCanvasViewer`. +- [ ] Run visual smoke tests with extension-heavy image views (2D at minimum) before publishing package versions. + +### Fast acceptance criteria + +- [ ] No regression in extension-driven rendering for image layers (including opacity/channel state updates). +- [ ] No regression in composed app overlays when Viv layers are present. +- [ ] No obvious channel-limit regressions introduced by mismatched constants. +- [ ] No hard dependency on deprecated uniform-only update patterns in custom layers/extensions. + +## Suggested MDV smoke-test sequence + +1. Install branch build of `@spatialdata/vis` into MDV. +2. Add a minimal wrapper chart that renders `SpatialCanvasViewer` with existing MDV view state. +3. Pass current MDV overlays from `VivScatterComponent.tsx` as `deckProps.layers`. +4. Route tooltip through MDV's outer-container tooltip path only. +5. Validate pan/zoom linking and selection behavior parity. +6. Validate at least one labels/shapes overlay above image. + +## Follow-up work after release + +After this MDV-ready release, pursue incremental improvements in separate PRs: + +- formalize stable layer identity contract for cross-layer picking/highlighting +- broaden feature-property contract beyond tooltip fields +- improve points styling/filtering parity with MDV needs +- evaluate optional Arrow-friendly data paths without forcing deck dependencies into `core` + +## Future consideration: editable layers and write support + +**Not in v1.** v1 keeps selection/annotation as **MDV-owned deck layers** passed through `deckLayers` (for example editable ROI / lasso layers from `spatial_context.ts`), with this repo focused on **read** paths into SpatialData. + +Later, consider implementing here (and/or in `@spatialdata/core`) when write APIs exist: + +- **Editable overlay layers** — drawing, editing vertices, selection geometries synced with chart state (today MDV implements these; candidates to lift into `@spatialdata/vis` if the interaction model stabilizes). +- **Annotation persistence** — round-trip edits to SpatialData-compatible stores (shapes tables, labels, or sidecar layers), not only ephemeral deck state. +- **General write support** — align with `@spatialdata/core` moving beyond read-only (see [core overview](../core/overview.mdx)); vis would expose mutation helpers only once core defines safe, tested write contracts. + +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/packages/vis/src/Sketch/demoUrl.ts b/packages/vis/src/Sketch/demoUrl.ts new file mode 100644 index 00000000..daa5e345 --- /dev/null +++ b/packages/vis/src/Sketch/demoUrl.ts @@ -0,0 +1,38 @@ +/** Default SpatialData Zarr URL for the vis demo / docs Sketch embed. */ +export const DEFAULT_DEMO_SPATIALDATA_URL = + 'https://storage.googleapis.com/vitessce-demo-data/spatialdata-august-2025/visium_hd_3.0.0.spatialdata.zarr'; + +/** + * Read `?url=` from a query string (e.g. for MDV links into the demo). + * Returns `fallback` when the param is missing or blank. + */ +export function getSpatialDataUrlFromSearchParams( + searchParams: URLSearchParams, + fallback: string = DEFAULT_DEMO_SPATIALDATA_URL +): string { + const raw = searchParams.get('url'); + if (raw == null) { + return fallback; + } + const trimmed = raw.trim(); + if (trimmed === '') { + return fallback; + } + try { + return decodeURIComponent(trimmed); + } catch { + return trimmed; + } +} + +/** Build a demo page href with the given SpatialData store URL in `?url=`. */ +export function buildDemoPageHref( + spatialDataUrl: string, + base: string | URL = typeof window !== 'undefined' + ? window.location.href + : 'http://127.0.0.1:5173/' +): string { + const page = new URL(base); + page.searchParams.set('url', spatialDataUrl); + return page.href; +} diff --git a/packages/vis/src/Sketch/index.tsx b/packages/vis/src/Sketch/index.tsx index d4c73226..a4ec647e 100644 --- a/packages/vis/src/Sketch/index.tsx +++ b/packages/vis/src/Sketch/index.tsx @@ -1,13 +1,15 @@ -import { useState, type CSSProperties } from 'react'; +import { useEffect, useState, type CSSProperties } from 'react'; import { SpatialDataProvider, useSpatialData } from '@spatialdata/react'; import SpatialDataTree from '../Tree'; import Table from '../Table'; import ImageView from '../ImageView'; import Transforms from '../Transforms'; import SpatialCanvas from '../SpatialCanvas'; - -const defaultUrl = - 'https://storage.googleapis.com/vitessce-demo-data/spatialdata-august-2025/visium_hd_3.0.0.spatialdata.zarr'; +import { + DEFAULT_DEMO_SPATIALDATA_URL, + buildDemoPageHref, + getSpatialDataUrlFromSearchParams, +} from './demoUrl'; const dataSourceBarStyle: CSSProperties = { flexShrink: 0, @@ -16,21 +18,53 @@ const dataSourceBarStyle: CSSProperties = { background: '#1e1e1e', }; +function getInitialDemoUrl(): string { + if (typeof window === 'undefined') { + return DEFAULT_DEMO_SPATIALDATA_URL; + } + return getSpatialDataUrlFromSearchParams(new URLSearchParams(window.location.search)); +} + function DataSource({ children }: React.PropsWithChildren) { - const [url, setUrl] = useState(defaultUrl); + const [url, setUrl] = useState(getInitialDemoUrl); + + useEffect(() => { + const nextHref = buildDemoPageHref(url); + if (window.location.href !== nextHref) { + window.history.replaceState(null, '', nextHref); + } + }, [url]); + + const shareHref = + typeof window !== 'undefined' + ? buildDemoPageHref(url, `${window.location.origin}${window.location.pathname}`) + : ''; + + const source = url.trim() || DEFAULT_DEMO_SPATIALDATA_URL; + return (
-
SpatialData URL
+
+ SpatialData URL (or open with ?url=…) +
setUrl(e.target.value)} style={{ width: '100%', boxSizing: 'border-box', padding: '6px 8px' }} /> + {shareHref ? ( + + Link to this dataset + + ) : null}
- {children} + {children}
); diff --git a/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx new file mode 100644 index 00000000..6021ee92 --- /dev/null +++ b/packages/vis/src/SpatialCanvas/SpatialCanvasViewer.tsx @@ -0,0 +1,386 @@ +import { type SpatialData, viewStateFromBounds } from '@spatialdata/core'; +import { useMeasure } from '@uidotdev/usehooks'; +import type { DeckGLProps, Layer, PickingInfo } from 'deck.gl'; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { + type SpatialCanvasTooltipRenderProps, + SpatialFeatureTooltip, + type SpatialFeatureTooltipData, +} from './SpatialFeatureTooltip'; +import { SpatialViewer } from './SpatialViewer'; +import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; +import type { ElementsByType, LayerConfig, ViewState } from './types'; +import { useLayerData } from './useLayerData'; +import { getAvailableElements } from './utils'; + +export type SpatialCanvasViewerRenderTooltip = + | false + | ((props: SpatialCanvasTooltipRenderProps) => ReactNode); + +export interface SpatialCanvasViewerProps { + spatialData?: SpatialData | null; + coordinateSystem: string | null; + layers: Record; + layerOrder: string[]; + viewState: ViewState | null; + onViewStateChange: (viewState: ViewState) => void; + deckLayers?: Layer[]; + deckProps?: Partial; + onHover?: (info: PickingInfo) => void; + onClick?: (info: PickingInfo) => void; + renderTooltip?: SpatialCanvasViewerRenderTooltip; + tooltipContainer?: HTMLElement | null; + showLoadingOverlay?: boolean; + autoFit?: boolean; + style?: CSSProperties; +} + +interface AutoFitInput { + autoFit: boolean; + hasEnabledLayers: boolean; + width: number; + height: number; + isBlocking: boolean; + viewState: ViewState | null; +} + +export function shouldAutoFitSpatialView({ + autoFit, + hasEnabledLayers, + width, + height, + isBlocking, + viewState, +}: AutoFitInput): boolean { + return ( + autoFit && hasEnabledLayers && width > 0 && height > 0 && !isBlocking && viewState === null + ); +} + +export function composeSpatialDeckLayers( + generatedLayers: Layer[], + externalLayers: Layer[] = [] +): Layer[] { + return [...generatedLayers.filter(Boolean), ...externalLayers.filter(Boolean)]; +} + +export function getEmptyElementsByType(): ElementsByType { + return { images: [], shapes: [], points: [], labels: [] }; +} + +export function shouldRenderInternalTooltip( + renderTooltip: SpatialCanvasViewerRenderTooltip | undefined +): boolean { + return renderTooltip !== false; +} + +export interface UseSpatialCanvasRendererOptions { + spatialData?: SpatialData | null; + coordinateSystem: string | null; + layers: Record; + layerOrder: string[]; + viewState: ViewState | null; + onViewStateChange: (viewState: ViewState) => void; + width: number; + height: number; + deckLayers?: Layer[]; + autoFit?: boolean; +} + +export function useSpatialCanvasRenderer({ + spatialData, + coordinateSystem, + layers, + layerOrder, + viewState, + onViewStateChange, + width, + height, + deckLayers: externalDeckLayers, + autoFit = true, +}: UseSpatialCanvasRendererOptions) { + const availableElements = useMemo(() => { + if (!spatialData || !coordinateSystem) { + return getEmptyElementsByType(); + } + return getAvailableElements(spatialData, coordinateSystem); + }, [spatialData, coordinateSystem]); + + const layerData = useLayerData( + layers, + layerOrder, + availableElements, + coordinateSystem, + spatialData ?? undefined + ); + + const generatedDeckLayers = layerData.getLayers(); + const deckLayers = useMemo( + () => composeSpatialDeckLayers(generatedDeckLayers, externalDeckLayers), + [generatedDeckLayers, externalDeckLayers] + ); + const vivLayerProps = layerData.getVivLayerProps(); + + const enabledLayerIds = useMemo(() => { + return new Set(layerOrder.filter((id) => layers[id]?.visible)); + }, [layers, layerOrder]); + const hasEnabledLayers = enabledLayerIds.size > 0; + const hasExternalDeckLayers = (externalDeckLayers?.length ?? 0) > 0; + const hasRenderableInputs = hasEnabledLayers || hasExternalDeckLayers; + const hasLayersDrawn = deckLayers.length > 0 || vivLayerProps.length > 0; + + useEffect(() => { + if ( + !shouldAutoFitSpatialView({ + autoFit, + hasEnabledLayers, + width, + height, + isBlocking: layerData.isBlocking, + viewState, + }) + ) { + return; + } + const bounds = layerData.getWorldBoundsForVisibleLayers(); + onViewStateChange( + bounds ? viewStateFromBounds(bounds, width, height) : { target: [0, 0], zoom: 0 } + ); + }, [ + autoFit, + hasEnabledLayers, + height, + layerData.isBlocking, + layerData.getWorldBoundsForVisibleLayers, + onViewStateChange, + viewState, + width, + ]); + + return { + ...layerData, + availableElements, + deckLayers, + enabledLayerIds, + generatedDeckLayers, + hasEnabledLayers, + hasExternalDeckLayers, + hasLayersDrawn, + hasRenderableInputs, + vivLayerProps, + }; +} + +const viewerRootStyle: CSSProperties = { + width: '100%', + height: '100%', + minHeight: 0, + minWidth: 0, + position: 'relative', + overflow: 'hidden', +}; + +const placeholderStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: '#666', + fontSize: '14px', +}; + +const overlayStyle: CSSProperties = { + position: 'absolute', + top: 8, + right: 8, + padding: '4px 8px', + backgroundColor: 'rgba(0,0,0,0.7)', + color: '#fff', + fontSize: '11px', + borderRadius: 4, +}; + +function SpatialCanvasViewerInner({ + spatialData, + coordinateSystem, + layers, + layerOrder, + viewState, + onViewStateChange, + deckLayers: externalDeckLayers, + deckProps, + onHover, + onClick, + renderTooltip, + tooltipContainer, + showLoadingOverlay = true, + autoFit = true, + style, +}: SpatialCanvasViewerProps) { + const [measureRef, { width, height }] = useMeasure(); + const viewerContainerRef = useRef(null); + const [hoverTooltip, setHoverTooltip] = useState<{ + x: number; + y: number; + title?: string; + items: Array<{ label: string; value: string }>; + } | null>(null); + + const vw = width ?? 0; + const vh = height ?? 0; + const renderer = useSpatialCanvasRenderer({ + spatialData, + coordinateSystem, + layers, + layerOrder, + viewState, + onViewStateChange, + width: vw, + height: vh, + deckLayers: externalDeckLayers, + autoFit, + }); + + const handleHover = useCallback( + (info: PickingInfo) => { + onHover?.(info); + if (!shouldRenderInternalTooltip(renderTooltip)) { + return; + } + if (!info.picked || typeof info.x !== 'number' || typeof info.y !== 'number') { + setHoverTooltip(null); + return; + } + const rawLayerId = typeof info.layer?.id === 'string' ? info.layer.id : ''; + const normalizedLayerId = rawLayerId.replace(/-#.*#$/, ''); + const tooltip = renderer.getFeatureTooltip(normalizedLayerId, { + index: info.index, + object: info.object, + }); + if (!tooltip) { + setHoverTooltip(null); + return; + } + setHoverTooltip({ + x: info.x, + y: info.y, + ...tooltip, + }); + }, + [onHover, renderTooltip, renderer.getFeatureTooltip] + ); + + const handleViewerRef = useCallback( + (node: HTMLDivElement | null) => { + viewerContainerRef.current = node; + measureRef(node); + }, + [measureRef] + ); + + const viewerRect = viewerContainerRef.current?.getBoundingClientRect(); + const tooltipClientPosition = + hoverTooltip && viewerRect + ? { + x: viewerRect.left + hoverTooltip.x, + y: viewerRect.top + hoverTooltip.y, + } + : null; + + const tooltipPayload: SpatialFeatureTooltipData | null = + hoverTooltip && tooltipClientPosition + ? { + title: hoverTooltip.title, + items: hoverTooltip.items, + } + : null; + + const portalTarget = typeof document !== 'undefined' ? (tooltipContainer ?? document.body) : null; + const tooltipPortal = + shouldRenderInternalTooltip(renderTooltip) && + tooltipPayload && + tooltipClientPosition && + portalTarget && + createPortal( + renderTooltip ? ( + renderTooltip({ + clientX: tooltipClientPosition.x, + clientY: tooltipClientPosition.y, + tooltip: tooltipPayload, + }) + ) : ( + + ), + portalTarget + ); + + return ( + <> +
+ {!spatialData && !renderer.hasRenderableInputs ? ( +
No spatial data available
+ ) : !renderer.hasRenderableInputs ? ( +
+ {coordinateSystem ? 'No layers to display' : 'No coordinate system selected'} +
+ ) : viewState === null && renderer.hasEnabledLayers ? ( +
+ {renderer.isBlocking ? 'Loading layer data...' : 'Framing view...'} +
+ ) : ( + <> + 0 ? renderer.vivLayerProps : undefined} + onHover={handleHover} + onClick={onClick} + deckProps={deckProps} + /> + {showLoadingOverlay && renderer.isBlocking && ( +
Loading layer data...
+ )} + {showLoadingOverlay && renderer.isLoading && !renderer.isBlocking && ( +
+ Refreshing layer metadata... +
+ )} + {!renderer.hasLayersDrawn && !renderer.isBlocking && ( +
+ No layers to display +
+ )} + + )} +
+ {tooltipPortal} + + ); +} + +export function SpatialCanvasViewer(props: SpatialCanvasViewerProps) { + return ( + + + + ); +} + +export default SpatialCanvasViewer; diff --git a/packages/vis/src/SpatialCanvas/SpatialViewer.tsx b/packages/vis/src/SpatialCanvas/SpatialViewer.tsx index d5a54d06..9dbc65fb 100644 --- a/packages/vis/src/SpatialCanvas/SpatialViewer.tsx +++ b/packages/vis/src/SpatialCanvas/SpatialViewer.tsx @@ -12,9 +12,9 @@ import { DetailView } from '@hms-dbmi/viv'; import { DeckGL } from 'deck.gl'; -import type { Layer, PickingInfo } from 'deck.gl'; +import type { DeckGLProps, Layer, PickingInfo } from 'deck.gl'; import { useCallback, useId, useMemo } from 'react'; -import VivSpatialViewer from './VivSpatialViewer'; +import VivSpatialViewer, { normalizeVivLayers } from './VivSpatialViewer'; import type { ViewState } from './types'; import type { ImageLayerConfig } from './useLayerData'; @@ -35,6 +35,8 @@ export interface SpatialViewerProps { onHover?: (info: PickingInfo) => void; /** Optional: Callback on click */ onClick?: (info: PickingInfo) => void; + /** Optional: Additional deck.gl props */ + deckProps?: Partial; } /** @@ -53,6 +55,7 @@ export function SpatialViewer({ vivLayerProps, onHover, onClick, + deckProps, }: SpatialViewerProps) { const hasImageLayers = vivLayerProps && vivLayerProps.length > 0; @@ -68,6 +71,7 @@ export function SpatialViewer({ extraLayers={layers} onHover={onHover} onClick={onClick} + deckProps={deckProps} /> ); } @@ -82,6 +86,7 @@ export function SpatialViewer({ layers={layers} onHover={onHover} onClick={onClick} + deckProps={deckProps} /> ); } @@ -97,6 +102,7 @@ function SpatialViewerSimple({ layers, onHover, onClick, + deckProps, }: Omit) { const viewId = useId(); const detailViewId = useMemo(() => `spatial-${viewId}`, [viewId]); @@ -156,8 +162,8 @@ function SpatialViewerSimple({ // Filter out any null/undefined layers const composedLayers = useMemo(() => { - return layers.filter(Boolean); - }, [layers]); + return [...layers.filter(Boolean), ...normalizeVivLayers(deckProps?.layers ?? [])]; + }, [deckProps?.layers, layers]); // Don't render if dimensions are invalid if (width <= 0 || height <= 0) { @@ -168,6 +174,7 @@ function SpatialViewerSimple({ return ( (isDragging ? 'grabbing' : 'crosshair')} - style={{ backgroundColor: '#111' }} + style={{ backgroundColor: '#111', ...deckProps?.style }} /> ); } diff --git a/packages/vis/src/SpatialCanvas/index.tsx b/packages/vis/src/SpatialCanvas/index.tsx index c391f1dd..0c989d9b 100644 --- a/packages/vis/src/SpatialCanvas/index.tsx +++ b/packages/vis/src/SpatialCanvas/index.tsx @@ -22,21 +22,21 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { ImageChannelPanel } from './ImageChannelPanel'; -import { LayerOrderList } from './LayerOrderList'; import { LabelsChannelPanel } from './LabelsChannelPanel'; -import { TooltipFieldsPanel } from './TooltipFieldsPanel'; +import { LayerOrderList } from './LayerOrderList'; +import { useSpatialCanvasRenderer } from './SpatialCanvasViewer'; import { type SpatialCanvasTooltipRenderProps, SpatialFeatureTooltip, type SpatialFeatureTooltipData, } from './SpatialFeatureTooltip'; import { SpatialViewer } from './SpatialViewer'; +import { TooltipFieldsPanel } from './TooltipFieldsPanel'; import { VivLoaderRegistryProvider } from './VivLoaderRegistry'; import { SpatialCanvasProvider, useSpatialCanvasActions, useSpatialCanvasStore } from './context'; import type { SpatialCanvasStoreApi } from './stores'; import type { AvailableElement, ElementsByType, LayerConfig, ViewState } from './types'; -import { useLayerData } from './useLayerData'; -import { generateLayerId, getAllCoordinateSystems, getAvailableElements } from './utils'; +import { generateLayerId, getAllCoordinateSystems } from './utils'; export { SpatialFeatureTooltip, @@ -58,6 +58,17 @@ export type { SpatialCanvasStoreApi } from './stores'; export type * from './types'; export { useSpatialViewState, useViewStateUrl } from './hooks'; export { VivSpatialViewer } from './VivSpatialViewer'; +export { + SpatialCanvasViewer, + composeSpatialDeckLayers, + shouldRenderInternalTooltip, + shouldAutoFitSpatialView, + useSpatialCanvasRenderer, +} from './SpatialCanvasViewer'; +export type { + SpatialCanvasViewerProps, + SpatialCanvasViewerRenderTooltip, +} from './SpatialCanvasViewer'; export type { ImageLayerConfig as VivImageLayerConfig } from './useLayerData'; // ============================================ @@ -252,35 +263,6 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn return getAllCoordinateSystems(spatialData); }, [spatialData]); - const availableElements = useMemo(() => { - if (!spatialData || !coordinateSystem) { - return { images: [], shapes: [], points: [], labels: [] } satisfies ElementsByType; - } - return getAvailableElements(spatialData, coordinateSystem); - }, [spatialData, coordinateSystem]); - - const { - getLayers, - getVivLayerProps, - getImageLayerLoadedData, - getLabelsLayerLoadedData, - getLayerLoadState, - hasRenderableLayerData, - getFeatureTooltip, - isLoading, - isBlocking, - getWorldBoundsForLayer, - getWorldBoundsForVisibleLayers, - } = useLayerData( - layers, - layerOrder, - availableElements, - coordinateSystem, - spatialData ?? undefined - ); - - const deckLayers = getLayers(); - const vivLayerProps = getVivLayerProps(); const handleViewStateChange = useCallback( (vs: ViewState) => { actions.setViewState(vs); @@ -288,24 +270,34 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn [actions] ); - const enabledLayerIds = useMemo(() => { - return new Set(layerOrder.filter((id) => layers[id]?.visible)); - }, [layers, layerOrder]); - const vw = width ?? 0; const vh = height ?? 0; - const hasEnabledLayers = enabledLayerIds.size > 0; - - useEffect(() => { - if (!hasEnabledLayers || vw <= 0 || vh <= 0 || isBlocking || viewState !== null) { - return; - } - const bounds = getWorldBoundsForVisibleLayers(); - const next = bounds - ? viewStateFromBounds(bounds, vw, vh) - : { target: [0, 0] as [number, number], zoom: 0 }; - actions.setViewState(next); - }, [hasEnabledLayers, vw, vh, isBlocking, viewState, getWorldBoundsForVisibleLayers, actions]); + const { + availableElements, + deckLayers, + enabledLayerIds, + getFeatureTooltip, + getImageLayerLoadedData, + getLabelsLayerLoadedData, + getLayerLoadState, + getWorldBoundsForLayer, + getWorldBoundsForVisibleLayers, + hasEnabledLayers, + hasLayersDrawn, + hasRenderableLayerData, + isBlocking, + isLoading, + vivLayerProps, + } = useSpatialCanvasRenderer({ + spatialData, + coordinateSystem, + layers, + layerOrder, + viewState, + onViewStateChange: handleViewStateChange, + width: vw, + height: vh, + }); useEffect(() => { if ( @@ -382,7 +374,6 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn [layers, actions] ); - const hasLayersDrawn = deckLayers.length > 0 || vivLayerProps.length > 0; const selectedConfig = selectedLayerId ? layers[selectedLayerId] : undefined; const associatedTable = selectedConfig?.type === 'shapes' @@ -392,22 +383,12 @@ function SpatialCanvasInner({ tooltipContainer, renderTooltip }: SpatialCanvasIn : undefined; const selectedLayerLoadState = getLayerLoadState(selectedConfig?.id); - /** Avoid recomputing polygon/image bounds on every pan (viewState) — only when layer data / CS / selection changes. */ - const selectedLayerWorldBounds = useMemo(() => { + const selectedLayerWorldBounds = (() => { const id = selectedConfig?.id; if (!id) return null; if (!hasRenderableLayerData(id)) return null; return getWorldBoundsForLayer(id); - }, [ - selectedConfig?.id, - selectedLayerLoadState, - coordinateSystem, - layerOrder, - layers, - availableElements, - getWorldBoundsForLayer, - hasRenderableLayerData, - ]); + })(); // we probably want to see more than obs columns here... but I also don't understand what subset of those we end up with. // why not allow instanceKey & regionKey... diff --git a/packages/vis/src/index.ts b/packages/vis/src/index.ts index 76a98103..8ead4e57 100644 --- a/packages/vis/src/index.ts +++ b/packages/vis/src/index.ts @@ -15,6 +15,7 @@ export { default as Table } from './Table'; // SpatialCanvas - composable spatial layers viewer export { default as SpatialCanvas } from './SpatialCanvas'; export { + SpatialCanvasViewer, SpatialCanvasProvider, useSpatialCanvasStore, useSpatialCanvasActions, @@ -22,6 +23,10 @@ export { createSpatialCanvasStore, useSpatialViewState, useViewStateUrl, + composeSpatialDeckLayers, + shouldRenderInternalTooltip, + shouldAutoFitSpatialView, + useSpatialCanvasRenderer, } from './SpatialCanvas'; export type { SpatialCanvasStoreApi, @@ -34,6 +39,8 @@ export type { AvailableElement, ElementsByType, SpatialCanvasProps, + SpatialCanvasViewerProps, + SpatialCanvasViewerRenderTooltip, SpatialFeatureTooltipData, SpatialFeatureTooltipItem, SpatialCanvasTooltipRenderProps, diff --git a/packages/vis/tests/demoUrl.spec.ts b/packages/vis/tests/demoUrl.spec.ts new file mode 100644 index 00000000..6e78d112 --- /dev/null +++ b/packages/vis/tests/demoUrl.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_DEMO_SPATIALDATA_URL, + buildDemoPageHref, + getSpatialDataUrlFromSearchParams, +} from '../src/Sketch/demoUrl.js'; + +describe('getSpatialDataUrlFromSearchParams', () => { + it('uses fallback when url param is absent', () => { + expect(getSpatialDataUrlFromSearchParams(new URLSearchParams())).toBe( + DEFAULT_DEMO_SPATIALDATA_URL + ); + }); + + it('reads url param', () => { + const store = 'https://example.com/data.zarr'; + expect( + getSpatialDataUrlFromSearchParams(new URLSearchParams({ url: store })) + ).toBe(store); + }); + + it('decodes encoded url param', () => { + const store = 'https://example.com/a b.zarr'; + expect( + getSpatialDataUrlFromSearchParams( + new URLSearchParams({ url: encodeURIComponent(store) }) + ) + ).toBe(store); + }); + + it('treats blank url param as fallback', () => { + expect(getSpatialDataUrlFromSearchParams(new URLSearchParams({ url: ' ' }))).toBe( + DEFAULT_DEMO_SPATIALDATA_URL + ); + }); +}); + +describe('buildDemoPageHref', () => { + it('sets url search param', () => { + const href = buildDemoPageHref( + 'https://example.com/dataset.zarr', + 'https://demo.test/sketch' + ); + expect(href).toBe( + 'https://demo.test/sketch?url=https%3A%2F%2Fexample.com%2Fdataset.zarr' + ); + }); +}); diff --git a/packages/vis/tests/index.spec.tsx b/packages/vis/tests/index.spec.tsx index 856a3c37..8f6fd77c 100644 --- a/packages/vis/tests/index.spec.tsx +++ b/packages/vis/tests/index.spec.tsx @@ -6,6 +6,8 @@ describe('@spatialdata/vis', () => { // SpatialCanvas is exported as a named export, not default expect(VisExports.SpatialCanvas).toBeDefined(); expect(typeof VisExports.SpatialCanvas).toBe('function'); + expect(VisExports.SpatialCanvasViewer).toBeDefined(); + expect(typeof VisExports.SpatialCanvasViewer).toBe('function'); }); it('should export named components', () => { @@ -25,6 +27,8 @@ describe('@spatialdata/vis', () => { expect(VisExports.createSpatialCanvasStore).toBeDefined(); expect(VisExports.useSpatialViewState).toBeDefined(); expect(VisExports.useViewStateUrl).toBeDefined(); + expect(VisExports.composeSpatialDeckLayers).toBeDefined(); + expect(VisExports.shouldAutoFitSpatialView).toBeDefined(); }); it('should have all expected exports', () => { diff --git a/packages/vis/tests/spatialCanvasViewer.spec.ts b/packages/vis/tests/spatialCanvasViewer.spec.ts new file mode 100644 index 00000000..e32b0d34 --- /dev/null +++ b/packages/vis/tests/spatialCanvasViewer.spec.ts @@ -0,0 +1,52 @@ +import { ScatterplotLayer } from 'deck.gl'; +import { describe, expect, it } from 'vitest'; +import { + composeSpatialDeckLayers, + shouldAutoFitSpatialView, + shouldRenderInternalTooltip, +} from '../src/SpatialCanvas/SpatialCanvasViewer.js'; + +describe('composeSpatialDeckLayers', () => { + it('places caller-provided deck layers after generated SpatialData layers', () => { + const generated = new ScatterplotLayer({ id: 'generated', data: [], getPosition: [0, 0] }); + const external = new ScatterplotLayer({ id: 'external', data: [], getPosition: [0, 0] }); + + expect(composeSpatialDeckLayers([generated], [external]).map((layer) => layer.id)).toEqual([ + 'generated', + 'external', + ]); + }); +}); + +describe('shouldAutoFitSpatialView', () => { + it('only auto-fits when the view is unset and renderable dimensions are available', () => { + expect( + shouldAutoFitSpatialView({ + autoFit: true, + hasEnabledLayers: true, + width: 600, + height: 400, + isBlocking: false, + viewState: null, + }) + ).toBe(true); + + expect( + shouldAutoFitSpatialView({ + autoFit: true, + hasEnabledLayers: true, + width: 600, + height: 400, + isBlocking: false, + viewState: { target: [0, 0], zoom: 0 }, + }) + ).toBe(false); + }); +}); + +describe('shouldRenderInternalTooltip', () => { + it('disables internal tooltip rendering when renderTooltip is false', () => { + expect(shouldRenderInternalTooltip(false)).toBe(false); + expect(shouldRenderInternalTooltip(undefined)).toBe(true); + }); +});