diff --git a/PanTS-Demo/src/components/Preview.tsx b/PanTS-Demo/src/components/Preview.tsx index 1f554ba..0a298c8 100644 --- a/PanTS-Demo/src/components/Preview.tsx +++ b/PanTS-Demo/src/components/Preview.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { API_BASE } from "../helpers/constants"; import { prefetchViewer } from "../helpers/prefetchViewer"; +import { prefetchVolume } from "../helpers/prefetchVolume"; import type { PreviewType } from "../types"; type Props = { @@ -25,6 +26,9 @@ export default function Preview({ const [imgLoaded, setImgLoaded] = useState(false); const [imgError, setImgError] = useState(false); const [hovered, setHovered] = useState(false); + // Warm the low-res CT only after a short hover dwell, so skimming across the grid + // doesn't fire a fetch per card. Cleared on mouse-leave. + const prefetchTimer = useRef | null>(null); // Prefer the lab's local data via the existing backend endpoint; fall back to // the HuggingFace dataset if the local profile image isn't available on the // server (so thumbnails never break regardless of deployment). Loaded natively @@ -65,8 +69,17 @@ export default function Preview({ onMouseEnter={() => { setHovered(true); prefetchViewer(); // warm the viewer JS chunk so clicking feels instant + // warm the low-res CT too, after a brief dwell (see prefetchVolume) + if (prefetchTimer.current) clearTimeout(prefetchTimer.current); + prefetchTimer.current = setTimeout(() => prefetchVolume(id), 150); + }} + onMouseLeave={() => { + setHovered(false); + if (prefetchTimer.current) { + clearTimeout(prefetchTimer.current); + prefetchTimer.current = null; + } }} - onMouseLeave={() => setHovered(false)} onClick={() => navigate(`/case/${id}`)} > {/* Gradient accent line — slides in on hover */} diff --git a/PanTS-Demo/src/helpers/prefetchVolume.ts b/PanTS-Demo/src/helpers/prefetchVolume.ts new file mode 100644 index 0000000..c49f44a --- /dev/null +++ b/PanTS-Demo/src/helpers/prefetchVolume.ts @@ -0,0 +1,42 @@ +// Warms the low-res CT for a case on card hover so clicking pays only decode + render, +// not the download. Pairs with prefetchViewer.ts (which warms the JS chunk). +// +// The volume responses are immutable (7-day Cache-Control), so a prefetched CT is reused +// straight from the browser cache on click. Guardrails keep this safe on shared JHU infra: +// - CT only (never the mask) to halve bandwidth +// - dedupe: each case is fetched at most once per session +// - a global concurrency cap so hovering across the grid can't stampede the backend +// (this cap is also why it's safe before the low-res batch exists — worst case is a +// couple of full-res CTs in flight, not the whole grid) +// Callers should debounce with a short hover dwell (see Preview.tsx) so a quick pass-over +// doesn't trigger a fetch. +import { API_BASE } from "./constants"; + +const MAX_CONCURRENT = 2; +const requested = new Set(); +const queue: number[] = []; +let inFlight = 0; + +function pump(): void { + while (inFlight < MAX_CONCURRENT && queue.length > 0) { + const id = queue.shift()!; + inFlight += 1; + // low-res by default; the server serves full res only if low-res isn't generated yet. + fetch(`${API_BASE}/api/get-main-nifti/${id}.nii.gz?res=low`) + // Drain the body so the response is fully received and cached, then let it GC. + .then((r) => (r.ok ? r.blob() : null)) + .catch(() => null) + .finally(() => { + inFlight -= 1; + pump(); + }); + } +} + +/** Queue a low-res CT prefetch for a dataset case (idempotent, bandwidth-bounded). */ +export function prefetchVolume(id: number): void { + if (!Number.isFinite(id) || requested.has(id)) return; + requested.add(id); + queue.push(id); + pump(); +}