diff --git a/main.ts b/main.ts index e3308a3f..4df6713c 100644 --- a/main.ts +++ b/main.ts @@ -2,7 +2,7 @@ import debounce from "just-debounce-it"; import * as vizarr from "./src/index"; async function main() { - console.log(`vizarr v${vizarr.version}: https://github.com/hms-dbmi/vizarr`); + console.log(`vizarr v${vizarr.version}: https://github.com/BioNGFF/vizarr`); // biome-ignore lint/style/noNonNullAssertion: We know the element exists const viewer = await vizarr.createViewer(document.querySelector("#root")!); const url = new URL(window.location.href); diff --git a/src/ZarrPixelSource.ts b/src/ZarrPixelSource.ts index 4445dc08..44b3193c 100644 --- a/src/ZarrPixelSource.ts +++ b/src/ZarrPixelSource.ts @@ -83,12 +83,15 @@ export class ZarrPixelSource implements viv.PixelSource> { async getRaster(options: { selection: viv.PixelSourceSelection> | Array; signal?: AbortSignal; + window?: { x: [number, number]; y: [number, number] }; }): Promise { - const { selection, signal } = options; + const { selection, signal, window } = options; + const xSlice = makeSlice(window?.x, this.#width); + const ySlice = makeSlice(window?.y, this.#height); return this.#fetchData({ selection: buildZarrSelection(selection, { labels: this.labels, - slices: { x: zarr.slice(null), y: zarr.slice(null) }, + slices: { x: xSlice, y: ySlice }, }), signal, }); @@ -180,3 +183,21 @@ function capitalize(s: T): Capitalize { // @ts-expect-error - TypeScript can't verify that the return type is correct return s[0].toUpperCase() + s.slice(1); } + +function makeSlice(range: [number, number] | undefined, limit: number) { + if (!range) { + return zarr.slice(null); + } + const [rawStart, rawStop] = range; + if (!Number.isFinite(rawStart) || !Number.isFinite(rawStop)) { + return zarr.slice(null); + } + if (limit <= 0) { + return zarr.slice(null); + } + const maxStart = Math.max(0, limit - 1); + const start = Math.min(Math.max(0, Math.floor(rawStart)), maxStart); + const roundedStop = Math.ceil(rawStop); + const stop = Math.min(limit, Math.max(start + 1, roundedStop)); + return zarr.slice(start, stop); +} diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx index f54a58dd..58af938c 100644 --- a/src/components/Viewer.tsx +++ b/src/components/Viewer.tsx @@ -4,6 +4,7 @@ import { OrthographicView } from "deck.gl"; import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; +import { useAxisNavigation } from "../hooks/useAxisNavigation"; import { layerAtoms, viewportAtom } from "../state"; import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils"; @@ -11,13 +12,115 @@ import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl"; import type { GrayscaleBitmapLayerPickingInfo } from "../layers/label-layer"; import type { ViewState, VizarrLayer } from "../state"; +const VIEWSTATE_EPSILON = 1e-3; + +function mapDeckToViewState(next: OrthographicViewState, prev?: ViewState | null): ViewState { + const targetCandidate = (Array.isArray(next.target) ? next.target : (prev?.target ?? [])) as number[]; + const resolvedTarget: [number, number] = + targetCandidate.length >= 2 + ? [Number(targetCandidate[0] ?? 0), Number(targetCandidate[1] ?? 0)] + : (prev?.target ?? [0, 0]); + const zoom = typeof next.zoom === "number" ? next.zoom : (prev?.zoom ?? 0); + const width = + typeof (next as { width?: unknown }).width === "number" ? (next as { width: number }).width : prev?.width; + const height = + typeof (next as { height?: unknown }).height === "number" ? (next as { height: number }).height : prev?.height; + return { + zoom, + target: resolvedTarget, + width, + height, + }; +} + +function hasViewportDimensions(state: unknown): state is ViewState & { width: number; height: number } { + if (!state || typeof state !== "object") { + return false; + } + const maybe = state as { width?: unknown; height?: unknown }; + return typeof maybe.width === "number" && typeof maybe.height === "number"; +} + +function viewStatesApproximatelyEqual( + a: OrthographicViewState | null, + b: (OrthographicViewState | ViewState) | null, +): boolean { + if (!a || !b) { + return a === (b as OrthographicViewState | null); + } + const nextTarget = Array.isArray(a.target) ? a.target.map((value) => Number(value)) : []; + const rawPrevTarget = Array.isArray((b as OrthographicViewState).target) + ? (b as OrthographicViewState).target + : ((b as ViewState).target ?? []); + const prevTarget = (rawPrevTarget as number[]).map((value) => Number(value)); + const length = Math.min(nextTarget.length, prevTarget.length); + for (let i = 0; i < length; i += 1) { + if (Math.abs(nextTarget[i] - prevTarget[i]) > VIEWSTATE_EPSILON) { + return false; + } + } + const zoomA = typeof a.zoom === "number" ? a.zoom : 0; + const zoomCandidate = (b as OrthographicViewState).zoom ?? (b as ViewState).zoom; + const zoomB = typeof zoomCandidate === "number" ? zoomCandidate : 0; + return Math.abs(zoomA - zoomB) <= VIEWSTATE_EPSILON; +} + export default function Viewer() { const deckRef = React.useRef(null); const [viewport, setViewport] = useAtom(viewportAtom); const [viewState, setViewState] = useViewState(); + const [localViewState, setLocalViewState] = React.useState(null); const layers = useAtomValue(layerAtoms); const firstLayer = layers[0] as VizarrLayer; + useAxisNavigation(deckRef, viewport); + + const pendingViewStateRef = React.useRef(null); + const pendingFrameRef = React.useRef(); + const interactionStateRef = React.useRef({ isActive: false }); + + const cancelPendingFrame = React.useCallback(() => { + if (pendingFrameRef.current !== undefined) { + window.cancelAnimationFrame(pendingFrameRef.current); + pendingFrameRef.current = undefined; + } + }, []); + + const flushPendingViewState = React.useCallback(() => { + cancelPendingFrame(); + const next = pendingViewStateRef.current; + pendingViewStateRef.current = null; + if (next) { + setViewState((prev) => mapDeckToViewState(next, prev)); + } + }, [cancelPendingFrame, setViewState]); + + const scheduleViewStateCommit = React.useCallback( + (next: OrthographicViewState, immediate = false) => { + pendingViewStateRef.current = next; + if (immediate) { + flushPendingViewState(); + return; + } + if (pendingFrameRef.current !== undefined) { + return; + } + pendingFrameRef.current = window.requestAnimationFrame(() => { + pendingFrameRef.current = undefined; + flushPendingViewState(); + }); + }, + [flushPendingViewState], + ); + + React.useEffect( + () => () => { + cancelPendingFrame(); + pendingViewStateRef.current = null; + }, + [cancelPendingFrame], + ); + const resetViewState = React.useCallback( (layer: VizarrLayer) => { const { deck } = deckRef.current || {}; @@ -54,8 +157,23 @@ export default function Viewer() { } }, [viewport, setViewport, firstLayer, resetViewState, viewState, setViewState]); + React.useEffect(() => { + if (!viewState) { + cancelPendingFrame(); + pendingViewStateRef.current = null; + setLocalViewState(null); + return; + } + if (!viewStatesApproximatelyEqual(pendingViewStateRef.current, viewState)) { + pendingViewStateRef.current = null; + } + setLocalViewState((prev) => + viewStatesApproximatelyEqual(prev, viewState) ? prev : (viewState as OrthographicViewState), + ); + }, [cancelPendingFrame, viewState]); + const deckLayers = React.useMemo(() => { - if (!firstLayer || !(viewState?.width && viewState?.height)) { + if (!firstLayer || !hasViewportDimensions(viewState)) { return layers; } const loader = resolveLoaderFromLayerProps(firstLayer.props); @@ -65,7 +183,7 @@ export default function Viewer() { id: "scalebar", size: size / firstLayer.props.modelMatrix[0], unit: unit, - viewState: viewState, + viewState: viewState as unknown as OrthographicViewState, snap: false, }); return [...layers, scalebar]; @@ -138,11 +256,24 @@ export default function Viewer() { - // @ts-expect-error - deck doesn't know this should be ok - setViewState(e.viewState) - } + viewState={localViewState ? { ortho: localViewState } : undefined} + controller={{ keyboard: true }} + onViewStateChange={(event: { + viewState: OrthographicViewState; + interactionState?: { inTransition?: boolean }; + }) => { + const { viewState: next, interactionState } = event; + setLocalViewState((prev) => (viewStatesApproximatelyEqual(prev, next) ? prev : next)); + const immediate = !(interactionState?.inTransition ?? false); + scheduleViewStateCommit(next, immediate); + }} + onInteractionStateChange={(state) => { + const isActive = Boolean(state.isDragging || state.isZooming || state.isRotating || state.isPanning); + if (interactionStateRef.current.isActive && !isActive) { + flushPendingViewState(); + } + interactionStateRef.current = { isActive }; + }} views={[new OrthographicView({ id: "ortho", controller: true, near, far })]} glOptions={glOptions} getTooltip={getTooltip} diff --git a/src/hooks/useAxisNavigation.ts b/src/hooks/useAxisNavigation.ts new file mode 100644 index 00000000..ab5c5a7a --- /dev/null +++ b/src/hooks/useAxisNavigation.ts @@ -0,0 +1,297 @@ +import type { DeckGLRef, PickingInfo } from "deck.gl"; +import { useAtomCallback } from "jotai/utils"; +import * as React from "react"; +import { layerFamilyAtom, sourceInfoAtom } from "../state"; + +type DeckInstance = DeckGLRef["deck"] | null; + +type Axis = "z" | "t"; +type AdjustArgs = { + axis: Axis; + delta: number; + pointer?: { x: number; y: number }; +}; + +const AXIS_SCROLL_STEP_DELTA = 40; + +export function useAxisNavigation(deckRef: React.RefObject, viewport: DeckInstance) { + const [axisScrollKey, setAxisScrollKey] = React.useState(null); + const axisScrollKeyRef = React.useRef(null); + const axisScrollAccumulatorRef = React.useRef(0); + const lastPointerRef = React.useRef<{ x: number; y: number } | undefined>(undefined); + const lastTargetSourceIdRef = React.useRef(undefined); + + const updateAxisScrollKey = React.useCallback((nextKey: Axis | null) => { + axisScrollKeyRef.current = nextKey; + setAxisScrollKey(nextKey); + }, []); + + const adjustAxis = useAtomCallback( + React.useCallback( + (get, set, { axis, delta, pointer }: AdjustArgs) => { + if (delta === 0) { + return; + } + + const deckInstance = viewport ?? deckRef.current?.deck ?? null; + const canvas = (deckInstance as { canvas?: HTMLCanvasElement } | null)?.canvas; + if (!deckInstance || !canvas) { + return; // no deck instance or canvas + } + + const rect = canvas.getBoundingClientRect(); + if (pointer) { + lastPointerRef.current = pointer; + } + + const sources = get(sourceInfoAtom); + if (sources.length === 0) { + return; + } + + const getAxisIndex = (source: (typeof sources)[number]) => + (source.axis_labels ?? []).findIndex((label) => label.toLowerCase() === axis); + + const pointerToUse = pointer ?? lastPointerRef.current; + + let targetSource: (typeof sources)[number] | undefined; + let axisIndex = -1; + + if (pointerToUse) { + const { x, y } = pointerToUse; + if (x >= 0 && y >= 0 && x <= rect.width && y <= rect.height) { + const picks = (deckInstance.pickMultipleObjects({ x, y, depth: 1 }) ?? []) as PickingInfo[]; + const pickedLayerId = (() => { + const pick = picks.find((info: PickingInfo) => info.layer && typeof info.layer.props?.id === "string"); + if (!pick || !pick.layer?.props?.id) { + return undefined; + } + return String(pick.layer.props.id); + })(); + + if (pickedLayerId) { + targetSource = sources.find( + (item) => + pickedLayerId === item.id || + pickedLayerId.startsWith(`${item.id}_`) || + pickedLayerId.startsWith(`${item.id}-`), + ); + if (targetSource) { + axisIndex = getAxisIndex(targetSource); + } + } + } + } + + if ((!targetSource || axisIndex === -1) && lastTargetSourceIdRef.current) { + targetSource = sources.find((item) => item.id === lastTargetSourceIdRef.current); + if (targetSource) { + axisIndex = getAxisIndex(targetSource); + } + } + + if (!targetSource) { + targetSource = sources[0]; + axisIndex = targetSource ? getAxisIndex(targetSource) : -1; + } + + if (!targetSource || axisIndex === -1) { + return; + } + + lastTargetSourceIdRef.current = targetSource.id; + + const baseLoader = targetSource.loader?.[0]; + const shape = baseLoader?.shape; + if (!shape || axisIndex >= shape.length) { + return; + } + + const maxIndex = shape[axisIndex] - 1; + if (maxIndex <= 0) { + return; + } + + const layerAtom = layerFamilyAtom(targetSource); + const layerState = get(layerAtom); + if (!layerState) { + return; + } + + const { layerProps } = layerState; + const selections = layerProps.selections; + if (selections.length === 0) { + return; + } + + const currentIndex = selections[0]?.[axisIndex] ?? 0; + const nextIndex = Math.min(Math.max(currentIndex + delta, 0), maxIndex); + if (nextIndex === currentIndex) { + return; + } + + const nextSelections = selections.map((selection: number[]) => { + const next = [...selection]; + next[axisIndex] = nextIndex; + return next; + }); + + set(layerAtom, { + ...layerState, + layerProps: { + ...layerProps, + selections: nextSelections, + }, + }); + + const defaultSelection = nextSelections[0] ? [...nextSelections[0]] : undefined; + if (!defaultSelection) { + return; + } + + const resolvedTarget = targetSource; + + set(sourceInfoAtom, (prev: typeof sources) => + prev.map((item) => { + if (item.id !== resolvedTarget.id) { + return item; + } + const prevSelection = item.defaults.selection; + const isSame = + prevSelection.length === defaultSelection.length && + prevSelection.every((value: number, index: number) => value === defaultSelection[index]); + if (isSame) { + return item; + } + return { + ...item, + defaults: { + ...item.defaults, + selection: defaultSelection, + }, + }; + }), + ); + }, + [viewport, deckRef], + ), + ); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const lower = event.key.toLowerCase(); + if (lower === "z" || lower === "t") { + event.preventDefault(); + event.stopPropagation(); + updateAxisScrollKey(lower as Axis); + return; // set when pressing the key + } + + if ( + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" + ) { + const axis = axisScrollKeyRef.current; + if (!axis) { + return; // only respond when an axis key is active + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + return; // suppress vertical arrows when an axis key is active + } + const delta = event.key === "ArrowLeft" ? -1 : 1; + event.preventDefault(); + event.stopPropagation(); + void adjustAxis({ axis, delta }); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + const lower = event.key.toLowerCase(); + if (lower === "z" || lower === "t") { + event.preventDefault(); + event.stopPropagation(); + if (axisScrollKeyRef.current === lower) { + updateAxisScrollKey(null); + } + } // reset when letting go of the key + }; + + const handleBlur = () => { + // reset when switching windows + updateAxisScrollKey(null); + }; + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("blur", handleBlur); + }; + }, [adjustAxis, updateAxisScrollKey]); + + React.useEffect(() => { + // reset accumulator when axis key changes + axisScrollAccumulatorRef.current = 0; + void axisScrollKey; + }, [axisScrollKey]); + + const handleWheel = React.useCallback( + (event: WheelEvent) => { + if (!axisScrollKey) { + return; // ignore if no axis key is set, fall back to default zoom behavior + } + + const deckInstance = viewport ?? deckRef.current?.deck ?? null; + const canvas = (deckInstance as { canvas?: HTMLCanvasElement } | null)?.canvas; + if (!deckInstance || !canvas) { + return; // no deck instance or canvas + } + + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + if (x < 0 || y < 0 || x > rect.width || y > rect.height) { + return; // only consider events within the canvas + } + + event.preventDefault(); + event.stopPropagation(); + + axisScrollAccumulatorRef.current += event.deltaY; + const steps = Math.trunc(axisScrollAccumulatorRef.current / AXIS_SCROLL_STEP_DELTA); + if (steps === 0) { + return; + } + + axisScrollAccumulatorRef.current -= steps * AXIS_SCROLL_STEP_DELTA; + + const pointer = { x, y }; + void adjustAxis({ axis: axisScrollKey, delta: -steps, pointer }); + }, + [axisScrollKey, viewport, deckRef, adjustAxis], + ); + + React.useEffect(() => { + // attach wheel listener to deck canvas + const deckInstance = (viewport ?? deckRef.current?.deck ?? null) as { canvas?: HTMLCanvasElement } | null; + const element = deckInstance?.canvas; + if (!element) { + return; + } + + const listener = (event: WheelEvent) => { + handleWheel(event); + }; + + element.addEventListener("wheel", listener, { passive: false }); + return () => { + element.removeEventListener("wheel", listener); + }; + }, [viewport, handleWheel, deckRef]); +} diff --git a/src/layers/grid-layer.ts b/src/layers/grid-layer.ts index eabb7ac3..b6337f51 100644 --- a/src/layers/grid-layer.ts +++ b/src/layers/grid-layer.ts @@ -1,4 +1,6 @@ import { CompositeLayer, SolidPolygonLayer, TextLayer } from "deck.gl"; +import type { Viewport } from "deck.gl"; +import { Matrix4 } from "math.gl"; import pMap from "p-map"; import { ColorPaletteExtension, XRLayer } from "@hms-dbmi/viv"; @@ -9,7 +11,7 @@ import { assert } from "../utils"; import type { BaseLayerProps } from "./viv-layers"; export interface GridLoader { - loader: ZarrPixelSource; + sources: ZarrPixelSource[]; row: number; col: number; name: string; @@ -30,52 +32,306 @@ export interface GridLayerProps concurrency?: number; } -function scaleBounds(width: number, height: number, translate = [0, 0], scale = 1) { - const [left, top] = translate; - const right = width * scale + left; - const bottom = height * scale + top; - return [left, bottom, right, top]; +const MIN_PIXELS_PER_DATA_PIXEL = 0.5; + +type DeckBounds = [left: number, bottom: number, right: number, top: number]; + +type CellBounds = { + left: number; + right: number; + top: number; + bottom: number; +}; + +type Dimensions = { + width: number; + height: number; +}; + +type VisibleGridCell = { + loader: GridLoader; + cellBounds: CellBounds; + viewportBounds: CellBounds; +}; + +type GridContext = { + fullSize: Dimensions; + spacer: number; + visibleCells: VisibleGridCell[]; +}; + +type GridDataEntry = GridLoader & { + bounds: DeckBounds; + coversWholeCell: boolean; + source: ZarrPixelSource; + sourceIndex: number; + data: { + data: SupportedTypedArray[]; + width: number; + height: number; + }; +}; + +function clamp(value: number, min: number, max: number) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +function getCellBounds(loader: GridLoader, width: number, height: number, spacer: number): CellBounds { + const left = loader.col * (width + spacer); + const top = loader.row * (height + spacer); + const right = left + width; + const bottom = top + height; + return { left, top, right, bottom }; +} + +function toDeckBounds(bounds: CellBounds): DeckBounds { + return [bounds.left, bounds.bottom, bounds.right, bounds.top]; +} + +function intersectBounds(a: CellBounds, b: CellBounds): CellBounds | null { + const left = Math.max(a.left, b.left); + const right = Math.min(a.right, b.right); + const top = Math.max(a.top, b.top); + const bottom = Math.min(a.bottom, b.bottom); + if (right <= left || bottom <= top) { + return null; + } + return { left, right, top, bottom }; +} + +function getViewportBounds(viewport: Viewport, modelMatrix?: Matrix4): CellBounds { + let inverse: Matrix4 | null = null; + if (modelMatrix) { + try { + inverse = new Matrix4(modelMatrix).invert(); + } catch { + inverse = null; + } + } + const corners = [ + viewport.unproject([0, 0, 0]), + viewport.unproject([viewport.width, 0, 0]), + viewport.unproject([viewport.width, viewport.height, 0]), + viewport.unproject([0, viewport.height, 0]), + ]; + const transformed = inverse ? corners.map((corner) => inverse.transformAsPoint(corner)) : corners; + const xs = transformed.map((p) => p[0]); + const ys = transformed.map((p) => p[1]); + return { + left: Math.min(...xs), + right: Math.max(...xs), + top: Math.min(...ys), + bottom: Math.max(...ys), + }; +} + +function getAllGridCells(loaders: GridLoader[], cellSize: Dimensions, spacer: number): VisibleGridCell[] { + const { width, height } = cellSize; + if (width === 0 || height === 0) { + return []; + } + return loaders + .filter((loader) => loader.sources.length > 0) + .map((loader) => { + const cellBounds = getCellBounds(loader, width, height, spacer); + return { + loader, + cellBounds, + viewportBounds: cellBounds, + }; + }); +} + +function getVisibleGridCells( + loaders: GridLoader[], + viewport: Viewport, + cellSize: Dimensions, + spacer: number, + modelMatrix?: Matrix4, +): VisibleGridCell[] { + const { width, height } = cellSize; + if (width === 0 || height === 0) { + return []; + } + const viewportBounds = getViewportBounds(viewport, modelMatrix); + const visible: VisibleGridCell[] = []; + for (const loader of loaders) { + if (loader.sources.length === 0) { + continue; + } + const cellBounds = getCellBounds(loader, width, height, spacer); + const intersection = intersectBounds(cellBounds, viewportBounds); + if (intersection) { + visible.push({ loader, cellBounds, viewportBounds: intersection }); + } + } + return visible; +} + +function computeWindowForSource(options: { + viewportBounds: CellBounds; + cellBounds: CellBounds; + fullSize: Dimensions; + levelSize: Dimensions; +}): { + window?: { x: [number, number]; y: [number, number] }; + renderBounds: CellBounds; + coversWholeCell: boolean; +} { + const { viewportBounds, cellBounds, fullSize, levelSize } = options; + const { width: levelWidth, height: levelHeight } = levelSize; + if (levelWidth === 0 || levelHeight === 0) { + return { window: undefined, renderBounds: cellBounds, coversWholeCell: true }; + } + + const pixelSizeX = fullSize.width / levelWidth; + const pixelSizeY = fullSize.height / levelHeight; + + const localLeft = clamp(viewportBounds.left - cellBounds.left, 0, fullSize.width); + const localRight = clamp(viewportBounds.right - cellBounds.left, 0, fullSize.width); + const localTop = clamp(viewportBounds.top - cellBounds.top, 0, fullSize.height); + const localBottom = clamp(viewportBounds.bottom - cellBounds.top, 0, fullSize.height); + + const xStart = Math.max(0, Math.floor(localLeft / pixelSizeX)); + const xEnd = Math.min(levelWidth, Math.max(xStart + 1, Math.ceil(localRight / pixelSizeX))); + const yStart = Math.max(0, Math.floor(localTop / pixelSizeY)); + const yEnd = Math.min(levelHeight, Math.max(yStart + 1, Math.ceil(localBottom / pixelSizeY))); + + const coversWholeCell = xStart === 0 && xEnd === levelWidth && yStart === 0 && yEnd === levelHeight; + + const renderBounds: CellBounds = coversWholeCell + ? cellBounds + : { + left: cellBounds.left + xStart * pixelSizeX, + right: cellBounds.left + xEnd * pixelSizeX, + top: cellBounds.top + yStart * pixelSizeY, + bottom: cellBounds.top + yEnd * pixelSizeY, + }; + + const window = coversWholeCell + ? undefined + : { + x: [xStart, xEnd] as [number, number], + y: [yStart, yEnd] as [number, number], + }; + + return { window, renderBounds, coversWholeCell }; +} + +function buildGridContext(props: GridLayerProps, viewport?: Viewport): GridContext | null { + const { loaders, spacer = 0 } = props; + if (loaders.length === 0) { + return null; + } + const baseLoader = loaders.find((loader) => loader.sources.length > 0); + if (!baseLoader) { + return null; + } + const fullSize = getSourceDimensions(baseLoader.sources[0]); + if (fullSize.width === 0 || fullSize.height === 0) { + return null; + } + const visibleCells = viewport + ? getVisibleGridCells(loaders, viewport, fullSize, spacer, props.modelMatrix as Matrix4 | undefined) + : getAllGridCells(loaders, fullSize, spacer); + return { fullSize, spacer, visibleCells }; } -function validateWidthHeight(d: { data: { width: number; height: number } }[]) { - const [first] = d; - // Return early if no grid data. Maybe throw an error? +function getEffectiveConcurrency(concurrency: number | undefined, selectionCount: number) { + if (!concurrency) { + return undefined; + } + if (selectionCount <= 0) { + return concurrency; + } + return Math.max(1, Math.ceil(concurrency / selectionCount)); +} + +async function loadVisibleCell( + cell: VisibleGridCell, + level: number, + selections: number[][], + context: GridContext, +): Promise { + const { loader } = cell; + assert(loader.sources.length > 0, "Grid loader is missing pixel sources"); + const sourceIndex = Math.min(level, loader.sources.length - 1); + const source = loader.sources[sourceIndex]; + const levelSize = getSourceDimensions(source); + const { window, renderBounds, coversWholeCell } = computeWindowForSource({ + viewportBounds: cell.viewportBounds, + cellBounds: cell.cellBounds, + fullSize: context.fullSize, + levelSize, + }); + const tiles = await Promise.all(selections.map((selection) => source.getRaster({ selection, window }))); + const firstTile = tiles[0]; + const width = firstTile?.width ?? 0; + const height = firstTile?.height ?? 0; + return { + ...loader, + bounds: toDeckBounds(renderBounds), + coversWholeCell, + source, + sourceIndex, + data: { + data: tiles.map((tile) => tile.data) as SupportedTypedArray[], + width, + height, + }, + }; +} + +function refreshGridData( + context: GridContext, + level: number, + selections: number[][], + concurrency?: number, +): Promise { + if (context.visibleCells.length === 0) { + return Promise.resolve([]); + } + const effectiveConcurrency = getEffectiveConcurrency(concurrency, selections.length); + return pMap(context.visibleCells, (cell) => loadVisibleCell(cell, level, selections, context), { + concurrency: effectiveConcurrency, + }); +} + +function validateWidthHeight(data: GridDataEntry[]) { + const [first] = data; const { width, height } = first.data; - // Verify that all grid data is same shape (ignoring undefined) - for (const { data } of d) { - if (!data) continue; - assert(data.width === width && data.height === height, "Grid data is not same shape."); + for (const entry of data) { + const current = entry.data; + if (!current) { + continue; + } + assert(current.width === width && current.height === height, "Grid data is not same shape."); } return { width, height }; } -function refreshGridData(props: GridLayerProps) { - const { loaders, selections = [] } = props; - let { concurrency } = props; - if (concurrency && selections.length > 0) { - // There are `loaderSelection.length` requests per loader. This block scales - // the provided concurrency to map to the number of actual requests. - concurrency = Math.ceil(concurrency / selections.length); - } - const mapper = async (d: GridLoader) => { - const promises = selections.map((selection) => d.loader.getRaster({ selection })); - const tiles = await Promise.all(promises); - return { - ...d, - data: { - data: tiles.map((d) => d.data), - width: tiles[0].width, - height: tiles[0].height, - }, - }; +function getSourceDimensions(source: ZarrPixelSource) { + const labels = source.labels as unknown as string[]; + const xIndex = labels.indexOf("x"); + const yIndex = labels.indexOf("y"); + assert(xIndex !== -1 && yIndex !== -1, "Expected pixel source with x/y axes"); + return { + width: source.shape[xIndex], + height: source.shape[yIndex], }; - return pMap(loaders, mapper, { concurrency }); } type SharedLayerState = { - gridData: Awaited>; - width: number; - height: number; + gridData: GridDataEntry[]; + fullWidth: number; + fullHeight: number; + resolutionLevel: number; }; class GridLayer extends CompositeLayer { @@ -83,14 +339,12 @@ class GridLayer extends CompositeLayer { static defaultProps = { // @ts-expect-error - XRLayer props are not typed ...XRLayer.defaultProps, - // Special grid props loaders: { type: "array", value: [], compare: true }, spacer: { type: "number", value: 5, compare: true }, rows: { type: "number", value: 0, compare: true }, columns: { type: "number", value: 0, compare: true }, - concurrency: { type: "number", value: 10, compare: false }, // set concurrency for queue + concurrency: { type: "number", value: 10, compare: false }, text: { type: "boolean", value: false, compare: true }, - // Deck.gl onClick: { type: "function", value: null, compare: true }, onHover: { type: "function", value: null, compare: true }, }; @@ -105,11 +359,30 @@ class GridLayer extends CompositeLayer { } initializeState() { - this.#state = { gridData: [], width: 0, height: 0 }; - refreshGridData(this.props).then((gridData) => { - const { width, height } = validateWidthHeight(gridData); - this.setState({ gridData, width, height }); - }); + const initialLevel = this.#getInitialResolutionLevel(this.props.loaders); + const context = buildGridContext(this.props, this.context.viewport); + this.#state = { + gridData: [], + fullWidth: context?.fullSize.width ?? 0, + fullHeight: context?.fullSize.height ?? 0, + resolutionLevel: initialLevel, + }; + if (context) { + this.#refreshAndSetState(this.props, initialLevel, this.context.viewport, context); + } + } + + // biome-ignore lint/suspicious/noExplicitAny: deck.gl typing does not expose narrowed props + shouldUpdateState({ changeFlags, props, oldProps }: any) { + if (changeFlags.viewportChanged) { + return true; + } + const nextProps = props as GridLayerProps; + const prevProps = oldProps as GridLayerProps; + if (nextProps.selections !== prevProps.selections) { + return true; + } + return Boolean(changeFlags.propsChanged || changeFlags.dataChanged); } updateState({ @@ -121,32 +394,47 @@ class GridLayer extends CompositeLayer { oldProps: GridLayerProps; changeFlags: { propsChanged: string | boolean | null; + viewportChanged?: boolean; }; }) { const { propsChanged } = changeFlags; const loaderChanged = typeof propsChanged === "string" && propsChanged.includes("props.loaders"); - const loaderSelectionChanged = props.selections !== oldProps.selections; - if (loaderChanged || loaderSelectionChanged) { - // Only fetch new data to render if loader has changed - refreshGridData(this.props).then((gridData) => { - this.setState({ gridData }); + const selectionChanged = props.selections !== oldProps.selections; + const context = buildGridContext(props, this.context.viewport); + + if (loaderChanged) { + this.setState({ + fullWidth: context?.fullSize.width ?? 0, + fullHeight: context?.fullSize.height ?? 0, }); } + + if (loaderChanged || selectionChanged) { + this.#refreshAndSetState(props, this.#state.resolutionLevel, this.context.viewport, context ?? undefined); + return; + } + + if (changeFlags.viewportChanged) { + const level = this.#pickResolutionLevel(props.loaders, this.context.viewport); + if (level !== this.#state.resolutionLevel) { + this.setState({ resolutionLevel: level }); + } + this.#refreshAndSetState(props, level, this.context.viewport, context ?? undefined); + } } getPickingInfo({ info }: { info: PickingInfo }) { - // provide Grid row and column info for mouse events (hover & click) if (!info.coordinate) { return info; } const spacer = this.props.spacer || 0; - const { width, height } = this.#state; - if (width === 0 || height === 0) { + const { fullWidth, fullHeight } = this.#state; + if (fullWidth === 0 || fullHeight === 0) { return info; } const [x, y] = info.coordinate; - const row = Math.floor(y / (height + spacer)); - const column = Math.floor(x / (width + spacer)); + const row = Math.floor(y / (fullHeight + spacer)); + const column = Math.floor(x / (fullWidth + spacer)); const { rows, columns, rowLabels, columnLabels } = this.props; if (row < 0 || column < 0 || row >= rows || column >= columns) { return info; @@ -162,19 +450,18 @@ class GridLayer extends CompositeLayer { } renderLayers() { - const { gridData, width, height } = this.#state; - if (width === 0 || height === 0) return null; // early return if no data + const { gridData, fullWidth, fullHeight } = this.#state; + if (fullWidth === 0 || fullHeight === 0) { + return null; + } const { rows, columns, spacer = 0, id = "" } = this.props; - type Data = { row: number; col: number; loader: Pick; data: Array }; - const layers = gridData.map((d) => { - const y = d.row * (height + spacer); - const x = d.col * (width + spacer); + const layers = gridData.map((entry) => { const layerProps = { - channelData: d.data, // coerce to null if no data - bounds: scaleBounds(width, height, [x, y]), - id: `${id}-GridLayer-${d.row}-${d.col}`, - dtype: d.loader.dtype || "Uint16", // fallback if missing, + channelData: entry.data, + bounds: entry.bounds, + id: `${id}-GridLayer-${entry.row}-${entry.col}`, + dtype: entry.source?.dtype || entry.sources[0]?.dtype || "Uint16", pickable: false, extensions: [new ColorPaletteExtension()], }; @@ -184,8 +471,8 @@ class GridLayer extends CompositeLayer { if (this.props.pickable) { type Data = { polygon: Polygon }; - const bottom = rows * (height + spacer); - const right = columns * (width + spacer); + const bottom = rows * (fullHeight + spacer); + const right = columns * (fullWidth + spacer); const polygon = [ [0, 0], [right, 0], @@ -194,10 +481,10 @@ class GridLayer extends CompositeLayer { ] satisfies Polygon; const layerProps = { data: [{ polygon }], - getPolygon: (d) => d.polygon, - getFillColor: [0, 0, 0, 0], // transparent + getPolygon: (d: Data) => d.polygon, + getFillColor: [0, 0, 0, 0], getLineColor: [0, 0, 0, 0], - pickable: true, // enable picking + pickable: true, id: `${id}-GridLayer-picking`, } satisfies SolidPolygonLayerProps; const layer = new SolidPolygonLayer>({ ...this.props, ...layerProps }); @@ -209,7 +496,7 @@ class GridLayer extends CompositeLayer { const layer = new TextLayer>({ id: `${id}-GridLayer-text`, data: gridData, - getPosition: (d) => [d.col * (width + spacer), d.row * (height + spacer)], + getPosition: (d) => [d.col * (fullWidth + spacer), d.row * (fullHeight + spacer)], getText: (d) => d.name, getColor: [255, 255, 255, 255], getSize: 16, @@ -222,6 +509,116 @@ class GridLayer extends CompositeLayer { return layers; } + + #refreshAndSetState(props: GridLayerProps, level: number, viewport?: Viewport, context?: GridContext | null) { + const resolvedContext = context ?? buildGridContext(props, viewport); + if (!resolvedContext) { + this.setState({ gridData: [] }); + return; + } + const selections = props.selections ?? []; + refreshGridData(resolvedContext, level, selections, props.concurrency) + .then((gridData) => { + if (this.#state.resolutionLevel !== level) { + return; + } + if (gridData.length > 0) { + const shouldValidate = gridData.every((entry) => entry.coversWholeCell); + if (shouldValidate) { + validateWidthHeight(gridData); + } + } + this.setState({ + gridData, + fullWidth: resolvedContext.fullSize.width, + fullHeight: resolvedContext.fullSize.height, + }); + }) + .catch(() => { + if (this.#state.resolutionLevel !== level) { + return; + } + this.setState({ gridData: [] }); + }); + } + + #getMaxValidLevel(loaders: GridLoader[]) { + if (loaders.length === 0) { + return 0; + } + const minSources = loaders.reduce((min, loader) => Math.min(min, loader.sources.length), Number.POSITIVE_INFINITY); + if (!Number.isFinite(minSources)) { + return 0; + } + return Math.max(0, minSources - 1); + } + + #getInitialResolutionLevel(loaders: GridLoader[]) { + return this.#getMaxValidLevel(loaders); + } + + #getLevelDimensions(loaders: GridLoader[]) { + const first = loaders.find((loader) => loader.sources.length > 0); + if (!first) { + return [] as Array<{ width: number; height: number }>; + } + return first.sources.map((source) => getSourceDimensions(source)); + } + + #pickResolutionLevel(loaders: GridLoader[], viewport?: Viewport) { + const maxLevel = this.#getMaxValidLevel(loaders); + if (maxLevel <= 0) { + return 0; + } + const dimensions = this.#getLevelDimensions(loaders).slice(0, maxLevel + 1); + if (dimensions.length <= 1) { + return 0; + } + const screenSize = this.#getCellScreenSize(viewport); + if (!screenSize) { + return this.#state.resolutionLevel; + } + + for (let level = 0; level < dimensions.length; level += 1) { + const { width, height } = dimensions[level]; + if (width === 0 || height === 0) { + continue; + } + const ratio = Math.min(screenSize.width / width, screenSize.height / height); + if (ratio >= MIN_PIXELS_PER_DATA_PIXEL) { + return level; + } + } + return dimensions.length - 1; + } + + #getCellScreenSize(viewport?: Viewport) { + if (!viewport) { + return null; + } + const { fullWidth, fullHeight } = this.#state; + if (fullWidth === 0 || fullHeight === 0) { + return null; + } + const topLeft = this.#applyModelMatrix([0, 0, 0]); + const topRight = this.#applyModelMatrix([fullWidth, 0, 0]); + const bottomLeft = this.#applyModelMatrix([0, fullHeight, 0]); + const projectedTopLeft = viewport.project(topLeft); + const projectedTopRight = viewport.project(topRight); + const projectedBottomLeft = viewport.project(bottomLeft); + const width = Math.abs(projectedTopRight[0] - projectedTopLeft[0]); + const height = Math.abs(projectedBottomLeft[1] - projectedTopLeft[1]); + return { width, height }; + } + + #applyModelMatrix(point: [number, number, number]) { + const matrix = this.props.modelMatrix as Matrix4 | undefined; + if (!matrix) { + return point; + } + const transformed = matrix.transformAsPoint(point); + return [transformed[0], transformed[1], transformed[2] ?? 0]; + } } export { GridLayer }; diff --git a/src/ome.ts b/src/ome.ts index 3912ef01..d12e9eca 100644 --- a/src/ome.ts +++ b/src/ome.ts @@ -70,7 +70,7 @@ export async function loadWell( name: String(offset), row, col, - loader: new ZarrPixelSource(data[offset], { labels: axis_labels, tileSize }), + sources: [new ZarrPixelSource(data[offset], { labels: axis_labels, tileSize })], }; }); }); @@ -79,16 +79,16 @@ export async function loadWell( if (utils.isOmeMultiscales(imgAttrs)) { meta = parseOmeroMeta(imgAttrs.omero, axes); } else { - const lowres = loaders.at(-1); + const lowres = loaders.at(-1)?.sources.at(-1); utils.assert(lowres, "Expected at least one resolution, found none."); - meta = await defaultMeta(lowres.loader, axis_labels); + meta = await defaultMeta(lowres, axis_labels); } const sourceData: SourceData = { loaders, ...meta, axis_labels, - loader: [loaders[0].loader], + loader: loaders[0].sources, model_matrix: utils.parseMatrix(config.model_matrix), defaults: { selection: meta.defaultSelection, @@ -159,7 +159,7 @@ export async function loadPlate( // Lowest resolution is the 'path' of the last 'dataset' from the first multiscales const { datasets } = imgAttrs.multiscales[0]; - const resolution = datasets[datasets.length - 1].path; + const datasetPaths = datasets.map((dataset) => dataset.path); async function getImgPath(wellPath: string) { const wellAttrs = await utils.getAttrsOnly<{ well: Ome.Well }>(grp, { @@ -172,40 +172,44 @@ export async function loadPlate( const wellImagePaths = await Promise.all(wellPaths.map(getImgPath)); // Create loader for every Well. Some loaders may be undefined if Wells are missing. - const mapper = async ([key, path]: string[]) => { - // @ts-expect-error - we don't need the meta for these arrays - let arr: zarr.Array = await zarr.open(grp.resolve(path), { - kind: "array", - attrs: false, - }); - return [key, arr] as const; - }; - - const promises = await pMap( - wellImagePaths.map((p) => [p, utils.join(p, resolution)]), - mapper, - { concurrency: 10 }, + const data = await pMap( + wellImagePaths, + async (imagePath) => { + const arrays = await Promise.all( + datasetPaths.map(async (datasetPath) => { + // @ts-expect-error - attrs not needed for pixel data arrays + const arr: zarr.Array = await zarr.open( + grp.resolve(utils.join(imagePath, datasetPath)), + { kind: "array", attrs: false }, + ); + return arr; + }), + ); + return [imagePath, arrays] as const; + }, + { concurrency: 5 }, ); - const data = await Promise.all(promises); const axes = utils.getNgffAxes(imgAttrs.multiscales); const axis_labels = utils.getNgffAxisLabels(axes); - const tileSize = utils.guessTileSize(data[0][1]); - const loaders = data.map((d) => { - const [row, col] = d[0].split("/"); + const loaders = data.map(([path, arrays]) => { + const [row, col] = path.split("/"); + const sources = arrays.map( + (arr) => new ZarrPixelSource(arr, { labels: axis_labels, tileSize: utils.guessTileSize(arr) }), + ); return { name: `${row}${col}`, row: rows.indexOf(row), col: columns.indexOf(col), - loader: new ZarrPixelSource(d[1], { labels: axis_labels, tileSize }), + sources, }; }); let meta: Meta; if ("omero" in imgAttrs) { meta = parseOmeroMeta(imgAttrs.omero, axes); } else { - const lowres = loaders.at(-1); + const lowres = loaders.at(-1)?.sources.at(-1); utils.assert(lowres, "Expected at least one resolution, found none."); - meta = await defaultMeta(lowres.loader, axis_labels); + meta = await defaultMeta(lowres, axis_labels); } // Load Image to use for channel names, rendering settings, sizeZ, sizeT etc. @@ -213,7 +217,7 @@ export async function loadPlate( loaders, ...meta, axis_labels, - loader: [loaders[0].loader], + loader: loaders[0].sources, model_matrix: utils.parseMatrix(config.model_matrix), defaults: { selection: meta.defaultSelection, diff --git a/src/utils.ts b/src/utils.ts index 04e0562f..adf1dcb3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -532,7 +532,7 @@ export function isGridLayerProps( export function resolveLoaderFromLayerProps( layerProps: GridLayerProps | ImageLayerProps | MultiscaleImageLayerProps | LabelLayerProps, ) { - return isGridLayerProps(layerProps) ? layerProps.loaders[0].loader : layerProps.loader; + return isGridLayerProps(layerProps) ? layerProps.loaders[0].sources : layerProps.loader; } /**