Refactor CT presets and enhance modality-specific preset options for improved functionality
This commit is contained in:
+263
-196
@@ -104,8 +104,8 @@ const { MouseBindings, KeyboardBindings } = csToolsEnums;
|
|||||||
const { IMAGE_RENDERED } = Enums.Events;
|
const { IMAGE_RENDERED } = Enums.Events;
|
||||||
|
|
||||||
|
|
||||||
// Common CT presets (add more as needed)
|
// CT-specific presets. Other modalities use reset + autolevel only.
|
||||||
const WINDOW_LEVEL_PRESETS = [
|
const CT_WINDOW_LEVEL_PRESETS = [
|
||||||
{ name: "Soft Tissue", center: 40, width: 400 },
|
{ name: "Soft Tissue", center: 40, width: 400 },
|
||||||
{ name: "Lung", center: -600, width: 1500 },
|
{ name: "Lung", center: -600, width: 1500 },
|
||||||
{ name: "Bone", center: 300, width: 1500 },
|
{ name: "Bone", center: 300, width: 1500 },
|
||||||
@@ -113,6 +113,32 @@ const WINDOW_LEVEL_PRESETS = [
|
|||||||
{ name: "Abdomen", center: 60, width: 400 },
|
{ name: "Abdomen", center: 60, width: 400 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type PresetOption =
|
||||||
|
| { name: string; action: "reset" | "autolevel" }
|
||||||
|
| { name: string; action: "preset"; center: number; width: number };
|
||||||
|
|
||||||
|
const getPresetOptionsForModality = (modality?: string): PresetOption[] => {
|
||||||
|
const normalized = (modality || "").toUpperCase();
|
||||||
|
const common: PresetOption[] = [
|
||||||
|
{ name: "Reset to default", action: "reset" },
|
||||||
|
{ name: "Auto level", action: "autolevel" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (normalized !== "CT") {
|
||||||
|
return common;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...common,
|
||||||
|
...CT_WINDOW_LEVEL_PRESETS.map((preset) => ({
|
||||||
|
name: preset.name,
|
||||||
|
action: "preset" as const,
|
||||||
|
center: preset.center,
|
||||||
|
width: preset.width,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Tool options for dropdowns (icons for compact UI)
|
// Tool options for dropdowns (icons for compact UI)
|
||||||
const TOOL_OPTIONS = [
|
const TOOL_OPTIONS = [
|
||||||
{ label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" },
|
{ label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" },
|
||||||
@@ -177,16 +203,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings";
|
const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings";
|
||||||
const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings";
|
const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings";
|
||||||
|
|
||||||
// State to control preset menu open/close
|
|
||||||
const [presetsOpen, setPresetsOpen] = useState(false);
|
|
||||||
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
// Settings open state
|
// Settings open state
|
||||||
// (top bar removed; stack browser moved to right-side panel)
|
// (top bar removed; stack browser moved to right-side panel)
|
||||||
|
|
||||||
// Right-side stack panel open state (toggled by button)
|
// Right-side stack panel open state (toggled by button)
|
||||||
const [stackPanelOpen, setStackPanelOpen] = useState(false);
|
const [stackPanelOpen, setStackPanelOpen] = useState(false);
|
||||||
const [showOpenButtonHover, setShowOpenButtonHover] = useState(false);
|
|
||||||
const [stackPanelWidth, setStackPanelWidth] = useState(320);
|
const [stackPanelWidth, setStackPanelWidth] = useState(320);
|
||||||
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
|
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
|
||||||
resizing: false,
|
resizing: false,
|
||||||
@@ -936,16 +958,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
windowCenter: string,
|
windowCenter: string,
|
||||||
windowWidth: string,
|
windowWidth: string,
|
||||||
slicePosition: string,
|
slicePosition: string,
|
||||||
|
modality: string,
|
||||||
}>>(() =>
|
}>>(() =>
|
||||||
Array(viewportGrid.rows * viewportGrid.cols).fill({
|
Array(viewportGrid.rows * viewportGrid.cols).fill({
|
||||||
index: 0,
|
index: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
windowCenter: "",
|
windowCenter: "",
|
||||||
windowWidth: "",
|
windowWidth: "",
|
||||||
slicePosition: 0,
|
slicePosition: "",
|
||||||
|
modality: "",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [viewedIndicesByViewport, setViewedIndicesByViewport] = useState<number[][]>(
|
||||||
|
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
|
||||||
|
);
|
||||||
|
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
|
||||||
|
|
||||||
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
|
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
|
||||||
() => Array(MAX_VIEWPORTS).fill('stack')
|
() => Array(MAX_VIEWPORTS).fill('stack')
|
||||||
);
|
);
|
||||||
@@ -1584,6 +1613,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
let windowCenter = "";
|
let windowCenter = "";
|
||||||
let windowWidth = "";
|
let windowWidth = "";
|
||||||
let slicePosition = "";
|
let slicePosition = "";
|
||||||
|
let modality = "";
|
||||||
|
|
||||||
if (viewportModes[viewportIdx] === "stack") {
|
if (viewportModes[viewportIdx] === "stack") {
|
||||||
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
|
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
|
||||||
@@ -1593,6 +1623,26 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
? viewport.getImageIds()
|
? viewport.getImageIds()
|
||||||
: [];
|
: [];
|
||||||
total = imageIds.length;
|
total = imageIds.length;
|
||||||
|
|
||||||
|
const currentImageId = imageIds[currentIndex];
|
||||||
|
if (currentImageId) {
|
||||||
|
const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any;
|
||||||
|
modality = seriesMeta?.modality || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
setViewedIndicesByViewport((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const existing = new Set(next[viewportIdx] || []);
|
||||||
|
if (!existing.has(currentIndex)) {
|
||||||
|
existing.add(currentIndex);
|
||||||
|
next[viewportIdx] = Array.from(existing).sort((a, b) => a - b);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof viewport.getProperties === "function") {
|
if (typeof viewport.getProperties === "function") {
|
||||||
const props = viewport.getProperties();
|
const props = viewport.getProperties();
|
||||||
if (props.voiRange) {
|
if (props.voiRange) {
|
||||||
@@ -1632,6 +1682,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
slicePosition = coords.position.toFixed(2) + " mm";
|
slicePosition = coords.position.toFixed(2) + " mm";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstImageId = viewportImageIds[viewportIdx]?.[0];
|
||||||
|
if (firstImageId) {
|
||||||
|
const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any;
|
||||||
|
modality = seriesMeta?.modality || "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setImageInfos(prev => {
|
setImageInfos(prev => {
|
||||||
@@ -1642,13 +1698,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
windowCenter,
|
windowCenter,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
slicePosition, // add this field
|
slicePosition, // add this field
|
||||||
|
modality,
|
||||||
};
|
};
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [viewportModes]);
|
}, [viewportModes, viewportImageIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setViewedIndicesByViewport((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = [...prev];
|
||||||
|
|
||||||
const [gridBtnActive, setGridBtnActive] = useState(false);
|
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||||
|
const ids = viewportImageIds[i] || [];
|
||||||
|
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
|
||||||
|
if (viewportStackSignaturesRef.current[i] !== signature) {
|
||||||
|
viewportStackSignaturesRef.current[i] = signature;
|
||||||
|
next[i] = [];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [viewportImageIds]);
|
||||||
|
|
||||||
function saveVisibleViewportState() {
|
function saveVisibleViewportState() {
|
||||||
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
|
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
|
||||||
@@ -2062,6 +2135,57 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
}, [updateOverlay]);
|
}, [updateOverlay]);
|
||||||
|
|
||||||
|
const handleAutoLevel = useCallback((viewportIdx: number) => {
|
||||||
|
const viewportId = `CT_${viewportIdx}`;
|
||||||
|
try {
|
||||||
|
const renderingEngine = renderingEngineRef.current;
|
||||||
|
if (!renderingEngine) return;
|
||||||
|
const viewport = renderingEngine.getViewport(viewportId);
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
const ids = typeof viewport.getImageIds === "function"
|
||||||
|
? viewport.getImageIds()
|
||||||
|
: (viewportImageIds[viewportIdx] || []);
|
||||||
|
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
|
||||||
|
? viewport.getCurrentImageIdIndex()
|
||||||
|
: 0;
|
||||||
|
const imageId = ids[currentIndex] || ids[0];
|
||||||
|
|
||||||
|
if (!imageId) {
|
||||||
|
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
|
||||||
|
viewport.render();
|
||||||
|
updateOverlay(viewportIdx, viewport);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any;
|
||||||
|
const lutMeta = (metaData.get("modalityLutModule", imageId) || {}) as any;
|
||||||
|
|
||||||
|
const minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue);
|
||||||
|
const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue);
|
||||||
|
const slope = Number(lutMeta.rescaleSlope ?? 1);
|
||||||
|
const intercept = Number(lutMeta.rescaleIntercept ?? 0);
|
||||||
|
|
||||||
|
if (!Number.isFinite(minRaw) || !Number.isFinite(maxRaw) || maxRaw <= minRaw) {
|
||||||
|
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
|
||||||
|
viewport.render();
|
||||||
|
updateOverlay(viewportIdx, viewport);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewport.setProperties({
|
||||||
|
voiRange: {
|
||||||
|
lower: minRaw * slope + intercept,
|
||||||
|
upper: maxRaw * slope + intercept,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewport.render();
|
||||||
|
updateOverlay(viewportIdx, viewport);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore failures and keep existing VOI.
|
||||||
|
}
|
||||||
|
}, [updateOverlay, viewportImageIds]);
|
||||||
|
|
||||||
// Update overlay and preset state arrays when grid changes
|
// Update overlay and preset state arrays when grid changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
|
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
|
||||||
@@ -2069,6 +2193,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
total: 0,
|
total: 0,
|
||||||
windowCenter: "",
|
windowCenter: "",
|
||||||
windowWidth: "",
|
windowWidth: "",
|
||||||
|
slicePosition: "",
|
||||||
|
modality: "",
|
||||||
}));
|
}));
|
||||||
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
|
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
|
||||||
}, [viewportGrid]);
|
}, [viewportGrid]);
|
||||||
@@ -2078,18 +2204,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
applyMouseToolBindings()
|
applyMouseToolBindings()
|
||||||
}, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings])
|
}, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings])
|
||||||
|
|
||||||
// Close the menu when clicking outside
|
// Close preset menus when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presetsOpen) return;
|
if (!presetsOpenArr.some(Boolean)) return;
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
const menu = document.querySelector(".preset-menu");
|
const menu = document.querySelector(".preset-menu");
|
||||||
if (menu && !menu.contains(e.target as Node)) {
|
if (menu && !menu.contains(e.target as Node)) {
|
||||||
setPresetsOpen(false);
|
setPresetsOpenArr(prev => prev.map(() => false));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClick);
|
document.addEventListener("mousedown", handleClick);
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, [presetsOpen]);
|
}, [presetsOpenArr]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preventExtraButtonDefault = (e: MouseEvent) => {
|
const preventExtraButtonDefault = (e: MouseEvent) => {
|
||||||
@@ -3468,7 +3594,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Presets
|
Presets ({imageInfos[i]?.modality || "Unknown"})
|
||||||
<span style={{ float: "right", fontWeight: "normal" }}>
|
<span style={{ float: "right", fontWeight: "normal" }}>
|
||||||
{presetsOpenArr[i] ? "▲" : "▼"}
|
{presetsOpenArr[i] ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
@@ -3487,58 +3613,45 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
|
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
|
||||||
>
|
>
|
||||||
<button
|
{getPresetOptionsForModality(imageInfos[i]?.modality).map((preset) => (
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
margin: "2px 0 8px 0",
|
|
||||||
background: "#444",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "3px",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "6px 0",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Reset viewport properties (window/level and camera) to defaults
|
|
||||||
try {
|
|
||||||
const viewportId = `CT_${i}`;
|
|
||||||
const renderingEngine = renderingEngineRef.current;
|
|
||||||
const viewport = renderingEngine?.getViewport(viewportId);
|
|
||||||
if (viewport) {
|
|
||||||
if (typeof viewport.resetProperties === 'function') viewport.resetProperties();
|
|
||||||
if (typeof viewport.resetCamera === 'function') viewport.resetCamera();
|
|
||||||
if (typeof viewport.render === 'function') viewport.render();
|
|
||||||
updateOverlay(i, viewport);
|
|
||||||
}
|
|
||||||
} catch (err) { console.warn('Failed to reset viewport properties', err); }
|
|
||||||
setPresetsOpenArr(prev => { const next = [...prev]; next[i] = false; return next; });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset to default
|
|
||||||
</button>
|
|
||||||
{WINDOW_LEVEL_PRESETS.map((preset) => (
|
|
||||||
<button
|
<button
|
||||||
key={preset.name}
|
key={preset.name}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
margin: "2px 0",
|
margin: "2px 0",
|
||||||
background: "#333",
|
background: preset.action === "preset" ? "#333" : "#444",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "3px",
|
borderRadius: "3px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
padding: "4px 0",
|
padding: preset.action === "preset" ? "4px 0" : "6px 0",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
|
fontWeight: preset.action === "preset" ? "normal" : "600",
|
||||||
}}
|
}}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation(); // <-- prevent bubbling to viewport
|
e.stopPropagation(); // <-- prevent bubbling to viewport
|
||||||
handleApplyPreset(i, preset.center, preset.width);
|
if (preset.action === "reset") {
|
||||||
|
try {
|
||||||
|
const viewportId = `CT_${i}`;
|
||||||
|
const renderingEngine = renderingEngineRef.current;
|
||||||
|
const viewport = renderingEngine?.getViewport(viewportId);
|
||||||
|
if (viewport) {
|
||||||
|
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
|
||||||
|
if (typeof viewport.resetCamera === "function") viewport.resetCamera();
|
||||||
|
if (typeof viewport.render === "function") viewport.render();
|
||||||
|
updateOverlay(i, viewport);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to reset viewport properties", err);
|
||||||
|
}
|
||||||
|
} else if (preset.action === "autolevel") {
|
||||||
|
handleAutoLevel(i);
|
||||||
|
} else {
|
||||||
|
const ctPreset = preset as Extract<PresetOption, { action: "preset" }>;
|
||||||
|
handleApplyPreset(i, ctPreset.center, ctPreset.width);
|
||||||
|
}
|
||||||
setPresetsOpenArr(prev => {
|
setPresetsOpenArr(prev => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[i] = false;
|
next[i] = false;
|
||||||
@@ -3560,96 +3673,124 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "10%",
|
top: "10%",
|
||||||
right: 8,
|
right: 8,
|
||||||
width: "12px",
|
width: "14px",
|
||||||
height: "80%",
|
height: "80%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
zIndex: 30,
|
zIndex: 35,
|
||||||
display: viewportImageIds[i].length > 0 ? "flex" : "none",
|
display: viewportImageIds[i].length > 0 ? "flex" : "none",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
pointerEvents: "none",
|
pointerEvents: "auto",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{(() => {
|
||||||
|
const imageIds = viewportImageIds[i] || [];
|
||||||
|
const total = imageIds.length;
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
const renderingEngine = renderingEngineRef.current;
|
||||||
|
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||||||
|
const currentIdx = viewport && typeof viewport.getCurrentImageIdIndex === "function"
|
||||||
|
? viewport.getCurrentImageIdIndex()
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const viewedSet = new Set(viewedIndicesByViewport[i] || []);
|
||||||
|
const loadedFlags = imageIds.map((id) => {
|
||||||
|
if (id.startsWith("external:")) return true;
|
||||||
|
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
|
||||||
|
});
|
||||||
|
const loadedCount = loadedFlags.reduce((sum, loaded) => sum + (loaded ? 1 : 0), 0);
|
||||||
|
const viewedCount = imageIds.reduce((sum, _id, idx) => sum + (viewedSet.has(idx) ? 1 : 0), 0);
|
||||||
|
|
||||||
|
const segmentCount = Math.min(total, 180);
|
||||||
|
const segments = Array.from({ length: segmentCount }, () => ({ loaded: false, viewed: false, current: false }));
|
||||||
|
|
||||||
|
for (let idx = 0; idx < total; idx++) {
|
||||||
|
const segmentIdx = total === 1
|
||||||
|
? 0
|
||||||
|
: Math.min(segmentCount - 1, Math.floor((idx / (total - 1)) * (segmentCount - 1)));
|
||||||
|
if (loadedFlags[idx]) segments[segmentIdx].loaded = true;
|
||||||
|
if (viewedSet.has(idx)) segments[segmentIdx].viewed = true;
|
||||||
|
if (idx === currentIdx) segments[segmentIdx].current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "8px",
|
width: "10px",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
background: "#222",
|
background: "#191919",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
boxShadow: "0 0 8px #2196f3cc",
|
boxShadow: "0 0 8px #2196f3cc",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewportImageIds[i].map((id, idx) => {
|
|
||||||
const loaded = !!metaData.get("imagePlaneModule", id);
|
|
||||||
const renderingEngine = renderingEngineRef.current;
|
|
||||||
let currentIdx = -1;
|
|
||||||
if (
|
|
||||||
renderingEngine &&
|
|
||||||
typeof renderingEngine.getViewport === "function"
|
|
||||||
) {
|
|
||||||
const viewport = renderingEngine.getViewport(`CT_${i}`);
|
|
||||||
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
|
|
||||||
currentIdx = viewport.getCurrentImageIdIndex();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isCurrent = idx === currentIdx;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: `${100 / viewportImageIds[i].length}%`,
|
|
||||||
background: isCurrent
|
|
||||||
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
|
|
||||||
: loaded
|
|
||||||
? "linear-gradient(to right, #4caf50 60%, #2196f3 100%)"
|
|
||||||
: "#444",
|
|
||||||
opacity: loaded ? 0.95 : 0.4,
|
|
||||||
transition: "background 0.3s, opacity 0.3s",
|
|
||||||
borderBottom:
|
|
||||||
idx < viewportImageIds[i].length - 1
|
|
||||||
? "1px solid #222"
|
|
||||||
: "none",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
title={
|
title={`Loaded ${loadedCount}/${total} • Viewed ${viewedCount}/${total}`}
|
||||||
isCurrent
|
onClick={(e) => {
|
||||||
? `Current image #${idx + 1}`
|
const bar = e.currentTarget as HTMLDivElement;
|
||||||
: loaded
|
const rect = bar.getBoundingClientRect();
|
||||||
? `Go to image #${idx + 1}`
|
const y = Math.min(rect.height, Math.max(0, e.clientY - rect.top));
|
||||||
: `Loading...`
|
const ratio = rect.height > 0 ? y / rect.height : 0;
|
||||||
}
|
const targetIndex = Math.min(total - 1, Math.max(0, Math.round(ratio * (total - 1))));
|
||||||
onClick={() => {
|
const targetViewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
|
||||||
const renderingEngine = renderingEngineRef.current;
|
|
||||||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
|
||||||
if (
|
if (
|
||||||
viewport &&
|
targetViewport &&
|
||||||
typeof viewport.setImageIdIndex === "function" &&
|
typeof targetViewport.setImageIdIndex === "function" &&
|
||||||
viewportModes[i] === "stack"
|
viewportModes[i] === "stack"
|
||||||
) {
|
) {
|
||||||
|
csUtilities.jumpToSlice(targetViewport.element, { imageIndex: targetIndex });
|
||||||
//viewport.setImageIdIndex(idx);
|
updateOverlay(i, targetViewport);
|
||||||
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx })
|
|
||||||
//viewport.render();
|
|
||||||
updateOverlay(i, viewport);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{segments.map((segment, idx) => {
|
||||||
|
let background = "#3f3f3f";
|
||||||
|
if (segment.loaded) background = "#2e7d32";
|
||||||
|
if (segment.viewed) background = "#1565c0";
|
||||||
|
if (segment.current) background = "#fbc02d";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: `${100 / segmentCount}%`,
|
||||||
|
minHeight: 1,
|
||||||
|
background,
|
||||||
|
opacity: segment.loaded || segment.viewed || segment.current ? 0.95 : 0.45,
|
||||||
|
borderBottom: idx < segmentCount - 1 ? "1px solid #222" : "none",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{currentIdx >= 0 && total > segmentCount && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: `${(currentIdx / Math.max(1, total - 1)) * 100}%`,
|
||||||
|
height: 2,
|
||||||
|
background: "#ffeb3b",
|
||||||
|
boxShadow: "0 0 4px #ffeb3b",
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{viewportModes[i] === "volume" && (() => {
|
{viewportModes[i] === "volume" && (() => {
|
||||||
@@ -4012,33 +4153,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
{/* Right-side stack panel is implemented in the main content flex container below */}
|
{/* Right-side stack panel is implemented in the main content flex container below */}
|
||||||
|
|
||||||
{/* Right-edge hover strip to reveal open button */}
|
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
|
||||||
<div
|
{!stackPanelOpen && (
|
||||||
onMouseEnter={() => { if (!stackPanelOpen) setShowOpenButtonHover(true); }}
|
|
||||||
onMouseLeave={() => { if (!stackPanelOpen) setShowOpenButtonHover(false); }}
|
|
||||||
style={{ position: 'absolute', right: 0, top: 0, height: '100%', width: 56, zIndex: 2400, pointerEvents: stackPanelOpen ? 'none' : 'auto' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Open button only visible when hover strip is active */}
|
|
||||||
{showOpenButtonHover && !stackPanelOpen && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setStackPanelOpen(true)}
|
onClick={() => setStackPanelOpen(true)}
|
||||||
onMouseEnter={() => setShowOpenButtonHover(true)}
|
|
||||||
onMouseLeave={() => setShowOpenButtonHover(false)}
|
|
||||||
aria-pressed={false}
|
aria-pressed={false}
|
||||||
title="Open stacks"
|
title="Open stacks"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 14,
|
right: 8,
|
||||||
top: '50%',
|
top: 76,
|
||||||
transform: 'translateY(-50%)',
|
zIndex: 120,
|
||||||
zIndex: 2700,
|
width: 24,
|
||||||
width: 28,
|
height: 42,
|
||||||
height: 48,
|
|
||||||
background: '#1976d2',
|
background: '#1976d2',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 6,
|
borderRadius: 5,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -4288,83 +4419,21 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
className="grid-menu-hover-container"
|
className="grid-menu-hover-container"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 8,
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: "auto",
|
width: "auto",
|
||||||
height: "48px",
|
height: "40px",
|
||||||
pointerEvents: "none", // Only the hit area and button will be interactive
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Transparent hit area to catch hover/focus */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: 80,
|
|
||||||
height: 14,
|
|
||||||
zIndex: 1,
|
|
||||||
pointerEvents: "auto",
|
|
||||||
background: "transparent",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setGridBtnActive(true)}
|
|
||||||
onFocus={() => setGridBtnActive(true)}
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
{/* --- Fullscreen button next to grid button --- */}
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
top: gridBtnActive ? "0" : "-43px",
|
|
||||||
opacity: gridBtnActive ? 1 : 0.5,
|
|
||||||
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
|
|
||||||
padding: "6px 16px",
|
|
||||||
background: "#222",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontWeight: "bold",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "15px",
|
|
||||||
marginLeft: "4px",
|
|
||||||
marginRight: "8px",
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
zIndex: 2,
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
title="Toggle Fullscreen"
|
|
||||||
onMouseEnter={() => setGridBtnActive(true)}
|
|
||||||
onMouseLeave={() => setGridBtnActive(false)}
|
|
||||||
onClick={() => {
|
|
||||||
const root = appRootRef.current;
|
|
||||||
if (!root) return;
|
|
||||||
if (document.fullscreenElement === root) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else if (root.requestFullscreen) {
|
|
||||||
root.requestFullscreen();
|
|
||||||
} else if ((root as any).webkitRequestFullscreen) {
|
|
||||||
(root as any).webkitRequestFullscreen();
|
|
||||||
} else if ((root as any).msRequestFullscreen) {
|
|
||||||
(root as any).msRequestFullscreen();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⛶ Fullscreen
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="grid-menu-btn"
|
className="grid-menu-btn"
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
|
||||||
top: gridBtnActive ? "0" : "-43px",
|
|
||||||
opacity: gridBtnActive ? 1 : 0.5,
|
|
||||||
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
|
|
||||||
padding: "6px 16px",
|
padding: "6px 16px",
|
||||||
background: "#222",
|
background: "#222",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
@@ -4377,11 +4446,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
display: "block",
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setGridBtnActive(true)}
|
|
||||||
onMouseLeave={() => setGridBtnActive(false)}
|
|
||||||
onBlur={() => setGridBtnActive(false)}
|
|
||||||
onClick={() => setViewportMenuOpen(open => !open)}
|
onClick={() => setViewportMenuOpen(open => !open)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user