From 68210f5f9ce83f351eb7cd4feca56eddc4fb5852 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 14 Apr 2026 12:19:56 +0200 Subject: [PATCH 1/4] Fix docs and vis dev setup for local builds --- docs/package.json | 2 +- packages/avivatorish/vite.config.ts | 2 +- packages/vis/package.json | 5 ++- packages/vis/scripts/dev.mjs | 61 +++++++++++++++++++++++++++++ packages/vis/vite.config.demo.ts | 5 ++- packages/vis/vite.config.ts | 2 +- vite.config.base.ts | 8 +++- 7 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 packages/vis/scripts/dev.mjs diff --git a/docs/package.json b/docs/package.json index d6980a5f..ceb4fa04 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "dev": "docusaurus start --port 3001", + "dev": "docusaurus start --host 127.0.0.1 --port 3001", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", diff --git a/packages/avivatorish/vite.config.ts b/packages/avivatorish/vite.config.ts index 93d68324..81f33e18 100644 --- a/packages/avivatorish/vite.config.ts +++ b/packages/avivatorish/vite.config.ts @@ -7,7 +7,7 @@ const pkgRoot = fileURLToPath(new URL('.', import.meta.url)); const baseConfig = defineViteConfig({ pkgRoot, libName: 'SpatialDataAvivatorish', - external: ['@hms-dbmi/viv', '@math.gl/core', 'geotiff', 'zustand'], + external: ['@hms-dbmi/viv', '@math.gl/core', 'geotiff', /^zustand(?:\/.*)?$/], }); export default mergeConfig(baseConfig, { diff --git a/packages/vis/package.json b/packages/vis/package.json index cbaade88..7a6ec704 100644 --- a/packages/vis/package.json +++ b/packages/vis/package.json @@ -16,7 +16,8 @@ ], "scripts": { "build": "vite build && tsc --noEmit", - "dev": "vite --config vite.config.demo.ts", + "dev": "node scripts/dev.mjs", + "dev:demo": "vite --config vite.config.demo.ts --host 127.0.0.1 --port 5173 --strictPort", "watch": "vite build --watch", "test": "vitest run", "test:watch": "vitest", @@ -61,4 +62,4 @@ "url": "https://github.com/Taylor-CCB-Group/SpatialData.js.git", "directory": "packages/vis" } -} \ No newline at end of file +} diff --git a/packages/vis/scripts/dev.mjs b/packages/vis/scripts/dev.mjs new file mode 100644 index 00000000..3fac2209 --- /dev/null +++ b/packages/vis/scripts/dev.mjs @@ -0,0 +1,61 @@ +import { spawn } from 'node:child_process'; + +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const children = []; +let shuttingDown = false; + +function start(name, args) { + const child = spawn(pnpmCommand, ['exec', ...args], { + stdio: 'inherit', + env: process.env, + }); + + child.on('error', (error) => { + if (shuttingDown) { + return; + } + + console.error(`[${name}] failed to start:`, error); + shutdown(1); + }); + + child.on('exit', (code, signal) => { + if (shuttingDown) { + return; + } + + if (code === 0 && !signal) { + shutdown(0); + return; + } + + console.error(`[${name}] exited with ${signal ?? `code ${code ?? 1}`}`); + shutdown(code ?? 1); + }); + + children.push(child); +} + +function shutdown(code) { + if (shuttingDown) { + return; + } + + shuttingDown = true; + + for (const child of children) { + child.kill(); + } + + const timer = setTimeout(() => { + process.exit(code); + }, 500); + timer.unref(); +} + +process.on('SIGINT', () => shutdown(130)); +process.on('SIGTERM', () => shutdown(143)); + +console.log('Starting vis build watch and demo server...'); +start('watch', ['vite', 'build', '--watch']); +start('demo', ['vite', '--config', 'vite.config.demo.ts', '--host', '127.0.0.1', '--port', '5173', '--strictPort']); diff --git a/packages/vis/vite.config.demo.ts b/packages/vis/vite.config.demo.ts index 8e520b25..ad6d1ffb 100644 --- a/packages/vis/vite.config.demo.ts +++ b/packages/vis/vite.config.demo.ts @@ -22,6 +22,9 @@ export default defineConfig({ dedupe: ['react', 'react-dom'], }, server: { - open: true, + host: '127.0.0.1', + port: 5173, + strictPort: true, + open: false, }, }); diff --git a/packages/vis/vite.config.ts b/packages/vis/vite.config.ts index 09324aa7..b6a4c3ec 100644 --- a/packages/vis/vite.config.ts +++ b/packages/vis/vite.config.ts @@ -9,7 +9,7 @@ const coreSrcIndex = path.resolve(pkgRoot, '../core/src/index.ts'); const baseConfig = defineViteConfig({ pkgRoot, libName: 'SpatialDataVis', - external: ['@spatialdata/core', '@spatialdata/react'], + external: [/^@spatialdata\/[^/]+$/, /^zustand(?:\/.*)?$/], }); const testResolve = diff --git a/vite.config.base.ts b/vite.config.base.ts index 608e22e9..9c8a68d0 100644 --- a/vite.config.base.ts +++ b/vite.config.base.ts @@ -65,7 +65,13 @@ export function defineViteConfig(options: DefineConfigOptions) { formats: ['es'], }, rollupOptions: { - external: ['react', 'react-dom', ...external], + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + ...external, + ], }, }, }); From 71a9413687b4b58b2c4fae3aab67a0807ff0fb90 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 14 Apr 2026 12:32:03 +0200 Subject: [PATCH 2/4] Disable Docusaurus dev split chunks to avoid chunk load errors --- docs/docusaurus.config.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 973eecb1..4c97f1c6 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -1,5 +1,5 @@ import type * as Preset from '@docusaurus/preset-classic'; -import type { Config } from '@docusaurus/types'; +import type { Config, Plugin } from '@docusaurus/types'; import { themes as prismThemes } from 'prism-react-renderer'; // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) @@ -20,6 +20,8 @@ const config: Config = { // For GitHub pages deployment, it is often '//' baseUrl: '/SpatialData.js/', + plugins: [disableDevSplitChunks], + // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. organizationName: 'Taylor-CCB-Group', // Usually your GitHub org/user name. @@ -130,3 +132,20 @@ const config: Config = { }; export default config; + +function disableDevSplitChunks(): Plugin { + return { + name: 'disable-dev-split-chunks', + configureWebpack(_config, isServer) { + if (process.env.NODE_ENV === 'production' || isServer) { + return {}; + } + + return { + optimization: { + splitChunks: false, + }, + }; + }, + }; +} From a6f28597827a2e5912444696d2a96e5d9bcc7bab Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 14 Apr 2026 15:45:21 +0200 Subject: [PATCH 3/4] Fix Viv loader resolution when switching images --- packages/avivatorish/src/hooks.ts | 11 ++++-- packages/avivatorish/src/utils.ts | 53 ++++++++++++++++++++++++++-- packages/vis/src/ImageView/index.tsx | 8 ++--- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/avivatorish/src/hooks.ts b/packages/avivatorish/src/hooks.ts index e6df7fd6..0a629cdb 100644 --- a/packages/avivatorish/src/hooks.ts +++ b/packages/avivatorish/src/hooks.ts @@ -20,6 +20,7 @@ import { getMultiSelectionStats, getBoundingCube, isInterleaved, + resolveRasterSource, } from "./utils"; import { COLOR_PALLETE, FILL_PIXEL_VALUE } from "./constants"; import { createLoader } from "./utils"; @@ -53,7 +54,7 @@ export const useImage = ( async function changeLoader() { // Placeholder viewerStore.setState({ isChannelLoading: [true] }); - viewerStore.setState({ isViewerLoading: true }); + viewerStore.setState({ isViewerLoading: true, metadata: null }); if (use3d) toggleUse3d(); if (!source) throw "this should never happen - this is a type-guard"; const { urlOrFile } = source; @@ -109,11 +110,15 @@ export const useImage = ( useEffect(() => { if (!metadata) return; const changeSettings = async () => { + const rasterSource = resolveRasterSource(loader); + if (!rasterSource) { + return; + } // Placeholder viewerStore.setState({ isChannelLoading: [true] }); viewerStore.setState({ isViewerLoading: true }); if (use3d) toggleUse3d(); - const newSelections = buildDefaultSelection(loader[0]); + const newSelections = buildDefaultSelection(rasterSource); const { Channels } = metadata.Pixels; const channelOptions = Channels.map( (c, i) => c.Name ?? `Channel ${i}`, @@ -126,7 +131,7 @@ export const useImage = ( let newColors: Colors = []; const isRgb = guessRgb(metadata); if (isRgb) { - if (isInterleaved(loader[0].shape)) { + if (isInterleaved(rasterSource.shape)) { // These don't matter because the data is interleaved. newContrastLimits = [[0, 255]]; newDomains = [[0, 255]]; diff --git a/packages/avivatorish/src/utils.ts b/packages/avivatorish/src/utils.ts index 0708758a..04ddec35 100644 --- a/packages/avivatorish/src/utils.ts +++ b/packages/avivatorish/src/utils.ts @@ -405,8 +405,55 @@ function narrowLimits(limits: number[]): limits is [number, number] { function narrowStats(stats: { domain: number[]; contrastLimits: number[] }): stats is { domain: [number, number]; contrastLimits: [number, number] } { return narrowLimits(stats.domain) && narrowLimits(stats.contrastLimits); } +type RasterSourceLike = { + getRaster: (args: { selection: VivSelection }) => Promise<{ data: any }>; + labels: string[]; + shape: number[]; +}; +export function isRasterSourceLike(value: unknown): value is RasterSourceLike { + if (!value || typeof value !== "object") return false; + return "getRaster" in value && typeof value.getRaster === "function" && "labels" in value && "shape" in value; +} +export function resolveRasterSource(loader: LOADER): RasterSourceLike | undefined { + const seen = new Set(); + function visit(current: unknown): RasterSourceLike | undefined { + if (!current || typeof current !== "object" || seen.has(current)) { + return undefined; + } + seen.add(current); + if (isRasterSourceLike(current)) { + return current; + } + if (Array.isArray(current)) { + for (let i = current.length - 1; i >= 0; i -= 1) { + const found = visit(current[i]); + if (found) return found; + } + return undefined; + } + if ("data" in current) { + const found = visit(current.data); + if (found) return found; + } + if ("source" in current) { + const found = visit(current.source); + if (found) return found; + } + if ("loader" in current) { + const found = visit(current.loader); + if (found) return found; + } + return undefined; + } + return visit(loader); +} +export function getRasterSource(loader: LOADER): RasterSourceLike { + const source = resolveRasterSource(loader); + if (source) return source; + throw new Error("Expected Viv loader to resolve to a raster source with getRaster()."); +} export async function getSingleSelectionStats2D({ loader, selection }: { loader: LOADER, selection: VivSelection}) { - const data = Array.isArray(loader) ? loader[loader.length - 1] : loader; + const data = getRasterSource(loader); const raster = await data.getRaster({ selection }); const selectionStats = getChannelStats(raster.data); if (!narrowStats(selectionStats)) { @@ -422,7 +469,7 @@ export async function getSingleSelectionStats2D({ loader, selection }: { loader: } export async function getSingleSelectionStats3D({ loader, selection }: { loader: LOADER, selection: VivSelection }) { - const lowResSource = loader[loader.length - 1]; + const lowResSource = getRasterSource(loader); const { shape, labels } = lowResSource; const sizeZ = shape[labels.indexOf("z")]; const raster0 = await lowResSource.getRaster({ @@ -531,7 +578,7 @@ export function getPhysicalSizeScalingMatrix(loader: PixelSource | any) { } export function getBoundingCube(loader: PixelSource) { - const source = Array.isArray(loader) ? loader[0] : loader; + const source = getRasterSource(loader); const { shape, labels } = source; const physicalSizeScalingMatrix = getPhysicalSizeScalingMatrix(source); const xSlice: [number, number] = [ diff --git a/packages/vis/src/ImageView/index.tsx b/packages/vis/src/ImageView/index.tsx index cbf865db..9f8b5300 100644 --- a/packages/vis/src/ImageView/index.tsx +++ b/packages/vis/src/ImageView/index.tsx @@ -10,6 +10,7 @@ import { VivProvider, useChannelsStoreApi, DEFAULT_CHANNEL_STATE, + resolveRasterSource, } from '@spatialdata/avivatorish'; import { DetailView, VivViewer, getDefaultInitialViewState } from '@hms-dbmi/viv'; import { useImage } from '@spatialdata/avivatorish'; @@ -19,9 +20,8 @@ import { useImage } from '@spatialdata/avivatorish'; * the conditions under which this function returns false are conditions where internally it would have pixelWidth undefined, etc. */ function _isValidImage(image: ReturnType) { - if (!image) return false; - const source = Array.isArray(image) ? image[0] : image; - return source.shape.length > 0; + const source = resolveRasterSource(image); + return !!source && source.shape.length > 0; } function VivImage({ url, width, height }: { url?: string | URL; width: number; height: number }) { @@ -66,7 +66,7 @@ function VivImage({ url, width, height }: { url?: string | URL; width: number; h useEffect(() => { if (!url) return; const source = { urlOrFile: url.toString(), description: 'image' }; - viewerStore.setState({ source, viewState: null }); + viewerStore.setState({ source, viewState: null, metadata: null }); channelsStore.setState({ loader: DEFAULT_CHANNEL_STATE.loader }); }, [url, viewerStore, channelsStore]); From fc3746a1ac0b0601cdfea1941f24805a23d179b9 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 14 Apr 2026 17:12:35 +0200 Subject: [PATCH 4/4] Fix bounding cube physical size scaling --- packages/avivatorish/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/avivatorish/src/utils.ts b/packages/avivatorish/src/utils.ts index 04ddec35..669dcc41 100644 --- a/packages/avivatorish/src/utils.ts +++ b/packages/avivatorish/src/utils.ts @@ -580,7 +580,7 @@ export function getPhysicalSizeScalingMatrix(loader: PixelSource | any) { export function getBoundingCube(loader: PixelSource) { const source = getRasterSource(loader); const { shape, labels } = source; - const physicalSizeScalingMatrix = getPhysicalSizeScalingMatrix(source); + const physicalSizeScalingMatrix = getPhysicalSizeScalingMatrix(loader); const xSlice: [number, number] = [ 0, physicalSizeScalingMatrix[0] * shape[labels.indexOf("x")],