Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CNAME
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
demo.minerva.im
2 changes: 2 additions & 0 deletions src/components/shared/viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/imaging/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand All @@ -136,7 +137,7 @@ const toLoaderFromUrl = async (
pool?: PoolClass,
): Promise<Loader> => {
if (pool) {
return await loadOmeTiff(url, { pool });
return await loadOmeTiff(url, { pool: pool as never });
}
return await loadOmeTiff(url);
};
Expand Down
130 changes: 105 additions & 25 deletions src/lib/imaging/workers/Pool.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>;
destroy(): void;
destroy(): Promise<void>;
}

// 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<WorkerWrapper[]> | 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();
}
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/lib/persistence/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import { useDocumentStore } from "@/lib/stores/documentStore";
import {
emptyDocumentData,
getStoryRecord,
listStorySummaries,
saveStoryDocument,
setActiveStoryId,
} from "./storyPersistence";

let inflight: Promise<void> | 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<void> {
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<void> {
await setActiveStoryId(null);
useDocumentStore.getState().clearForLibraryView();
Expand All @@ -17,12 +36,14 @@ async function runAuthorBootstrap(preferredStoryId: string): Promise<void> {
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;
}

Expand All @@ -36,12 +57,14 @@ async function runBootstrap(preferredStoryId: string | null): Promise<void> {
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).
Expand Down
1 change: 1 addition & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
dedupe: [
"geotiff",
"@luma.gl/core",
"@luma.gl/constants",
"@luma.gl/engine",
Expand Down
Loading