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
17 changes: 15 additions & 2 deletions PanTS-Demo/src/components/Preview.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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<ReturnType<typeof setTimeout> | 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
Expand Down Expand Up @@ -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 */}
Expand Down
42 changes: 42 additions & 0 deletions PanTS-Demo/src/helpers/prefetchVolume.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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();
}
Loading