diff --git a/.gitignore b/.gitignore
index 3e2f06e..328ba07 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,7 @@ tmp/*
downloaded.nii.gz
PanTS/data/pdf/
.DS_Store
+
+*.nii.gz
+CancerVerse/CancerVerse_dataset_metadata.csv
+CancerVerse/~/.cache/huggingface/.agent_harnesses.json
diff --git a/CancerVerse/download_partial_cancerverse.sh b/CancerVerse/download_partial_cancerverse.sh
new file mode 100644
index 0000000..f3a8f4b
--- /dev/null
+++ b/CancerVerse/download_partial_cancerverse.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+set -u
+
+REPO_ID="BodyMaps/CancerVerse"
+REPO_TYPE="dataset"
+LOCAL_DIR="."
+
+while true; do
+ echo "=========================================="
+ echo "Starting/retrying download at $(date)"
+ echo "Downloading CV_00000001 through CV_00000100"
+ echo "=========================================="
+
+ hf download "$REPO_ID" \
+ --repo-type "$REPO_TYPE" \
+ --local-dir "$LOCAL_DIR" \
+ --include "CancerVerse/CV_0000000[1-9]/*" \
+ --include "CancerVerse/CV_000000[1-9][0-9]/*" \
+ --include "CancerVerse/CV_00000100/*"
+
+ status=$?
+
+ if [ "$status" -eq 0 ]; then
+ echo "Download completed successfully at $(date)"
+ break
+ fi
+
+ echo "Download failed with exit code $status at $(date)"
+ echo "Retrying in 30 minutes..."
+ sleep 1800
+done
\ No newline at end of file
diff --git a/PanTS-Demo/src/components/Preview.tsx b/PanTS-Demo/src/components/Preview.tsx
index 1f554ba..db5c7d4 100644
--- a/PanTS-Demo/src/components/Preview.tsx
+++ b/PanTS-Demo/src/components/Preview.tsx
@@ -1,11 +1,12 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { API_BASE } from "../helpers/constants";
+import { getCaseDisplay } from "../helpers/utils";
import { prefetchViewer } from "../helpers/prefetchViewer";
import type { PreviewType } from "../types";
type Props = {
- id: number;
+ id: string;
previewMetadata: PreviewType;
saved?: boolean;
onToggleSave?: () => void;
@@ -35,14 +36,16 @@ export default function Preview({
if (!previewMetadata) return null;
- const caseIdStr = `PanTS_${id.toString().padStart(8, "0")}`;
+ const { dataset, label: caseIdStr } = getCaseDisplay(id);
// 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.
+ // CancerVerse has no HuggingFace profile-image mirror, so it skips straight to the
+ // "both sources failed" placeholder if the local endpoint 404s.
const hfProfileUrl = `https://huggingface.co/datasets/BodyMaps/iPanTSMini/resolve/main/profile_only/${caseIdStr}/profile.jpg`;
const proxyThumbUrl = `${API_BASE}/api/proxy-image?url=${encodeURIComponent(hfProfileUrl)}`;
const handleImgError = () => {
- if (thumbUrl !== proxyThumbUrl) {
+ if (dataset !== "CancerVerse" && thumbUrl !== proxyThumbUrl) {
setThumbUrl(proxyThumbUrl); // local failed — retry via the same-origin HF proxy
} else {
setImgError(true); // both sources failed
diff --git a/PanTS-Demo/src/helpers/savedCases.test.ts b/PanTS-Demo/src/helpers/savedCases.test.ts
index 4560901..a7fb75c 100644
--- a/PanTS-Demo/src/helpers/savedCases.test.ts
+++ b/PanTS-Demo/src/helpers/savedCases.test.ts
@@ -1,47 +1,62 @@
import { beforeEach, describe, expect, it } from "vitest";
import { isSavedCase, loadSavedCases, SAVED_CASES_KEY, toggleSavedCase } from "./savedCases";
-const meta = (id: number) => ({ id, sex: "M", age: 50, tumor: 0 });
+const meta = (id: string) => ({ id, sex: "M", age: 50, tumor: 0 });
describe("savedCases", () => {
beforeEach(() => localStorage.clear());
it("starts empty", () => {
expect(loadSavedCases()).toEqual([]);
- expect(isSavedCase(17)).toBe(false);
+ expect(isSavedCase("17")).toBe(false);
});
it("toggles a case on and off", () => {
- toggleSavedCase(meta(17));
- expect(isSavedCase(17)).toBe(true);
- expect(loadSavedCases().map((c) => c.id)).toEqual([17]);
+ toggleSavedCase(meta("17"));
+ expect(isSavedCase("17")).toBe(true);
+ expect(loadSavedCases().map((c) => c.id)).toEqual(["17"]);
- toggleSavedCase(meta(17));
- expect(isSavedCase(17)).toBe(false);
+ toggleSavedCase(meta("17"));
+ expect(isSavedCase("17")).toBe(false);
expect(loadSavedCases()).toEqual([]);
});
it("stores the card metadata and a savedAt timestamp", () => {
const before = Date.now();
- toggleSavedCase({ id: 30, sex: "F", age: 66, tumor: 1 });
+ toggleSavedCase({ id: "30", sex: "F", age: 66, tumor: 1 });
const [saved] = loadSavedCases();
- expect(saved).toMatchObject({ id: 30, sex: "F", age: 66, tumor: 1 });
+ expect(saved).toMatchObject({ id: "30", sex: "F", age: 66, tumor: 1 });
expect(saved.savedAt).toBeGreaterThanOrEqual(before);
});
it("keeps most-recently-saved first and de-dupes by id", () => {
- toggleSavedCase(meta(1));
- toggleSavedCase(meta(2));
- expect(loadSavedCases().map((c) => c.id)).toEqual([2, 1]);
+ toggleSavedCase(meta("1"));
+ toggleSavedCase(meta("2"));
+ expect(loadSavedCases().map((c) => c.id)).toEqual(["2", "1"]);
// re-adding an already-saved id removes it (toggle), it does not duplicate
- toggleSavedCase(meta(2));
- expect(loadSavedCases().map((c) => c.id)).toEqual([1]);
+ toggleSavedCase(meta("2"));
+ expect(loadSavedCases().map((c) => c.id)).toEqual(["1"]);
+ });
+
+ it("works with a CancerVerse id, unchanged behavior", () => {
+ toggleSavedCase(meta("CV_00000012"));
+ expect(isSavedCase("CV_00000012")).toBe(true);
+ expect(loadSavedCases().map((c) => c.id)).toEqual(["CV_00000012"]);
});
it("ignores corrupt storage instead of throwing", () => {
localStorage.setItem(SAVED_CASES_KEY, "not json");
expect(loadSavedCases()).toEqual([]);
localStorage.setItem(SAVED_CASES_KEY, JSON.stringify([{ nope: true }, { id: 5, sex: "M", age: 1, tumor: 0, savedAt: 1 }]));
- expect(loadSavedCases().map((c) => c.id)).toEqual([5]);
+ expect(loadSavedCases().map((c) => c.id)).toEqual(["5"]);
+ });
+
+ it("coerces legacy numeric ids (saved before ids became strings) to strings", () => {
+ localStorage.setItem(
+ SAVED_CASES_KEY,
+ JSON.stringify([{ id: 17, sex: "M", age: 40, tumor: 0, savedAt: 1 }])
+ );
+ expect(loadSavedCases().map((c) => c.id)).toEqual(["17"]);
+ expect(isSavedCase("17")).toBe(true);
});
});
diff --git a/PanTS-Demo/src/helpers/savedCases.ts b/PanTS-Demo/src/helpers/savedCases.ts
index 909ce89..c8ef6f0 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: string;
sex: string;
age: number;
tumor: number;
@@ -18,7 +18,12 @@ 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") : [];
+ // Accept legacy entries saved before case ids became strings (`id: number`) so
+ // existing bookmarks in a user's localStorage don't silently vanish.
+ if (!Array.isArray(arr)) return [];
+ return arr
+ .filter((c) => c && (typeof c.id === "number" || typeof c.id === "string"))
+ .map((c) => ({ ...c, id: String(c.id) }));
} catch {
return [];
}
@@ -38,7 +43,7 @@ const persistSavedCases = (list: SavedCase[]) => {
}
};
-export const isSavedCase = (id: number): boolean => loadSavedCases().some((c) => c.id === id);
+export const isSavedCase = (id: 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 29828cb..9a6f225 100644
--- a/PanTS-Demo/src/helpers/search.test.ts
+++ b/PanTS-Demo/src/helpers/search.test.ts
@@ -10,18 +10,23 @@ import {
describe("itemToId", () => {
it("parses the numeric id out of a PanTS case id", () => {
- expect(itemToId({ case_id: "PanTS_00008854" })).toBe(8854);
- expect(itemToId({ case_id: "PanTS_00000001" })).toBe(1);
+ expect(itemToId({ case_id: "PanTS_00008854" })).toBe("8854");
+ expect(itemToId({ case_id: "PanTS_00000001" })).toBe("1");
});
it("falls back across id fields and handles numbers", () => {
- expect(itemToId({ "PanTS ID": "PanTS_00000900" })).toBe(900);
- expect(itemToId({ id: 42 })).toBe(42);
+ expect(itemToId({ "PanTS ID": "PanTS_00000900" })).toBe("900");
+ expect(itemToId({ id: 42 })).toBe("42");
});
it("returns 0 when no usable id is present", () => {
- expect(itemToId({})).toBe(0);
- expect(itemToId({ case_id: "no-digits-here" })).toBe(0);
+ expect(itemToId({})).toBe("0");
+ expect(itemToId({ case_id: "no-digits-here" })).toBe("0");
+ });
+
+ it("keeps CancerVerse ids as-is, no offset/renumbering", () => {
+ expect(itemToId({ case_id: "CV_00000012" })).toBe("CV_00000012");
+ expect(itemToId({ dataset: "CancerVerse", case_id: "CV_00000012" })).toBe("CV_00000012");
});
});
@@ -76,6 +81,7 @@ describe("parseFiltersFromParams", () => {
ctPhase: ["Venous"],
siteNat: ["US"],
year: ["2020"],
+ dataset: ["CancerVerse"],
};
const restored = parseFiltersFromParams(buildSearchParams(filters));
expect(restored).toEqual(filters);
diff --git a/PanTS-Demo/src/helpers/search.ts b/PanTS-Demo/src/helpers/search.ts
index ffb12ee..8712ae1 100644
--- a/PanTS-Demo/src/helpers/search.ts
+++ b/PanTS-Demo/src/helpers/search.ts
@@ -11,6 +11,7 @@ export type SearchFilters = {
ctPhase: string[]; // CT phase, e.g. Arterial (from facets)
siteNat: string[]; // site nationality, e.g. US (from facets)
year: string[]; // study year (from facets)
+ dataset: string[]; // "PanTS" / "CancerVerse" (from facets)
};
export const EMPTY_FILTERS: SearchFilters = {
@@ -21,27 +22,32 @@ export const EMPTY_FILTERS: SearchFilters = {
ctPhase: [],
siteNat: [],
year: [],
+ dataset: [],
};
// The multi-select array keys (everything except `tumor`).
-export type MultiFilterKey = "sex" | "age" | "manufacturer" | "ctPhase" | "siteNat" | "year";
+export type MultiFilterKey = "sex" | "age" | "manufacturer" | "ctPhase" | "siteNat" | "year" | "dataset";
// Minimal shape of an item returned by /api/search and /api/random.
export type SearchItem = {
case_id?: string | number;
"PanTS ID"?: string | number;
id?: string | number;
+ dataset?: "PanTS" | "CancerVerse";
tumor?: number | null;
sex?: string | null;
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 => {
+// Case identity as a string, e.g. "PanTS_00008854" -> "8854" (PanTS's existing bare-
+// number id, unchanged) or "CV_00000012" -> "CV_00000012" (CancerVerse's real id,
+// kept as-is — no offset/renumbering, so it stays identical to the folder name on
+// disk). Returns "0" when nothing usable is present.
+export const itemToId = (it: SearchItem): string => {
const raw = String(it.case_id ?? it["PanTS ID"] ?? it.id ?? "");
+ if (/^cv_?\d+$/i.test(raw.trim())) return raw.trim().toUpperCase();
const m = raw.match(/\d+/);
- return m ? Number(m[0]) : 0;
+ return m ? String(Number(m[0])) : "0";
};
// Build the /api/search (and /api/facets, and URL) query string from the active
@@ -61,6 +67,7 @@ export const buildSearchParams = (
(filters.ctPhase ?? []).forEach((v) => params.append("ct_phase[]", v));
(filters.siteNat ?? []).forEach((v) => params.append("site_nat[]", v));
(filters.year ?? []).forEach((v) => params.append("year[]", v));
+ (filters.dataset ?? []).forEach((v) => params.append("dataset[]", v));
if (opts.sortBy) params.set("sort_by", opts.sortBy);
if (opts.perPage) params.set("per_page", String(opts.perPage));
return params;
@@ -79,6 +86,7 @@ export const parseFiltersFromParams = (params: URLSearchParams): SearchFilters =
ctPhase: params.getAll("ct_phase[]"),
siteNat: params.getAll("site_nat[]"),
year: params.getAll("year[]"),
+ dataset: params.getAll("dataset[]"),
};
};
@@ -89,4 +97,5 @@ export const countActiveFilters = (f: SearchFilters): number =>
f.manufacturer.length +
f.ctPhase.length +
f.siteNat.length +
- f.year.length;
+ f.year.length +
+ f.dataset.length;
diff --git a/PanTS-Demo/src/helpers/utils.test.ts b/PanTS-Demo/src/helpers/utils.test.ts
index 61411b5..854898f 100644
--- a/PanTS-Demo/src/helpers/utils.test.ts
+++ b/PanTS-Demo/src/helpers/utils.test.ts
@@ -7,7 +7,9 @@ import {
closestColorIndex,
deepIsEqual,
filenameToName,
+ getCaseDisplay,
getPanTSId,
+ isCancerVerseId,
prettify_segmentation_category,
roundDigits,
} from "./utils";
@@ -27,6 +29,19 @@ describe("case-ID formatting", () => {
expect(cleanName("PanTS_00008854")).toBe("8854");
expect(cleanName("PanTS_00000001")).toBe("1");
});
+
+ it("isCancerVerseId recognizes CV-prefixed ids, not plain PanTS numbers", () => {
+ expect(isCancerVerseId("CV_00000012")).toBe(true);
+ expect(isCancerVerseId("cv12")).toBe(true);
+ expect(isCancerVerseId("17")).toBe(false);
+ expect(isCancerVerseId("PanTS_00000017")).toBe(false);
+ });
+
+ it("getCaseDisplay keeps PanTS's existing label and gives CancerVerse its real id, no offset", () => {
+ expect(getCaseDisplay("17")).toEqual({ dataset: "PanTS", label: "PanTS_00000017" });
+ expect(getCaseDisplay("CV_00000012")).toEqual({ dataset: "CancerVerse", label: "CV_00000012" });
+ expect(getCaseDisplay("cv12")).toEqual({ dataset: "CancerVerse", label: "CV_00000012" });
+ });
});
describe("equality helpers", () => {
diff --git a/PanTS-Demo/src/helpers/utils.ts b/PanTS-Demo/src/helpers/utils.ts
index a0bd5f1..8cca5ef 100644
--- a/PanTS-Demo/src/helpers/utils.ts
+++ b/PanTS-Demo/src/helpers/utils.ts
@@ -99,6 +99,23 @@ export const getPanTSId = (case_id: string) => {
return `PanTS_${new_id}`;
}
+// Whether a case id string identifies a CancerVerse case ("CV_00000012", or a bare
+// "cv12" as typed by a user) rather than a PanTS case (a plain number, "17").
+// CancerVerse has no offset/renumbering — the id here is exactly the on-disk folder
+// name, "CV_########".
+export const isCancerVerseId = (case_id: string): boolean => /^cv_?\d+$/i.test(case_id.trim());
+
+// Dataset-aware display label for a case id: PanTS keeps its existing
+// `PanTS_<8-digit>` form (unchanged), CancerVerse cases show their real
+// `CV_<8-digit>` folder name (no arithmetic — id "12" is CV_00000012's actual number).
+export const getCaseDisplay = (case_id: string): { dataset: "PanTS" | "CancerVerse"; label: string } => {
+ const m = case_id.trim().match(/^cv_?(\d+)$/i);
+ if (m) {
+ return { dataset: "CancerVerse", label: `CV_${m[1].padStart(8, "0")}` };
+ }
+ return { dataset: "PanTS", label: getPanTSId(case_id) };
+}
+
export function capitalize(word: string) {
if (!word) return "";
return word.charAt(0).toUpperCase() + word.slice(1);
diff --git a/PanTS-Demo/src/routes/ComparePage.tsx b/PanTS-Demo/src/routes/ComparePage.tsx
index f244f81..2fc290d 100644
--- a/PanTS-Demo/src/routes/ComparePage.tsx
+++ b/PanTS-Demo/src/routes/ComparePage.tsx
@@ -10,6 +10,7 @@ import { alignStatRows } from "../helpers/compareStats";
import { API_BASE } from "../helpers/constants";
import { loadOrganNorms, type OrganNorms } from "../helpers/organNorms";
import { computeStatRows, type OrganMetric } from "../helpers/organStatsExport";
+import { getCaseDisplay } from "../helpers/utils";
import "./ComparePage.css";
type Demographics = { sex: string | null; age: number | null; tumor: number | null };
@@ -78,13 +79,15 @@ function useCaseData(id: string): CaseData {
// (mirrors the dashboard Preview's chain). In dev/demo both endpoints 404 → placeholder.
function Thumbnail({ id }: { id: string }) {
const local = `${API_BASE}/api/get_image_preview/${id}`;
- const caseIdStr = `PanTS_${id.toString().padStart(8, "0")}`;
+ const { dataset, label: caseIdStr } = getCaseDisplay(id);
const hf = `${API_BASE}/api/proxy-image?url=${encodeURIComponent(
`https://huggingface.co/datasets/BodyMaps/iPanTSMini/resolve/main/profile_only/${caseIdStr}/profile.jpg`
)}`;
+ // CancerVerse has no HuggingFace profile-image mirror to fall back to.
const [stage, setStage] = useState<0 | 1 | 2>(0);
useEffect(() => setStage(0), [id]);
- if (stage === 2) return
No preview
;
+ if (stage === 2 || (stage === 1 && dataset === "CancerVerse"))
+ return No preview
;
return (
([]);
+ const [PREVIEW_IDS, SET_PREVIEW_IDS] = useState([]);
const navigation = useNavigate();
const [previewMetadata, setPreviewMetadata] = useState<{
[key: string]: PreviewType;
@@ -163,37 +164,39 @@ export default function Homepage() {
};
}, []);
- const handleToggleSave = (id: number, meta?: PreviewType) => {
+ const handleToggleSave = (id: string, 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: string) => {
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: string) => {
setCompareIds((prev) => (prev.includes(id) ? prev : [...prev, id].slice(-2)));
};
+ // The typed-compare box only takes plain numbers, i.e. PanTS ids — matches the
+ // jump-to-case box above, which is the same PanTS-only convenience.
const submitTypedCompare = () => {
const n = parseInt(compareTyped.trim(), 10);
- if (Number.isFinite(n) && n > 0) addCompareId(n);
+ if (Number.isFinite(n) && n > 0) addCompareId(String(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: string[] = [];
const meta: { [key: string]: PreviewType } = {};
for (const it of items) {
const id = itemToId(it);
- if (!id) continue;
+ if (!id || id === "0") continue;
ids.push(id);
meta[id] = {
sex: it.sex ?? "",
@@ -272,7 +275,7 @@ export default function Homepage() {
const loadFacetOptions = async () => {
try {
const params = new URLSearchParams();
- params.set("fields", "tumor,sex,manufacturer,ct_phase,site_nat,year");
+ params.set("fields", "tumor,sex,manufacturer,ct_phase,site_nat,year,dataset");
params.set("top_k", "8");
const res = await fetch(`${API_BASE}/api/facets?${params.toString()}`);
const data = await res.json();
diff --git a/PanTS-Demo/src/routes/VisualizationPage.css b/PanTS-Demo/src/routes/VisualizationPage.css
index 09a15a1..1c3cb7b 100644
--- a/PanTS-Demo/src/routes/VisualizationPage.css
+++ b/PanTS-Demo/src/routes/VisualizationPage.css
@@ -1370,6 +1370,23 @@
text-align: right;
}
+/* ---- Report notes panel — free-text radiology report (CancerVerse), shares the
+ .vp-stats dock shell like the metadata/stats panels above. ---- */
+.vp-report__body {
+ overflow-y: auto;
+ padding: 12px 16px 16px;
+ font-family: var(--vp-font);
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--vp-text);
+ white-space: pre-wrap;
+}
+.vp-report__body::-webkit-scrollbar { width: 8px; }
+.vp-report__body::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 99px;
+}
+
/* ---- Floating organ tooltip ---- */
.vp-organ-tip {
position: fixed;
diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx
index 8a49953..f03cb07 100644
--- a/PanTS-Demo/src/routes/VisualizationPage.tsx
+++ b/PanTS-Demo/src/routes/VisualizationPage.tsx
@@ -18,6 +18,7 @@ import {
IconClick,
IconDownload,
IconEye,
+ IconFileText,
IconFlipHorizontal,
IconGrid3x3,
IconHome,
@@ -130,7 +131,7 @@ import {
type SessionResult,
} from "../helpers/readingSession";
import { toolDisplayName, type ReportMeasurement } from "../helpers/sessionReport";
-import { filenameToName, getPanTSId } from "../helpers/utils";
+import { filenameToName, getPanTSId, isCancerVerseId } from "../helpers/utils";
import { decodeViewerState, encodeViewerState } from "../helpers/viewerShareState";
import { type CheckBoxData } from "../types";
import "./VisualizationPage.css";
@@ -316,6 +317,20 @@ function VisualizationPage() {
const isLocal = isDicom || isLocalNifti;
const [dicomError, setDicomError] = useState(null);
+ // CancerVerse: a real backend case (unlike isLocal), but CT-only — no
+ // segmentations/meshes exist for it. Segmentation-dependent tools stay visible but
+ // rendered faded/disabled (fadedProps below) rather than hidden or silently broken.
+ const isCancerVerse = !isLocal && isCancerVerseId(pantsCase ?? "");
+ const FADED_STYLE = { opacity: 0.4, pointerEvents: "none" as const, cursor: "not-allowed" };
+ const fadedProps = isCancerVerse
+ ? { style: FADED_STYLE, title: "Not available — CancerVerse has no segmentation data", "aria-disabled": true }
+ : {};
+ // The inverse: report notes only exist for CancerVerse (free-text radiology reports
+ // from its metadata CSV) — fade the tool for every other dataset/mode.
+ const reportNotesFadedProps = !isCancerVerse
+ ? { style: FADED_STYLE, title: "Not available — no report notes for this dataset", "aria-disabled": true }
+ : {};
+
// Where to load the volumes from. Per the maintainer's rule, dataset cases load
// from the lab's LOCAL endpoints (served off disk on the JHU server — much faster
// for big full-body scans than streaming the .nii.gz from HuggingFace). We probe
@@ -340,6 +355,14 @@ function VisualizationPage() {
setSegUrl(`${API_BASE}/api/session-segmentation/${sessionId}`);
return;
}
+ if (isCancerVerse) {
+ // CT-only: no masks, no HuggingFace mirror, no low-res tier generated for
+ // this dataset — always full res straight from the local backend.
+ setLocalAvailable(false);
+ setCtUrl(`${API_BASE}/api/get-main-nifti/${pantsCase}.nii.gz`);
+ setSegUrl(null);
+ return;
+ }
const id = pantsCase ?? "1";
const p = getPanTSId(id);
const localCt = `${API_BASE}/api/get-main-nifti/${id}.nii.gz`;
@@ -358,7 +381,7 @@ function VisualizationPage() {
};
resolveSources();
return () => { cancelled = true; };
- }, [pantsCase, sessionId, isHd, isLocal]);
+ }, [pantsCase, sessionId, isHd, isLocal, isCancerVerse]);
// Flip between low-res and full-res by reloading the route — a fresh mount cleanly
// re-inits the Cornerstone/NiiVue contexts (re-running them in place is fragile).
@@ -436,6 +459,9 @@ function VisualizationPage() {
const [showMetadata, setShowMetadata] = useState(false);
const [caseMetadata, setCaseMetadata] = useState | null>(null);
const demographicsTriedRef = useRef(false);
+ // Free-text radiology report notes — CancerVerse-only (comes from caseMetadata.report,
+ // same /api/search row PanTS cases just don't carry that field on).
+ const [showReportNotes, setShowReportNotes] = useState(false);
// Measured download progress for the loading screen (from the nifti loader's real
// bytes-loaded/total — accurate, not a guess).
const [dlPct, setDlPct] = useState(null);
@@ -494,7 +520,7 @@ function VisualizationPage() {
useEffect(() => { checkBoxDataRef.current = checkBoxData; }, [checkBoxData]);
// 3D pane rendering mode: organ meshes (dataset cases) or shaded GPU volume
// rendering of the CT itself (the only 3D option for local DICOM).
- const [threeDMode, setThreeDMode] = useState<"mesh" | "volume">(isLocal ? "volume" : "mesh");
+ const [threeDMode, setThreeDMode] = useState<"mesh" | "volume">(isLocal || isCancerVerse ? "volume" : "mesh");
const [volumePreset, setVolumePreset] = useState(VOLUME_3D_PRESETS[0].name);
// CT presets by default; swapped for the MR set when a local DICOM turns out to be MR.
const [volume3DPresets, setVolume3DPresets] = useState(VOLUME_3D_PRESETS);
@@ -774,6 +800,7 @@ function VisualizationPage() {
} else if (key === "m") {
setShowStats(false);
setShowMetadata(false);
+ setShowReportNotes(false);
setShowEditPanel(false);
setEditMode(null);
setShowMeasurePanel((v) => !v);
@@ -1020,7 +1047,9 @@ function VisualizationPage() {
if (
!ctUrl ||
- !segUrl ||
+ // CancerVerse cases never get a segUrl (no masks exist) — that's expected,
+ // not a "still loading" state, so it doesn't block rendering the CT.
+ (!segUrl && !isCancerVerse) ||
!axial_ref.current ||
!sagittal_ref.current ||
!coronal_ref.current ||
@@ -1037,7 +1066,7 @@ function VisualizationPage() {
coronal_ref.current,
cmap,
ctUrl,
- segUrl,
+ segUrl ?? undefined,
setLoading
);
@@ -1081,6 +1110,7 @@ function VisualizationPage() {
segUrl,
isDicom,
isLocalNifti,
+ isCancerVerse,
axial_ref,
sagittal_ref,
coronal_ref,
@@ -1262,7 +1292,7 @@ function VisualizationPage() {
// Group-level "something inside is active" flags, so each collapsed toolbar dropdown
// still visually reflects its contents' state without having to be open.
const viewGroupActive = hoverIdentifyEnabled || referenceLinesOn;
- const panelsGroupActive = showOrganDetails || showStats || showMetadata || showMeasurePanel;
+ const panelsGroupActive = showOrganDetails || showStats || showMetadata || showReportNotes || showMeasurePanel;
// The Layout ▾ trigger shows the pane-layout preset's name when one is active
// (it's the more specific choice), otherwise the current view mode.
@@ -1574,8 +1604,9 @@ function VisualizationPage() {
};
const handleToggleStats = () => {
- // The right-side slot is shared by stats / metadata / measurements / mask editing.
+ // The right-side slot is shared by stats / metadata / report notes / measurements / mask editing.
setShowMetadata(false);
+ setShowReportNotes(false);
setShowMeasurePanel(false);
setShowEditPanel(false);
setEditMode(null);
@@ -1586,6 +1617,7 @@ function VisualizationPage() {
const handleToggleMetadata = () => {
setShowStats(false);
+ setShowReportNotes(false);
setShowMeasurePanel(false);
setShowEditPanel(false);
setEditMode(null);
@@ -1593,6 +1625,18 @@ function VisualizationPage() {
loadPercentileContext();
};
+ // Report notes only ever has data for CancerVerse cases (see reportNotesFadedProps),
+ // but the toggle itself just reuses the same shared right-side slot + metadata fetch.
+ const handleToggleReportNotes = () => {
+ setShowStats(false);
+ setShowMetadata(false);
+ setShowMeasurePanel(false);
+ setShowEditPanel(false);
+ setEditMode(null);
+ setShowReportNotes((v) => !v);
+ loadPercentileContext();
+ };
+
const handleToggleAISidebar = () => {
const opening = !showAISidebar;
@@ -1601,6 +1645,7 @@ function VisualizationPage() {
if (opening) {
setShowStats(false);
setShowMetadata(false);
+ setShowReportNotes(false);
setShowMeasurePanel(false);
setShowEditPanel(false);
setEditMode(null);
@@ -2091,7 +2136,9 @@ const aiAvailableOrgans = useMemo(() => {
className={`vp-flyout__item ${hoverIdentifyEnabled ? "is-active" : ""}`}
role="menuitem"
title="Name the organ under the cursor"
+ {...fadedProps}
onClick={() => {
+ if (isCancerVerse) return;
setHoverIdentifyEnabled((v) => !v);
setHoverOrganTip((t) => (t.visible ? { ...t, visible: false } : t));
viewFlyout.close();
@@ -2217,10 +2264,13 @@ const aiAvailableOrgans = useMemo(() => {
{!isLocal && (