Enhance stack handling by adding studyId support and improving descriptor parsing logic

This commit is contained in:
Ross
2025-09-15 13:48:25 +01:00
parent 2c9b0345c4
commit b66352f9b9
2 changed files with 134 additions and 107 deletions
+73 -49
View File
@@ -64,10 +64,11 @@ declare global {
} }
type StackEntry = { type StackEntry = {
imageIds: string[], imageIds: string[];
studyInstanceUID?: string, studyInstanceUID?: string;
name?: string, name?: string;
caseId?: string, caseId?: string;
studyId?: string;
}; };
@@ -2829,7 +2830,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
{/* Dropdown */} {/* Dropdown */}
{openDropdownIdx === i && ( {openDropdownIdx === i && (
<div <div
ref={el => { if (el) el.focus(); }} ref={el => {
if (el) el.focus();
}}
tabIndex={0} tabIndex={0}
style={{ style={{
position: "absolute", position: "absolute",
@@ -2840,16 +2843,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
borderRadius: "4px", borderRadius: "4px",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)", boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
zIndex: 100, zIndex: 100,
minWidth: 180, minWidth: 220,
maxHeight: 220, maxHeight: 260,
overflowY: "auto", overflowY: "auto",
overscrollBehavior: "contain", overscrollBehavior: "contain",
scrollbarWidth: "thin", scrollbarWidth: "thin",
padding: 6,
}} }}
role="listbox" role="listbox"
onMouseDown={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}
onWheel={e => { /* trap handled elsewhere */ }}
onBlur={e => { onBlur={e => {
// close when focus leaves
setTimeout(() => { setTimeout(() => {
if (!(e.currentTarget as HTMLElement).contains(document.activeElement)) { if (!(e.currentTarget as HTMLElement).contains(document.activeElement)) {
setOpenDropdownIdx(null); setOpenDropdownIdx(null);
@@ -2857,48 +2861,68 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}, 0); }, 0);
}} }}
> >
{availableStacks.map((stackObj, idx) => { {/* Group stacks by studyId (use 'Unspecified' for missing) */}
const imageIds = (stackObj && Array.isArray((stackObj as any).imageIds)) ? (stackObj as any).imageIds : []; {(() => {
const label = (stackObj && ((stackObj as any).name || (stackObj as any).caseId)) const groups: Record<string, { idx: number; stack: any }[]> = {};
? ((stackObj as any).name || `Case: ${(stackObj as any).caseId}`) availableStacks.forEach((stackObj, idx) => {
: `Stack ${idx + 1}`; const studyKey = (stackObj as any).studyId || (stackObj as any).studyInstanceUID || "Unspecified";
const count = imageIds.length; if (!groups[studyKey]) groups[studyKey] = [];
const invalid = !Array.isArray(imageIds) || count === 0; groups[studyKey].push({ idx, stack: stackObj });
});
return ( return Object.keys(groups).map(studyKey => (
<button <div key={studyKey} style={{ marginBottom: 6 }}>
key={idx} <div style={{ fontSize: 12, color: "#aaa", padding: "4px 8px", fontWeight: 700 }}>
role="option" {studyKey === "Unspecified" ? "Unspecified study" : `Study: ${studyKey}`}
aria-selected={false} </div>
title={invalid ? `Invalid stack descriptor` : `${label}${count} image${count === 1 ? '' : 's'}${(stackObj as any)?.caseId ? ` — case ${(stackObj as any).caseId}` : ''}`} <div>
style={{ {groups[studyKey].map(({ idx, stack }) => {
display: "flex", const imageIds = Array.isArray((stack as any).imageIds) ? (stack as any).imageIds : [];
flexDirection: "column", const label = (stack && ((stack as any).name || (stack as any).caseId))
alignItems: "flex-start", ? ((stack as any).name || `Case: ${(stack as any).caseId}`)
width: "100%", : `Stack ${idx + 1}`;
padding: "8px 12px", const count = imageIds.length;
background: "transparent", const invalid = !Array.isArray(imageIds) || count === 0;
color: "#fff",
border: "none", return (
textAlign: "left", <button
cursor: invalid ? "not-allowed" : "pointer", key={idx}
}} role="option"
onClick={(e) => { aria-selected={false}
e.stopPropagation(); title={invalid ? `Invalid stack descriptor` : `${label}${count} image${count === 1 ? '' : 's'}${(stack as any)?.caseId ? ` — case ${(stack as any).caseId}` : ''}`}
if (invalid) { style={{
console.warn(`Attempted to select invalid stack at index ${idx}`, stackObj); display: "flex",
return; flexDirection: "column",
} alignItems: "flex-start",
// Use existing handler to load the stack into this viewport width: "100%",
handleLoadStack(i, idx).catch(err => console.error("Failed to load stack:", err)); padding: "8px 12px",
setOpenDropdownIdx(null); background: "transparent",
}} color: "#fff",
> border: "none",
<div style={{ fontWeight: "600", fontSize: 13 }}>{label}</div> textAlign: "left",
<div style={{ fontSize: 12, opacity: 0.7 }}>{count} image{count === 1 ? '' : 's'}</div> cursor: invalid ? "not-allowed" : "pointer",
</button> }}
); onClick={(e) => {
})} e.stopPropagation();
if (invalid) {
console.warn(`Attempted to select invalid stack at index ${idx}`, stack);
return;
}
handleLoadStack(i, idx).catch(err => console.error("Failed to load stack:", err));
setOpenDropdownIdx(null);
}}
>
<div style={{ fontWeight: "600", fontSize: 13 }}>{label}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>
{count} image{count === 1 ? '' : 's'}{(stack as any)?.caseId ? ` • case ${(stack as any).caseId}` : ''}
</div>
</button>
);
})}
</div>
</div>
));
})()}
</div> </div>
)} )}
</div> </div>
+61 -58
View File
@@ -41,73 +41,76 @@ export function mountDicomViewers() {
const dataImages = container.getAttribute('data-images'); const dataImages = container.getAttribute('data-images');
const parseToDescriptors = (parsed: any): Array<any> => { const parseToDescriptors = (parsed: any): Array<any> => {
// Return array of descriptors: either simple string[] => { imageIds: string[] } // Return array of descriptors: either simple string[] => { imageIds: string[] }
// or objects: { imageIds: string[], name?, caseId? } // or objects: { imageIds: string[], name?, caseId?, studyId? }
if (!parsed) return []; if (!parsed) return [];
// If top-level is an array of strings -> single unnamed stack // If top-level is an array of strings -> single unnamed stack
if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") { if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") {
return [toDescriptor(parsed)]; return [toDescriptor(parsed)];
} }
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const out: any[] = []; const out: any[] = [];
parsed.forEach((item, itemIdx) => { parsed.forEach((item, itemIdx) => {
// Case object with nested stacks: { caseId, stacks: [ { name, imageIds } ] } // Case object with nested stacks: { caseId, studyId, stacks: [ { name, imageIds } ] }
if (item && typeof item === "object" && Array.isArray(item.stacks)) { if (item && typeof item === "object" && Array.isArray(item.stacks)) {
const caseId = item.caseId || item.caseUID || item.case || undefined; const caseId = item.caseId || item.caseUID || item.case || undefined;
item.stacks.forEach((s: any, sIdx: number) => { const studyId = item.studyId || item.studyUID || item.studyInstanceUID || undefined;
let imageIds: string[] = []; item.stacks.forEach((s: any, sIdx: number) => {
if (Array.isArray(s)) imageIds = s; let imageIds: string[] = [];
else if (Array.isArray(s.imageIds)) imageIds = s.imageIds; if (Array.isArray(s)) imageIds = s;
else if (Array.isArray(s.i)) imageIds = s.i; else if (Array.isArray(s.imageIds)) imageIds = s.imageIds;
else if (Array.isArray(s.i)) imageIds = s.i;
// Validation // Validation
if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every(x => typeof x === "string")) { if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every(x => typeof x === "string")) {
console.warn(`data-named-stacks: skipping invalid nested stack at parsed[${itemIdx}].stacks[${sIdx}]`, s); console.warn(`data-named-stacks: skipping invalid nested stack at parsed[${itemIdx}].stacks[${sIdx}]`, s);
return;
}
out.push({
imageIds: toWadouri(imageIds),
name: s.name || s.label || undefined,
caseId,
});
});
return; return;
} }
// Stack object: { imageIds, i, name, caseId } out.push({
if (item && typeof item === 'object' && (Array.isArray(item.imageIds) || Array.isArray(item.i))) { imageIds: toWadouri(imageIds),
const imageIds = Array.isArray(item.imageIds) ? item.imageIds : item.i; name: s.name || s.label || undefined,
caseId,
if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every(x => typeof x === "string")) { studyId: s.studyId || studyId,
console.warn(`data-named-stacks: skipping invalid stack object at parsed[${itemIdx}]`, item); });
return;
}
out.push({
imageIds: toWadouri(imageIds),
name: item.name || item.label || undefined,
caseId: item.caseId || item.caseUID || undefined,
});
return;
}
// Plain array (stack)
if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") {
out.push(toDescriptor(item));
return;
}
// Unknown / invalid entry
console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item);
}); });
return out; return;
} }
return []; // Stack object: { imageIds, i, name, caseId, studyId }
}; if (item && typeof item === 'object' && (Array.isArray(item.imageIds) || Array.isArray(item.i))) {
const imageIds = Array.isArray(item.imageIds) ? item.imageIds : item.i;
if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every(x => typeof x === "string")) {
console.warn(`data-named-stacks: skipping invalid stack object at parsed[${itemIdx}]`, item);
return;
}
out.push({
imageIds: toWadouri(imageIds),
name: item.name || item.label || undefined,
caseId: item.caseId || item.caseUID || undefined,
studyId: item.studyId || item.studyUID || item.studyInstanceUID || undefined,
});
return;
}
// Plain array (stack)
if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") {
out.push(toDescriptor(item));
return;
}
// Unknown / invalid entry
console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item);
});
return out;
}
return [];
};
if (dataNamed) { if (dataNamed) {
let parsed; let parsed;