diff --git a/PanTS-Demo/src/components/Preview.tsx b/PanTS-Demo/src/components/Preview.tsx index 0a298c8..bbc3d0a 100644 --- a/PanTS-Demo/src/components/Preview.tsx +++ b/PanTS-Demo/src/components/Preview.tsx @@ -3,10 +3,11 @@ import { useNavigate } from "react-router-dom"; import { API_BASE } from "../helpers/constants"; import { prefetchViewer } from "../helpers/prefetchViewer"; import { prefetchVolume } from "../helpers/prefetchVolume"; +import type { CaseId } from "../helpers/search"; import type { PreviewType } from "../types"; type Props = { - id: number; + id: CaseId; previewMetadata: PreviewType; saved?: boolean; onToggleSave?: () => void; @@ -39,7 +40,11 @@ export default function Preview({ if (!previewMetadata) return null; - const caseIdStr = `PanTS_${id.toString().padStart(8, "0")}`; + // CancerVerse ids arrive as full strings ("CV_00000001"); PanTS as bare numbers. + const caseIdStr = + typeof id === "string" && id.toUpperCase().startsWith("CV") + ? id + : `PanTS_${id.toString().padStart(8, "0")}`; // HuggingFace fallback, routed through the backend's same-origin proxy. A *direct* // cross-origin image is blocked by the viewer's COEP: require-corp header (which is // why thumbnails went missing); the proxy keeps it same-origin, matching home.html. diff --git a/PanTS-Demo/src/helpers/prefetchVolume.ts b/PanTS-Demo/src/helpers/prefetchVolume.ts index c49f44a..4027e25 100644 --- a/PanTS-Demo/src/helpers/prefetchVolume.ts +++ b/PanTS-Demo/src/helpers/prefetchVolume.ts @@ -11,10 +11,11 @@ // 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"; +import type { CaseId } from "./search"; const MAX_CONCURRENT = 2; -const requested = new Set(); -const queue: number[] = []; +const requested = new Set(); +const queue: CaseId[] = []; let inFlight = 0; function pump(): void { @@ -33,9 +34,10 @@ function pump(): void { } } -/** 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; +/** Queue a low-res CT prefetch for a dataset case (idempotent, bandwidth-bounded). + * Accepts a PanTS number or a CancerVerse string id (e.g. "CV_00000001"). */ +export function prefetchVolume(id: CaseId): void { + if (!id || requested.has(id)) return; // 0 / "" = no usable id requested.add(id); queue.push(id); pump(); diff --git a/PanTS-Demo/src/helpers/savedCases.ts b/PanTS-Demo/src/helpers/savedCases.ts index 909ce89..4717e52 100644 --- a/PanTS-Demo/src/helpers/savedCases.ts +++ b/PanTS-Demo/src/helpers/savedCases.ts @@ -5,7 +5,7 @@ // (cross-tab) to stay in sync. export type SavedCase = { - id: number; + id: number | string; // PanTS number (8854) or CancerVerse string ("CV_00000001") sex: string; age: number; tumor: number; @@ -18,7 +18,9 @@ export const SAVED_CASES_EVENT = "savedcaseschange"; export const loadSavedCases = (): SavedCase[] => { try { const arr = JSON.parse(localStorage.getItem(SAVED_CASES_KEY) || "[]"); - return Array.isArray(arr) ? arr.filter((c) => c && typeof c.id === "number") : []; + return Array.isArray(arr) + ? arr.filter((c) => c && (typeof c.id === "number" || typeof c.id === "string")) + : []; } catch { return []; } @@ -38,7 +40,8 @@ const persistSavedCases = (list: SavedCase[]) => { } }; -export const isSavedCase = (id: number): boolean => loadSavedCases().some((c) => c.id === id); +export const isSavedCase = (id: number | string): boolean => + loadSavedCases().some((c) => c.id === id); // Add the case if it isn't saved, otherwise remove it. Returns the updated list (most // recently saved first). diff --git a/PanTS-Demo/src/helpers/search.test.ts b/PanTS-Demo/src/helpers/search.test.ts index b0cc6cd..d834a7c 100644 --- a/PanTS-Demo/src/helpers/search.test.ts +++ b/PanTS-Demo/src/helpers/search.test.ts @@ -23,6 +23,11 @@ describe("itemToId", () => { expect(itemToId({})).toBe(0); expect(itemToId({ case_id: "no-digits-here" })).toBe(0); }); + + it("keeps CancerVerse ids as the full prefixed string (not a stripped number)", () => { + expect(itemToId({ case_id: "CV_00000001" })).toBe("CV_00000001"); + expect(itemToId({ "PanTS ID": "CV_00012345" })).toBe("CV_00012345"); + }); }); describe("buildSearchParams", () => { diff --git a/PanTS-Demo/src/helpers/search.ts b/PanTS-Demo/src/helpers/search.ts index a1b13bc..1da43ab 100644 --- a/PanTS-Demo/src/helpers/search.ts +++ b/PanTS-Demo/src/helpers/search.ts @@ -28,7 +28,13 @@ export const EMPTY_FILTERS: SearchFilters = { // The multi-select array keys (everything except `tumor`). export type MultiFilterKey = "dataset" | "sex" | "age" | "manufacturer" | "ctPhase" | "siteNat" | "year"; -// Minimal shape of an item returned by /api/search and /api/random. +// A case id as used across the UI: a bare number for PanTS (e.g. 8854) or the full +// prefixed string for CancerVerse (e.g. "CV_00000001"). CancerVerse ids MUST keep +// their prefix so they route to the CV endpoints instead of being mistaken for PanTS. +export type CaseId = number | string; + +// Minimal shape of an item returned by /api/search and /api/random. The id fields can +// be a PanTS id ("PanTS_00008854") or a CancerVerse id ("CV_00000001"). export type SearchItem = { case_id?: string | number; "PanTS ID"?: string | number; @@ -38,10 +44,13 @@ export type SearchItem = { age?: number | string | null; }; -// Parse the numeric case id out of any of the id-ish fields, e.g. -// "PanTS_00008854" -> 8854. Returns 0 when nothing usable is present. -export const itemToId = (it: SearchItem): number => { - const raw = String(it.case_id ?? it["PanTS ID"] ?? it.id ?? ""); +// Resolve a card id from any of the id-ish fields. PanTS → the bare number +// ("PanTS_00008854" → 8854); CancerVerse → the full string kept as-is +// ("CV_00000001") so it hits the CV endpoints. Returns 0 when nothing usable. +export const itemToId = (it: SearchItem): CaseId => { + const raw = String(it.case_id ?? it["PanTS ID"] ?? it.id ?? "").trim(); + if (!raw) return 0; + if (raw.toUpperCase().startsWith("CV")) return raw; // keep "CV_00000001" as-is const m = raw.match(/\d+/); return m ? Number(m[0]) : 0; }; diff --git a/PanTS-Demo/src/routes/Homepage.tsx b/PanTS-Demo/src/routes/Homepage.tsx index 251367c..78847ab 100644 --- a/PanTS-Demo/src/routes/Homepage.tsx +++ b/PanTS-Demo/src/routes/Homepage.tsx @@ -13,6 +13,7 @@ import Preview from "../components/Preview"; import { API_BASE } from "../helpers/constants"; import { buildSearchParams, + type CaseId, countActiveFilters, EMPTY_FILTERS, itemToId, @@ -137,7 +138,7 @@ const filterLabelStyle: React.CSSProperties = { }; export default function Homepage() { - const [PREVIEW_IDS, SET_PREVIEW_IDS] = useState([]); + const [PREVIEW_IDS, SET_PREVIEW_IDS] = useState([]); const navigation = useNavigate(); const [previewMetadata, setPreviewMetadata] = useState<{ [key: string]: PreviewType; @@ -170,33 +171,38 @@ export default function Homepage() { }; }, []); - const handleToggleSave = (id: number, meta?: PreviewType) => { + const handleToggleSave = (id: CaseId, meta?: PreviewType) => { const m = meta ?? previewMetadata[id]; toggleSavedCase({ id, sex: m?.sex ?? "", age: m?.age ?? 0, tumor: m?.tumor ?? 0 }); }; // Cases picked for side-by-side comparison (max 2). Adding a third drops the oldest, // so the two most recent picks are always what get compared. - const [compareIds, setCompareIds] = useState([]); + const [compareIds, setCompareIds] = useState([]); const [compareTyped, setCompareTyped] = useState(""); - const toggleCompare = (id: number) => { + const toggleCompare = (id: CaseId) => { setCompareIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id].slice(-2) ); }; // Add a case by id typed into the tray (idempotent; caps at the two most recent). - const addCompareId = (id: number) => { + const addCompareId = (id: CaseId) => { setCompareIds((prev) => (prev.includes(id) ? prev : [...prev, id].slice(-2))); }; const submitTypedCompare = () => { - const n = parseInt(compareTyped.trim(), 10); - if (Number.isFinite(n) && n > 0) addCompareId(n); + const raw = compareTyped.trim(); + if (raw.toUpperCase().startsWith("CV")) { + addCompareId(raw); // CancerVerse id typed in full + } else { + const n = parseInt(raw, 10); + if (Number.isFinite(n) && n > 0) addCompareId(n); + } setCompareTyped(""); }; // Turn /api/search (or /api/random) items into the ids + metadata the grid needs. const ingestItems = (items: SearchItem[]) => { - const ids: number[] = []; + const ids: CaseId[] = []; const meta: { [key: string]: PreviewType } = {}; for (const it of items) { const id = itemToId(it);