diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..8f23675 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +demo.minerva.im \ No newline at end of file diff --git a/src/components/shared/viewer/ImageViewer.tsx b/src/components/shared/viewer/ImageViewer.tsx index 4b6028d..4137d97 100644 --- a/src/components/shared/viewer/ImageViewer.tsx +++ b/src/components/shared/viewer/ImageViewer.tsx @@ -829,6 +829,8 @@ export const ImageViewer = (props: ImageViewerProps) => { touchZoom: true, touchRotate: false, keyboard: false, + /** Pan / pinch coast after release; `true` → Deck default (~300ms). Use a number for custom ms. */ + inertia: true, }), [activeTool, isDragging, hoveredShapeId], ); diff --git a/src/lib/imaging/filesystem.ts b/src/lib/imaging/filesystem.ts index 610d0f0..155e781 100644 --- a/src/lib/imaging/filesystem.ts +++ b/src/lib/imaging/filesystem.ts @@ -126,7 +126,8 @@ const toFile: ToFiles = async () => { const toLoader: ToLoader = async ({ handle, pool = null }) => { const in_file = await handle.getFile(); if (pool) { - return await loadOmeTiff(in_file, { pool }); + // @vivjs/loaders types geotiff@2.1.3 Pool; app uses geotiff@2.1.4-beta (different .d.ts). + return await loadOmeTiff(in_file, { pool: pool as never }); } return await loadOmeTiff(in_file); }; @@ -136,7 +137,7 @@ const toLoaderFromUrl = async ( pool?: PoolClass, ): Promise => { if (pool) { - return await loadOmeTiff(url, { pool }); + return await loadOmeTiff(url, { pool: pool as never }); } return await loadOmeTiff(url); }; diff --git a/src/lib/imaging/workers/Pool.ts b/src/lib/imaging/workers/Pool.ts index 6a6bd7b..8d68a2f 100644 --- a/src/lib/imaging/workers/Pool.ts +++ b/src/lib/imaging/workers/Pool.ts @@ -1,48 +1,128 @@ -import { Pool as GeotiffPool } from "geotiff"; +import { getDecoder } from "geotiff"; import DecoderWorker from "./decoder.worker.ts?worker"; export declare class PoolClass { - /** - * @constructor - * @param {Number} - * @param {function(): Worker} - */ constructor( size?: number | undefined, createWorker?: (() => Worker) | undefined, ); - workers: null; - _awaitingDecoder: null; - size: number; - messageId: number; decode(fileDirectory: unknown, buffer: ArrayBuffer): Promise; - destroy(): void; + destroy(): Promise; } -// adapted from https://github.com/hms-dbmi/viv/blob/08a74203b99f54bc62307c741944ed61e33e810c/packages/loaders/src/tiff/lib/Pool.ts#L4 +/** + * Same job protocol as geotiff.js Pool (`submitJob` / `jobId`); inlined so we do not + * subclass geotiff's Pool (avoids preferWorker skipping workers for raw/uncompressed). + * + * @see https://github.com/geotiffjs/geotiff.js/blob/master/src/pool.js + */ +class WorkerWrapper { + worker: Worker; + private jobIdCounter = 0; + private jobs = new Map< + number, + { + resolve: (v: { decoded: ArrayBuffer }) => void; + reject: (e: unknown) => void; + } + >(); -const defaultPoolSize = globalThis?.navigator?.hardwareConcurrency ?? 1; + constructor(worker: Worker) { + this.worker = worker; + this.worker.addEventListener("message", (e) => this.onWorkerMessage(e)); + } + + getJobCount() { + return this.jobs.size; + } + + private onWorkerMessage(e: MessageEvent) { + const { jobId, error, ...result } = e.data as { + jobId: number; + error?: string; + decoded?: ArrayBuffer; + }; + const job = this.jobs.get(jobId); + this.jobs.delete(jobId); + if (!job) return; + + if (error) job.reject(new Error(error)); + else job.resolve(result as { decoded: ArrayBuffer }); + } + + submitJob(message: object, transferables?: Transferable[]) { + const jobId = this.jobIdCounter++; + const promise = new Promise<{ decoded: ArrayBuffer }>((resolve, reject) => { + this.jobs.set(jobId, { resolve, reject }); + }); + this.worker.postMessage({ ...message, jobId }, transferables); + return promise; + } + + private rejectAllPending(reason: Error) { + for (const job of this.jobs.values()) { + job.reject(reason); + } + this.jobs.clear(); + } -function createWorker() { - return new DecoderWorker(); + terminate() { + this.rejectAllPending(new Error("Decoder pool destroyed")); + this.worker.terminate(); + } } -class Pool extends GeotiffPool { - workers: null; - _awaitingDecoder: null; - size: 1; - messageId: 0; +// https://developer.mozilla.org/en-US/docs/Web/API/NavigatorConcurrentHardware/hardwareConcurrency +const defaultPoolSize = globalThis?.navigator?.hardwareConcurrency ?? 4; - constructor(numWorkers = defaultPoolSize) { - super(numWorkers, createWorker); +/** + * Decoder pool for geotiff.js: same surface as `geotiff`’s default Pool (`decode` / + * `destroy`) but always sends work to {@link DecoderWorker} when `size > 0`. + */ +class Pool { + private workerWrappers: Promise | null = null; + + constructor( + size = defaultPoolSize, + createWorker: () => Worker = () => new DecoderWorker(), + ) { + if (size) { + this.workerWrappers = (async () => { + const wrappers: WorkerWrapper[] = []; + for (let i = 0; i < size; i++) { + wrappers.push(new WorkerWrapper(createWorker())); + } + return wrappers; + })(); + } } - decode(fileDirectory, buffer) { - return super.decode(fileDirectory, buffer); + async decode(fileDirectory: unknown, buffer: ArrayBuffer) { + // Snapshot promise: destroy() may set workerWrappers null before await runs. + const workerWrappersPromise = this.workerWrappers; + if (workerWrappersPromise) { + const wrappers = await workerWrappersPromise; + const workerWrapper = wrappers.reduce((a, b) => + a.getJobCount() < b.getJobCount() ? a : b, + ); + const { decoded } = await workerWrapper.submitJob( + { fileDirectory, buffer }, + [buffer], + ); + return decoded; + } + + const decoder = await getDecoder(fileDirectory as { Compression: number }); + return await decoder.decode(fileDirectory, buffer); } async destroy() { - super.destroy(); + if (!this.workerWrappers) return; + const wrappers = await this.workerWrappers; + this.workerWrappers = null; + for (const w of wrappers) { + w.terminate(); + } } } diff --git a/src/lib/persistence/bootstrap.ts b/src/lib/persistence/bootstrap.ts index 4c25798..7947a90 100644 --- a/src/lib/persistence/bootstrap.ts +++ b/src/lib/persistence/bootstrap.ts @@ -1,12 +1,31 @@ import { useDocumentStore } from "@/lib/stores/documentStore"; import { + emptyDocumentData, getStoryRecord, listStorySummaries, + saveStoryDocument, setActiveStoryId, } from "./storyPersistence"; let inflight: Promise | null = null; +/** Stable id so `pnpm run demo` always has one spine on the library shelf. */ +const DEMO_SHELF_STORY_ID = "018fd3a0-0000-7000-8000-00000000de11"; + +/** Mirrors `exhibit_config.Name` in `index.tsx` for the CRC demo build. */ +const DEMO_SHELF_TITLE = + "Multiplexed 3D atlas of state transitions and immune interactions in colorectal cancer"; + +async function ensureDemoShelfStory(): Promise { + if (import.meta.env.MODE !== "demo") return; + const existing = await getStoryRecord(DEMO_SHELF_STORY_ID); + if (existing) return; + await saveStoryDocument(DEMO_SHELF_STORY_ID, { + ...emptyDocumentData(), + metadata: { title: DEMO_SHELF_TITLE }, + }); +} + async function runLibraryBootstrap(): Promise { await setActiveStoryId(null); useDocumentStore.getState().clearForLibraryView(); @@ -17,12 +36,14 @@ async function runAuthorBootstrap(preferredStoryId: string): Promise { const ok = summaries.some((s) => s.id === preferredStoryId); if (!ok) { await runLibraryBootstrap(); + await ensureDemoShelfStory(); return; } const rec = await getStoryRecord(preferredStoryId); if (!rec) { await runLibraryBootstrap(); + await ensureDemoShelfStory(); return; } @@ -36,12 +57,14 @@ async function runBootstrap(preferredStoryId: string | null): Promise { return; } await runLibraryBootstrap(); + await ensureDemoShelfStory(); } /** * Load persisted state for {@link useDocumentStore}. * - No `storyid` in the URL → Minerva Library: clear active story and document slices. * - With `storyid` → hydrate that story from Dexie (or fall back to Library if missing). + * - In `vite --mode demo`, ensures one shelf row exists (Dexie) if the DB had none for that id. * * Call once before the main UI mounts. Concurrent callers share one run * (avoids duplicate work under React Strict Mode). diff --git a/vite.config.js b/vite.config.js index 60c956d..08ce8d1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -57,6 +57,7 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, dedupe: [ + "geotiff", "@luma.gl/core", "@luma.gl/constants", "@luma.gl/engine",