Refactor DICOM viewer mounting logic and enhance container management
- Introduced a new `mountContainer` function to handle individual container mounting and unmounting. - Added `shouldMountContainer` to determine if a container should be mounted based on provided options. - Enhanced `parseToDescriptors` to improve parsing of image stack data, supporting both named stacks and plain arrays. - Updated `mountDicomViewers` to utilize the new mounting logic and streamline container processing. - Exposed `remountDicomViewer` globally for dynamic remounting of specific containers. - Removed redundant debug logging and improved error handling for invalid data.
This commit is contained in:
+202
-322
@@ -30,7 +30,6 @@ declare global {
|
|||||||
cornerstone3dTools: typeof cornerstoneTools;
|
cornerstone3dTools: typeof cornerstoneTools;
|
||||||
cornerstone3d: typeof cornerstone3d;
|
cornerstone3d: typeof cornerstone3d;
|
||||||
cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader;
|
cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader;
|
||||||
__DV3D_THUMB_VERBOSE__?: boolean;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,16 +160,8 @@ const TOOL_OPTIONS = [
|
|||||||
{ label: "Reference Cursors", value: ReferenceCursors.toolName, icon: "⦿" },
|
{ label: "Reference Cursors", value: ReferenceCursors.toolName, icon: "⦿" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THUMB_DEBUG_BUFFER_MAX = 500;
|
|
||||||
const THUMB_CAPTURE_MAX_RETRIES = 3;
|
const THUMB_CAPTURE_MAX_RETRIES = 3;
|
||||||
const THUMB_CAPTURE_RETRY_BASE_MS = 220;
|
const THUMB_CAPTURE_RETRY_BASE_MS = 220;
|
||||||
const THUMB_DEBUG_HIGH_FREQ_EVENTS = new Set([
|
|
||||||
'stackThumbnails-state',
|
|
||||||
'thumbnail-generation-check',
|
|
||||||
'thumbnail-candidate',
|
|
||||||
'thumbnail-render-source',
|
|
||||||
'thumbnail-img-load',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getToolIcon(toolName?: string) {
|
function getToolIcon(toolName?: string) {
|
||||||
if (!toolName) return '🔖';
|
if (!toolName) return '🔖';
|
||||||
@@ -184,21 +175,6 @@ function stripWadouriPrefix(imageId?: string): string {
|
|||||||
return imageId.replace(/^wadouri:+/, "").replace(/^:+/, "");
|
return imageId.replace(/^wadouri:+/, "").replace(/^:+/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateForDebug(value: string, maxLen = 180): string {
|
|
||||||
if (!value) return value;
|
|
||||||
if (value.length <= maxLen) return value;
|
|
||||||
return `${value.slice(0, maxLen)}...(${value.length} chars)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyDicomThumbnailSource(src?: string | null): boolean {
|
|
||||||
if (!src) return false;
|
|
||||||
const lower = src.toLowerCase();
|
|
||||||
if (lower.startsWith('wadouri:')) return true;
|
|
||||||
if (lower.includes('.dcm')) return true;
|
|
||||||
if (lower.includes('/dicom') || lower.includes('/wado')) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean {
|
function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean {
|
||||||
try {
|
try {
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings);
|
const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings);
|
||||||
@@ -322,6 +298,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
||||||
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
||||||
const [draggedStackIdx, setDraggedStackIdx] = useState<number | null>(null);
|
const [draggedStackIdx, setDraggedStackIdx] = useState<number | null>(null);
|
||||||
|
const [externalDragActive, setExternalDragActive] = useState(false);
|
||||||
const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
|
const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
|
||||||
// After your useState for viewportImageIds:
|
// After your useState for viewportImageIds:
|
||||||
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
|
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
|
||||||
@@ -353,59 +330,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
|
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
|
||||||
const stackThumbnailsRef = useRef<Record<number, string>>({});
|
const stackThumbnailsRef = useRef<Record<number, string>>({});
|
||||||
const thumbRequestedRef = useRef<Record<number, boolean>>({});
|
const thumbRequestedRef = useRef<Record<number, boolean>>({});
|
||||||
const thumbDebugPrefix = `[DV3D Thumb][${container_id || 'default'}]`;
|
|
||||||
const thumbDebugBufferRef = useRef<Array<Record<string, unknown>>>([]);
|
|
||||||
const thumbDebugLastLogRef = useRef<Record<string, number>>({});
|
|
||||||
|
|
||||||
const thumbDebug = useCallback((event: string, details?: Record<string, unknown>) => {
|
|
||||||
try {
|
|
||||||
const payload = details || {};
|
|
||||||
const isHighFrequency = THUMB_DEBUG_HIGH_FREQ_EVENTS.has(event);
|
|
||||||
const stackPart = typeof payload.stackIdx === 'number' ? String(payload.stackIdx) : (typeof payload.idx === 'number' ? String(payload.idx) : 'global');
|
|
||||||
const key = `${event}:${stackPart}`;
|
|
||||||
const now = Date.now();
|
|
||||||
const last = thumbDebugLastLogRef.current[key] || 0;
|
|
||||||
const verbose = typeof window !== 'undefined' && !!window.__DV3D_THUMB_VERBOSE__;
|
|
||||||
|
|
||||||
if (isHighFrequency && !verbose && now - last < 2500) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbDebugLastLogRef.current[key] = now;
|
|
||||||
|
|
||||||
const entry = {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
event,
|
|
||||||
container: container_id || 'default',
|
|
||||||
path: typeof window !== 'undefined' ? window.location.pathname : '',
|
|
||||||
details: payload,
|
|
||||||
};
|
|
||||||
|
|
||||||
thumbDebugBufferRef.current.push(entry);
|
|
||||||
if (thumbDebugBufferRef.current.length > THUMB_DEBUG_BUFFER_MAX) {
|
|
||||||
thumbDebugBufferRef.current.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(thumbDebugPrefix, event, payload);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore debug logging failures
|
|
||||||
}
|
|
||||||
}, [thumbDebugPrefix, container_id]);
|
|
||||||
|
|
||||||
const setStackThumbnail = useCallback((stackIdx: number, value: string) => {
|
const setStackThumbnail = useCallback((stackIdx: number, value: string) => {
|
||||||
setStackThumbnails(prev => {
|
setStackThumbnails(prev => {
|
||||||
const previous = prev[stackIdx];
|
const previous = prev[stackIdx];
|
||||||
try {
|
|
||||||
console.debug(thumbDebugPrefix, 'setStackThumbnail', {
|
|
||||||
stackIdx,
|
|
||||||
previous: previous ? truncateForDebug(previous, 120) : null,
|
|
||||||
next: value ? truncateForDebug(value, 120) : null,
|
|
||||||
previousWasBlob: !!(previous && previous.startsWith('blob:')),
|
|
||||||
nextIsBlob: !!(value && value.startsWith('blob:')),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// ignore debug logging failures
|
|
||||||
}
|
|
||||||
if (previous && previous !== value && previous.startsWith('blob:')) {
|
if (previous && previous !== value && previous.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(previous);
|
URL.revokeObjectURL(previous);
|
||||||
@@ -417,18 +345,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
stackThumbnailsRef.current = next;
|
stackThumbnailsRef.current = next;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [thumbDebugPrefix]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stackThumbnailsRef.current = stackThumbnails;
|
stackThumbnailsRef.current = stackThumbnails;
|
||||||
const entries = Object.entries(stackThumbnails).map(([idx, value]) => ({
|
}, [stackThumbnails]);
|
||||||
idx: Number(idx),
|
|
||||||
value: truncateForDebug(value, 100),
|
|
||||||
isBlob: value.startsWith('blob:'),
|
|
||||||
isBroken: value === 'BROKEN',
|
|
||||||
}));
|
|
||||||
thumbDebug('stackThumbnails-state', { count: entries.length, entries });
|
|
||||||
}, [stackThumbnails, thumbDebug]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -813,23 +734,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
thumbDebug('imageStacks-loaded', {
|
|
||||||
descriptorCount: stacksRaw.length,
|
|
||||||
normalizedCount: normalized.length,
|
|
||||||
summary: normalized.map((stack, idx) => {
|
|
||||||
const ids = Array.isArray(stack.imageIds) ? stack.imageIds : [];
|
|
||||||
const mid = ids[Math.floor(ids.length / 2)] || ids[0] || '';
|
|
||||||
return {
|
|
||||||
idx,
|
|
||||||
name: stack.name || null,
|
|
||||||
caseId: stack.caseId || null,
|
|
||||||
studyId: stack.studyId || null,
|
|
||||||
imageCount: ids.length,
|
|
||||||
sampleImageId: truncateForDebug(mid, 180),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
setAvailableStacks(normalized);
|
setAvailableStacks(normalized);
|
||||||
|
|
||||||
// Seed viewports with first stack if present (preserve old behavior)
|
// Seed viewports with first stack if present (preserve old behavior)
|
||||||
@@ -842,15 +746,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Failed to load imageStacks:", err);
|
console.error("Failed to load imageStacks:", err);
|
||||||
thumbDebug('imageStacks-load-failed', {
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
setAvailableStacks([]);
|
setAvailableStacks([]);
|
||||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [thumbDebug]);
|
}, []);
|
||||||
|
|
||||||
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
||||||
const stackObj = availableStacks[stackIdx];
|
const stackObj = availableStacks[stackIdx];
|
||||||
@@ -934,32 +835,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null, retryAttempt = 0) => {
|
const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null, retryAttempt = 0) => {
|
||||||
if (!chosenImageId) {
|
if (!chosenImageId) {
|
||||||
console.warn('generateThumbnail: no chosenImageId for stack', stackIdx);
|
|
||||||
thumbDebug('generate-skip-no-image', { stackIdx });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// avoid regenerating
|
|
||||||
if (stackThumbnails[stackIdx]) {
|
|
||||||
console.debug('generateThumbnail: thumbnail already exists for', stackIdx);
|
|
||||||
thumbDebug('generate-skip-existing', {
|
|
||||||
stackIdx,
|
|
||||||
existing: truncateForDebug(stackThumbnails[stackIdx], 120),
|
|
||||||
});
|
|
||||||
// already have it
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (thumbRequestedRef.current[stackIdx]) {
|
|
||||||
console.debug('generateThumbnail: already requested for', stackIdx);
|
|
||||||
thumbDebug('generate-skip-requested', { stackIdx });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the chosen image is an external non-DICOM image, draw it to a canvas directly
|
if (stackThumbnails[stackIdx]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thumbRequestedRef.current[stackIdx]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isExternalImageId(chosenImageId)) {
|
if (isExternalImageId(chosenImageId)) {
|
||||||
thumbDebug('generate-external-start', {
|
|
||||||
stackIdx,
|
|
||||||
chosenImageId: truncateForDebug(chosenImageId, 180),
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
@@ -975,7 +861,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
canvas.height = size;
|
canvas.height = size;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// draw with aspect-fit
|
|
||||||
const ratio = Math.min(size / img.width, size / img.height);
|
const ratio = Math.min(size / img.width, size / img.height);
|
||||||
const w = img.width * ratio;
|
const w = img.width * ratio;
|
||||||
const h = img.height * ratio;
|
const h = img.height * ratio;
|
||||||
@@ -984,18 +869,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.fillRect(0, 0, size, size);
|
ctx.fillRect(0, 0, size, size);
|
||||||
ctx.drawImage(img, x, y, w, h);
|
ctx.drawImage(img, x, y, w, h);
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
|
||||||
setStackThumbnail(stackIdx, dataUrl);
|
|
||||||
} else {
|
} else {
|
||||||
setStackThumbnail(stackIdx, 'BROKEN');
|
setStackThumbnail(stackIdx, 'BROKEN');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('generateThumbnail: failed to generate thumbnail for external image', err);
|
|
||||||
thumbDebug('generate-external-failed', {
|
|
||||||
stackIdx,
|
|
||||||
chosenImageId: truncateForDebug(chosenImageId, 180),
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
setStackThumbnail(stackIdx, 'BROKEN');
|
setStackThumbnail(stackIdx, 'BROKEN');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1003,24 +881,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
const renderingEngine = renderingEngineRef.current;
|
const renderingEngine = renderingEngineRef.current;
|
||||||
if (!renderingEngine) {
|
if (!renderingEngine) {
|
||||||
console.warn('generateThumbnail: no renderingEngine yet for', stackIdx);
|
|
||||||
thumbDebug('generate-skip-no-engine', {
|
|
||||||
stackIdx,
|
|
||||||
chosenImageId: truncateForDebug(chosenImageId, 180),
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// mark requested once we are sure the rendering engine exists
|
|
||||||
thumbRequestedRef.current[stackIdx] = true;
|
thumbRequestedRef.current[stackIdx] = true;
|
||||||
console.debug('generateThumbnail: starting for', stackIdx, chosenImageId);
|
|
||||||
|
|
||||||
const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`;
|
const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`;
|
||||||
thumbDebug('generate-start', {
|
|
||||||
stackIdx,
|
|
||||||
chosenImageId: truncateForDebug(chosenImageId, 180),
|
|
||||||
viewportId,
|
|
||||||
retryAttempt,
|
|
||||||
});
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.style.width = '160px';
|
el.style.width = '160px';
|
||||||
el.style.height = '160px';
|
el.style.height = '160px';
|
||||||
@@ -1033,30 +899,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
renderingEngine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element: el });
|
renderingEngine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element: el });
|
||||||
const vp = renderingEngine.getViewport(viewportId);
|
const vp = renderingEngine.getViewport(viewportId);
|
||||||
if (!vp) {
|
if (!vp) {
|
||||||
console.warn('generateThumbnail: no viewport returned after enableElement for', viewportId);
|
|
||||||
thumbDebug('generate-failed-no-viewport', { stackIdx, viewportId });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Use single-image stack for thumbnail
|
|
||||||
try {
|
try {
|
||||||
vp.setStack([chosenImageId]);
|
vp.setStack([chosenImageId]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('generateThumbnail: vp.setStack failed', err);
|
// ignore setStack failures for fallback behavior
|
||||||
thumbDebug('generate-setStack-failed', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for IMAGE_RENDERED event on the temporary viewport element or timeout
|
await new Promise<boolean>(resolve => {
|
||||||
const canvasReady = await new Promise<boolean>(resolve => {
|
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
console.warn('generateThumbnail: IMAGE_RENDERED timeout for', viewportId);
|
|
||||||
thumbDebug('generate-render-timeout', { stackIdx, viewportId });
|
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -1065,26 +921,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
console.debug('generateThumbnail: IMAGE_RENDERED received for', viewportId);
|
|
||||||
thumbDebug('generate-rendered', { stackIdx, viewportId });
|
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (vp && vp.element) {
|
if (vp && vp.element) {
|
||||||
try {
|
vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any);
|
||||||
vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('generateThumbnail: failed to add IMAGE_RENDERED listener', err);
|
|
||||||
thumbDebug('generate-listener-failed', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Trigger a render in case it doesn't auto-render.
|
|
||||||
// vp.render may return undefined (not a Promise), so guard before calling .catch()
|
|
||||||
if (typeof vp.render === 'function') {
|
if (typeof vp.render === 'function') {
|
||||||
try {
|
try {
|
||||||
const res = vp.render();
|
const res = vp.render();
|
||||||
@@ -1092,60 +935,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
(res as any).catch(() => undefined);
|
(res as any).catch(() => undefined);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ignore render errors here
|
// ignore render errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
console.warn('generateThumbnail: error while waiting for render', err);
|
|
||||||
thumbDebug('generate-render-wait-error', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find canvas inside the temporary element or viewport element. Try multiple locations.
|
|
||||||
let canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
|
let canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.debug('generateThumbnail: no canvas found immediately, waiting briefly and retrying for', viewportId, 'canvasReady=', canvasReady);
|
|
||||||
thumbDebug('generate-canvas-missing-initial', { stackIdx, viewportId, canvasReady });
|
|
||||||
await new Promise(r => setTimeout(r, 150));
|
await new Promise(r => setTimeout(r, 150));
|
||||||
canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
|
canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
thumbDebug('generate-canvas-found', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
canvasWidth: canvas.width,
|
|
||||||
canvasHeight: canvas.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const readyForCapture = isCanvasLikelyReadyForThumbnail(canvas);
|
const readyForCapture = isCanvasLikelyReadyForThumbnail(canvas);
|
||||||
if (!readyForCapture) {
|
if (!readyForCapture && retryAttempt < THUMB_CAPTURE_MAX_RETRIES) {
|
||||||
if (retryAttempt < THUMB_CAPTURE_MAX_RETRIES) {
|
const retryDelayMs = THUMB_CAPTURE_RETRY_BASE_MS * (retryAttempt + 1);
|
||||||
const retryDelayMs = THUMB_CAPTURE_RETRY_BASE_MS * (retryAttempt + 1);
|
thumbRequestedRef.current[stackIdx] = false;
|
||||||
thumbDebug('generate-canvas-not-ready-retry', {
|
setTimeout(() => {
|
||||||
stackIdx,
|
generateThumbnail(stackIdx, chosenImageId, retryAttempt + 1);
|
||||||
viewportId,
|
}, retryDelayMs);
|
||||||
retryAttempt,
|
return;
|
||||||
retryDelayMs,
|
|
||||||
});
|
|
||||||
thumbRequestedRef.current[stackIdx] = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
generateThumbnail(stackIdx, chosenImageId, retryAttempt + 1);
|
|
||||||
}, retryDelayMs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbDebug('generate-canvas-not-ready-final-attempt', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
retryAttempt,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1160,87 +973,41 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
});
|
});
|
||||||
if (blobUrl) {
|
if (blobUrl) {
|
||||||
thumbDebug('generate-toBlob-success', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
blobUrl: truncateForDebug(blobUrl, 120),
|
|
||||||
});
|
|
||||||
setStackThumbnail(stackIdx, blobUrl);
|
setStackThumbnail(stackIdx, blobUrl);
|
||||||
} else {
|
} else {
|
||||||
thumbDebug('generate-toBlob-empty-fallback-dataUrl', { stackIdx, viewportId });
|
|
||||||
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
|
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
thumbDebug('generate-no-toBlob-fallback-dataUrl', { stackIdx, viewportId });
|
|
||||||
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
|
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
|
||||||
}
|
}
|
||||||
console.debug('generateThumbnail: thumbnail captured for stack', stackIdx);
|
|
||||||
thumbDebug('generate-success', { stackIdx, viewportId });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('generateThumbnail: toDataURL failed', err);
|
|
||||||
thumbDebug('generate-data-extract-failed', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
// Mark as broken so UI shows placeholder instead of attempting to load raw DICOM URL
|
|
||||||
setStackThumbnail(stackIdx, 'BROKEN');
|
setStackThumbnail(stackIdx, 'BROKEN');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('generateThumbnail: no canvas found after render for', viewportId, 'canvasReady=', canvasReady, 'vp.element=', !!(vp && vp.element));
|
|
||||||
thumbDebug('generate-failed-no-canvas', {
|
|
||||||
stackIdx,
|
|
||||||
viewportId,
|
|
||||||
canvasReady,
|
|
||||||
hasViewportElement: !!(vp && vp.element),
|
|
||||||
});
|
|
||||||
// Mark as broken so we don't attempt to use raw .dcm URL as <img> src
|
|
||||||
setStackThumbnail(stackIdx, 'BROKEN');
|
setStackThumbnail(stackIdx, 'BROKEN');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// log and mark broken so UI shows placeholder
|
|
||||||
console.warn('Thumbnail generation failed for', chosenImageId, err);
|
|
||||||
thumbDebug('generate-failed', {
|
|
||||||
stackIdx,
|
|
||||||
chosenImageId: truncateForDebug(chosenImageId || '', 180),
|
|
||||||
error: String((err as any)?.message || err),
|
|
||||||
});
|
|
||||||
try { setStackThumbnail(stackIdx, 'BROKEN'); } catch (e) { /* ignore */ }
|
try { setStackThumbnail(stackIdx, 'BROKEN'); } catch (e) { /* ignore */ }
|
||||||
} finally {
|
} finally {
|
||||||
try { renderingEngine.disableElement(viewportId); } catch (e) {
|
try { renderingEngine.disableElement(viewportId); } catch (e) { /* ignore */ }
|
||||||
console.warn('Failed to disable thumbnail element:', e);
|
|
||||||
}
|
|
||||||
thumbDebug('generate-cleanup', { stackIdx, viewportId });
|
|
||||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||||
}
|
}
|
||||||
}, [container_id, stackThumbnails, setStackThumbnail, thumbDebug]);
|
}, [container_id, stackThumbnails, setStackThumbnail]);
|
||||||
|
|
||||||
// Kick off thumbnail generation when stacks change
|
// Kick off thumbnail generation when stacks change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!availableStacks || availableStacks.length === 0) return;
|
if (!availableStacks || availableStacks.length === 0) return;
|
||||||
thumbDebug('thumbnail-generation-check', {
|
|
||||||
stackCount: availableStacks.length,
|
|
||||||
requested: { ...thumbRequestedRef.current },
|
|
||||||
thumbnailKeys: Object.keys(stackThumbnails),
|
|
||||||
});
|
|
||||||
const renderingEngine = renderingEngineRef.current;
|
const renderingEngine = renderingEngineRef.current;
|
||||||
if (!renderingEngine) return; // wait until rendering engine exists
|
if (!renderingEngine) return; // wait until rendering engine exists
|
||||||
availableStacks.forEach((stack, idx) => {
|
availableStacks.forEach((stack, idx) => {
|
||||||
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
|
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
|
||||||
const midIndex = Math.floor(imageIds.length / 2);
|
const midIndex = Math.floor(imageIds.length / 2);
|
||||||
const chosen = imageIds[midIndex] || imageIds[0] || null;
|
const chosen = imageIds[midIndex] || imageIds[0] || null;
|
||||||
thumbDebug('thumbnail-candidate', {
|
|
||||||
idx,
|
|
||||||
imageCount: imageIds.length,
|
|
||||||
chosen: truncateForDebug(chosen || '', 180),
|
|
||||||
hasThumbnail: !!stackThumbnails[idx],
|
|
||||||
requested: !!thumbRequestedRef.current[idx],
|
|
||||||
});
|
|
||||||
if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
|
if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
|
||||||
generateThumbnail(idx, chosen as string | null);
|
generateThumbnail(idx, chosen as string | null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [availableStacks, generateThumbnail, stackThumbnails, thumbDebug]);
|
}, [availableStacks, generateThumbnail, stackThumbnails]);
|
||||||
|
|
||||||
// Wrap setLoadedImageIds to also cache DICOM metadata
|
// Wrap setLoadedImageIds to also cache DICOM metadata
|
||||||
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => {
|
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => {
|
||||||
@@ -1539,18 +1306,25 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
|
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
|
||||||
// Convert FileList to array if needed
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
// Your logic to handle the files and load them as a stack
|
|
||||||
// For example, call your existing handler:
|
|
||||||
handleFilesSelected({ target: { files: fileArray } } as any);
|
handleFilesSelected({ target: { files: fileArray } } as any);
|
||||||
};
|
};
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
window.loadDicomStackFromFiles = undefined;
|
window.loadDicomStackFromFiles = undefined;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Clean up external drag state if the drag ends outside any viewer viewport.
|
||||||
|
useEffect(() => {
|
||||||
|
const onDocDragEnd = () => {
|
||||||
|
setExternalDragActive(false);
|
||||||
|
setDropPreview(null);
|
||||||
|
setDraggedStackIdx(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('dragend', onDocDragEnd);
|
||||||
|
return () => document.removeEventListener('dragend', onDocDragEnd);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [altPressed, setAltPressed] = useState(false);
|
const [altPressed, setAltPressed] = useState(false);
|
||||||
const [shiftPressed, setShiftPressed] = useState(false);
|
const [shiftPressed, setShiftPressed] = useState(false);
|
||||||
|
|
||||||
@@ -2187,6 +1961,113 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an external stack (from outside the viewer, e.g. a series block dragged
|
||||||
|
* from the page) into the given viewport, optionally splitting the grid.
|
||||||
|
* The stack is added to availableStacks so it appears in the side panel.
|
||||||
|
*/
|
||||||
|
const applyExternalDrop = async (
|
||||||
|
viewportIdx: number,
|
||||||
|
rawImageIds: string[],
|
||||||
|
name: string,
|
||||||
|
requestedZone: ViewportDropZone,
|
||||||
|
) => {
|
||||||
|
const imageIds = rawImageIds.map(url =>
|
||||||
|
url.startsWith('wadouri:') ? url : `wadouri:${url}`
|
||||||
|
);
|
||||||
|
if (imageIds.length === 0) return;
|
||||||
|
|
||||||
|
// Add the incoming stack to the panel and capture its index.
|
||||||
|
let newStackIdx = 0;
|
||||||
|
setAvailableStacks(prev => {
|
||||||
|
newStackIdx = prev.length;
|
||||||
|
return [...prev, { imageIds, name }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedZone = resolveDropZone(requestedZone);
|
||||||
|
if (resolvedZone === 'center') {
|
||||||
|
setLoadingState('displayset');
|
||||||
|
try {
|
||||||
|
setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; });
|
||||||
|
setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; });
|
||||||
|
setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; });
|
||||||
|
setActiveViewport(viewportIdx);
|
||||||
|
await setLoadedImageIdsAndCacheMeta(imageIds);
|
||||||
|
} finally {
|
||||||
|
setLoadingState(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldRows = viewportGrid.rows;
|
||||||
|
const oldCols = viewportGrid.cols;
|
||||||
|
const row = Math.floor(viewportIdx / oldCols);
|
||||||
|
const col = viewportIdx % oldCols;
|
||||||
|
const newRows = (resolvedZone === 'top' || resolvedZone === 'bottom') ? oldRows + 1 : oldRows;
|
||||||
|
const newCols = (resolvedZone === 'left' || resolvedZone === 'right') ? oldCols + 1 : oldCols;
|
||||||
|
|
||||||
|
if (newRows > 3 || newCols > 3 || newRows * newCols > MAX_VIEWPORTS) {
|
||||||
|
// Grid already full — just load into the target viewport.
|
||||||
|
setLoadingState('displayset');
|
||||||
|
try {
|
||||||
|
setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; });
|
||||||
|
setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; });
|
||||||
|
setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; });
|
||||||
|
setActiveViewport(viewportIdx);
|
||||||
|
await setLoadedImageIdsAndCacheMeta(imageIds);
|
||||||
|
} finally {
|
||||||
|
setLoadingState(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNewRow = resolvedZone === 'top' ? row : resolvedZone === 'bottom' ? row + 1 : row;
|
||||||
|
const targetNewCol = resolvedZone === 'left' ? col : resolvedZone === 'right' ? col + 1 : col;
|
||||||
|
const insertedViewportIndex = targetNewRow * newCols + targetNewCol;
|
||||||
|
|
||||||
|
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
|
||||||
|
const nextModes = Array(MAX_VIEWPORTS).fill('stack') as Array<'stack' | 'volume'>;
|
||||||
|
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
|
||||||
|
|
||||||
|
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
|
||||||
|
const oldRow = Math.floor(oldIdx / oldCols);
|
||||||
|
const oldCol = oldIdx % oldCols;
|
||||||
|
let mappedRow = oldRow;
|
||||||
|
let mappedCol = oldCol;
|
||||||
|
if (resolvedZone === 'left' || resolvedZone === 'right') {
|
||||||
|
if (oldRow === row) {
|
||||||
|
mappedCol = oldCol < col ? oldCol : oldCol > col ? oldCol + 1 : (resolvedZone === 'left' ? oldCol + 1 : oldCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedZone === 'top' || resolvedZone === 'bottom') {
|
||||||
|
if (oldCol === col) {
|
||||||
|
mappedRow = oldRow < row ? oldRow : oldRow > row ? oldRow + 1 : (resolvedZone === 'top' ? oldRow + 1 : oldRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mappedIdx = mappedRow * newCols + mappedCol;
|
||||||
|
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
|
||||||
|
nextModes[mappedIdx] = viewportModes[oldIdx] || 'stack';
|
||||||
|
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextImageIds[insertedViewportIndex] = imageIds;
|
||||||
|
nextModes[insertedViewportIndex] = 'stack';
|
||||||
|
nextSelected[insertedViewportIndex] = newStackIdx;
|
||||||
|
|
||||||
|
setLoadingState('displayset');
|
||||||
|
try {
|
||||||
|
saveVisibleViewportState();
|
||||||
|
setViewportGrid({ rows: newRows, cols: newCols });
|
||||||
|
setViewportImageIds(nextImageIds);
|
||||||
|
setViewportModes(nextModes);
|
||||||
|
setSelectedStackIdx(nextSelected);
|
||||||
|
setActiveViewport(insertedViewportIndex);
|
||||||
|
await setLoadedImageIdsAndCacheMeta(imageIds);
|
||||||
|
} finally {
|
||||||
|
setLoadingState(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
|
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
|
||||||
const stackObj = availableStacks[stackIdx];
|
const stackObj = availableStacks[stackIdx];
|
||||||
if (!stackObj) return;
|
if (!stackObj) return;
|
||||||
@@ -3318,6 +3199,33 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
setLoadedImageIdsAndCacheMeta(imageIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an external stack (raw media URLs or wadouri: IDs) into the active
|
||||||
|
* viewport of this viewer, adding it to the side-panel stack list.
|
||||||
|
* This is the preferred API for series-block drag-to-viewer interactions.
|
||||||
|
*/
|
||||||
|
window[`loadExternalStack_${apiKey}`] = async (rawImageIds: string[], name?: string) => {
|
||||||
|
if (!Array.isArray(rawImageIds) || rawImageIds.length === 0) return;
|
||||||
|
await applyExternalDrop(
|
||||||
|
activeViewport ?? 0,
|
||||||
|
rawImageIds,
|
||||||
|
name || 'Series',
|
||||||
|
'center',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an external stack into a new split viewport. If the grid is already
|
||||||
|
* at its maximum size the stack is loaded into the active viewport instead.
|
||||||
|
*/
|
||||||
|
window[`loadExternalStackNewViewport_${apiKey}`] = async (rawImageIds: string[], name?: string) => {
|
||||||
|
if (!Array.isArray(rawImageIds) || rawImageIds.length === 0) return;
|
||||||
|
const vp = activeViewport ?? 0;
|
||||||
|
// Use 'right' as the default split direction; applyExternalDrop will fall
|
||||||
|
// back to center if the grid is already full.
|
||||||
|
await applyExternalDrop(vp, rawImageIds, name || 'Series', 'right');
|
||||||
|
};
|
||||||
|
|
||||||
// Export the current viewer state as JSON
|
// Export the current viewer state as JSON
|
||||||
window[`exportViewerState_${apiKey}`] = () => {
|
window[`exportViewerState_${apiKey}`] = () => {
|
||||||
const numActive = viewportGrid.rows * viewportGrid.cols;
|
const numActive = viewportGrid.rows * viewportGrid.cols;
|
||||||
@@ -3794,20 +3702,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
updateOverlay(activeViewport ?? 0, viewport);
|
updateOverlay(activeViewport ?? 0, viewport);
|
||||||
};
|
};
|
||||||
|
|
||||||
window[`exportThumbDebug_${apiKey}`] = () => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(thumbDebugBufferRef.current, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return '[]';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window[`clearThumbDebug_${apiKey}`] = () => {
|
|
||||||
thumbDebugBufferRef.current = [];
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
return () => {
|
return () => {
|
||||||
window[`exportViewerState_${apiKey}`] = undefined;
|
window[`exportViewerState_${apiKey}`] = undefined;
|
||||||
@@ -3822,10 +3716,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
window[`truncateStack_${apiKey}`] = undefined;
|
window[`truncateStack_${apiKey}`] = undefined;
|
||||||
window[`getCurrentStackPosition_${apiKey}`] = undefined;
|
window[`getCurrentStackPosition_${apiKey}`] = undefined;
|
||||||
window[`loadAdditionalStack_${apiKey}`] = undefined;
|
window[`loadAdditionalStack_${apiKey}`] = undefined;
|
||||||
|
window[`loadExternalStack_${apiKey}`] = undefined;
|
||||||
|
window[`loadExternalStackNewViewport_${apiKey}`] = undefined;
|
||||||
window[`getViewport_${apiKey}`] = undefined;
|
window[`getViewport_${apiKey}`] = undefined;
|
||||||
window[`getAllViewports_${apiKey}`] = undefined;
|
window[`getAllViewports_${apiKey}`] = undefined;
|
||||||
window[`exportThumbDebug_${apiKey}`] = undefined;
|
|
||||||
window[`clearThumbDebug_${apiKey}`] = undefined;
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
viewportGrid,
|
viewportGrid,
|
||||||
@@ -3951,15 +3845,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}}
|
}}
|
||||||
onDoubleClick={() => handleDoubleClick(i)}
|
onDoubleClick={() => handleDoubleClick(i)}
|
||||||
onDragEnter={e => {
|
onDragEnter={e => {
|
||||||
const idxStr = e.dataTransfer.getData('text/stack-idx');
|
const types = e.dataTransfer.types;
|
||||||
if (!idxStr) return;
|
const hasInternal = types.includes('text/stack-idx');
|
||||||
|
const hasExternal = types.includes('text/dv3d-imageids');
|
||||||
|
if (!hasInternal && !hasExternal) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (hasExternal) setExternalDragActive(true);
|
||||||
const zone = getDropZoneFromPointer(e);
|
const zone = getDropZoneFromPointer(e);
|
||||||
setDropPreview({ viewportIdx: i, zone });
|
setDropPreview({ viewportIdx: i, zone });
|
||||||
}}
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
const idxStr = e.dataTransfer.getData('text/stack-idx');
|
const types = e.dataTransfer.types;
|
||||||
if (!idxStr) return;
|
const hasInternal = types.includes('text/stack-idx');
|
||||||
|
const hasExternal = types.includes('text/dv3d-imageids');
|
||||||
|
if (!hasInternal && !hasExternal) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const zone = getDropZoneFromPointer(e);
|
const zone = getDropZoneFromPointer(e);
|
||||||
const resolved = resolveDropZone(zone);
|
const resolved = resolveDropZone(zone);
|
||||||
@@ -3974,6 +3873,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}}
|
}}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const externalImageIdsStr = e.dataTransfer.getData('text/dv3d-imageids');
|
||||||
|
if (externalImageIdsStr) {
|
||||||
|
const stackName = e.dataTransfer.getData('text/dv3d-stack-name') || 'Series';
|
||||||
|
try {
|
||||||
|
const rawIds: string[] = JSON.parse(externalImageIdsStr);
|
||||||
|
const zone = getDropZoneFromPointer(e);
|
||||||
|
applyExternalDrop(i, rawIds, stackName, zone).catch(err => {
|
||||||
|
console.error('Failed to apply external drop:', err);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Invalid text/dv3d-imageids payload:', err);
|
||||||
|
}
|
||||||
|
setDropPreview(null);
|
||||||
|
setExternalDragActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const idxStr = e.dataTransfer.getData('text/stack-idx');
|
const idxStr = e.dataTransfer.getData('text/stack-idx');
|
||||||
const stackIdx = parseInt(idxStr, 10);
|
const stackIdx = parseInt(idxStr, 10);
|
||||||
if (!isNaN(stackIdx)) {
|
if (!isNaN(stackIdx)) {
|
||||||
@@ -3984,9 +3899,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
setDraggedStackIdx(null);
|
setDraggedStackIdx(null);
|
||||||
|
setExternalDragActive(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{draggedStackIdx !== null && currentPreviewZone && (
|
{(draggedStackIdx !== null || externalDragActive) && currentPreviewZone && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -5632,16 +5548,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
// If generation explicitly failed, treat as missing so placeholder is shown
|
// If generation explicitly failed, treat as missing so placeholder is shown
|
||||||
const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
|
const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
|
||||||
const src = generatedValid || thumbnailUrl || null;
|
const src = generatedValid || thumbnailUrl || null;
|
||||||
thumbDebug('thumbnail-render-source', {
|
|
||||||
idx,
|
|
||||||
label,
|
|
||||||
generated: generated ? truncateForDebug(generated, 120) : null,
|
|
||||||
generatedValid: generatedValid ? truncateForDebug(generatedValid, 120) : null,
|
|
||||||
fallback: thumbnailUrl ? truncateForDebug(thumbnailUrl, 180) : null,
|
|
||||||
chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null,
|
|
||||||
src: src ? truncateForDebug(src, 180) : null,
|
|
||||||
requested: !!thumbRequestedRef.current[idx],
|
|
||||||
});
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>);
|
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>);
|
||||||
}
|
}
|
||||||
@@ -5651,31 +5557,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
src={src}
|
src={src}
|
||||||
alt={label}
|
alt={label}
|
||||||
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
|
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
|
||||||
onLoad={(e) => {
|
|
||||||
const el = e.currentTarget;
|
|
||||||
thumbDebug('thumbnail-img-load', {
|
|
||||||
idx,
|
|
||||||
label,
|
|
||||||
src: truncateForDebug(src, 180),
|
|
||||||
naturalWidth: el.naturalWidth,
|
|
||||||
naturalHeight: el.naturalHeight,
|
|
||||||
clientWidth: el.clientWidth,
|
|
||||||
clientHeight: el.clientHeight,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onError={() => {
|
onError={() => {
|
||||||
console.warn('Thumbnail <img> failed to load for stack', idx, src);
|
console.warn('Thumbnail <img> failed to load for stack', idx, src);
|
||||||
const usedGeneratedImage = !!generatedValid;
|
const usedGeneratedImage = !!generatedValid;
|
||||||
const likelyDicomFallback = !usedGeneratedImage && isLikelyDicomThumbnailSource(src);
|
|
||||||
thumbDebug('thumbnail-img-error', {
|
|
||||||
idx,
|
|
||||||
label,
|
|
||||||
src: truncateForDebug(src, 180),
|
|
||||||
usedGeneratedImage,
|
|
||||||
likelyDicomFallback,
|
|
||||||
requested: !!thumbRequestedRef.current[idx],
|
|
||||||
chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (usedGeneratedImage) {
|
if (usedGeneratedImage) {
|
||||||
// Generated image failed: mark as broken to avoid endless reload attempts.
|
// Generated image failed: mark as broken to avoid endless reload attempts.
|
||||||
@@ -5686,10 +5570,6 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
// Fallback URL failed (often raw DICOM). Do not mark BROKEN yet;
|
// Fallback URL failed (often raw DICOM). Do not mark BROKEN yet;
|
||||||
// request generation and keep the thumbnail pipeline alive.
|
// request generation and keep the thumbnail pipeline alive.
|
||||||
if (!thumbRequestedRef.current[idx]) {
|
if (!thumbRequestedRef.current[idx]) {
|
||||||
thumbDebug('thumbnail-img-error-trigger-generate', {
|
|
||||||
idx,
|
|
||||||
chosenImage: chosenImage ? truncateForDebug(chosenImage, 180) : null,
|
|
||||||
});
|
|
||||||
generateThumbnail(idx, chosenImage as string | null);
|
generateThumbnail(idx, chosenImage as string | null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
+140
-150
@@ -1,10 +1,34 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import Nifti from './Nifti.tsx'
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { createImageIds, availableImageIds } from "./lib/createImageIds.ts"
|
import { availableImageIds } from "./lib/createImageIds.ts"
|
||||||
|
|
||||||
|
type MountOptions = {
|
||||||
|
force?: boolean;
|
||||||
|
containerIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewerDescriptor = {
|
||||||
|
imageIds: string[];
|
||||||
|
name?: string;
|
||||||
|
caseId?: string;
|
||||||
|
studyId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootRecord = {
|
||||||
|
root: Root;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootRegistry = new WeakMap<HTMLElement, RootRecord>();
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
mountDicomViewers?: (options?: MountOptions) => void;
|
||||||
|
remountDicomViewer?: (containerId: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize incoming image ids/URLs and ensure DICOM entries use a valid wadouri: prefix.
|
// Normalize incoming image ids/URLs and ensure DICOM entries use a valid wadouri: prefix.
|
||||||
const normalizeImageId = (value: string) => {
|
const normalizeImageId = (value: string) => {
|
||||||
@@ -35,118 +59,137 @@ const toDescriptor = (arr: string[] | undefined) => {
|
|||||||
return { imageIds: toWadouri(arr) };
|
return { imageIds: toWadouri(arr) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mountDicomViewers() {
|
const parseToDescriptors = (parsed: any): Array<ViewerDescriptor> => {
|
||||||
// Test containers
|
|
||||||
const test_containers = document.querySelectorAll(".dicom-viewer-test-root");
|
|
||||||
test_containers.forEach((container) => {
|
|
||||||
const id = container.id || '';
|
|
||||||
const imageStacks = () => availableImageIds();
|
|
||||||
createRoot(container).render(
|
|
||||||
<App
|
|
||||||
container_id={id}
|
|
||||||
imageStacks={imageStacks}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main containers
|
|
||||||
const containers = document.querySelectorAll(".dicom-viewer-root");
|
|
||||||
containers.forEach((container) => {
|
|
||||||
const id = container.id || '';
|
|
||||||
const mountPrefix = `[DV3D Mount][${id || 'default'}]`;
|
|
||||||
let imageStacks: () => Promise<any[]>;
|
|
||||||
const isEmpty = container.getAttribute('data-empty') === 'true';
|
|
||||||
|
|
||||||
// New: support data-named-stacks (richer format) and fallback to data-images
|
|
||||||
const dataNamed = container.getAttribute('data-named-stacks');
|
|
||||||
const dataImages = container.getAttribute('data-images');
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.debug(mountPrefix, 'container-attrs', {
|
|
||||||
path: window.location.pathname,
|
|
||||||
isEmpty,
|
|
||||||
hasDataNamedStacks: !!dataNamed,
|
|
||||||
hasDataImages: !!dataImages,
|
|
||||||
dataNamedLength: dataNamed ? dataNamed.length : 0,
|
|
||||||
dataImagesLength: dataImages ? dataImages.length : 0,
|
|
||||||
autoCacheStack: container.getAttribute('data-auto-cache-stack'),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// ignore debug logging failures
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseToDescriptors = (parsed: any): Array<any> => {
|
|
||||||
// Return array of descriptors: either simple string[] => { imageIds: string[] }
|
|
||||||
// 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 (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[] = [];
|
return [];
|
||||||
parsed.forEach((item, itemIdx) => {
|
}
|
||||||
// Case object with nested stacks: { caseId, studyId, stacks: [ { name, imageIds } ] }
|
|
||||||
if (item && typeof item === "object" && Array.isArray(item.stacks)) {
|
|
||||||
const caseId = item.caseId || item.caseUID || item.case || undefined;
|
|
||||||
const studyId = item.studyId || item.studyUID || item.studyInstanceUID || undefined;
|
|
||||||
item.stacks.forEach((s: any, sIdx: number) => {
|
|
||||||
let imageIds: string[] = [];
|
|
||||||
if (Array.isArray(s)) imageIds = s;
|
|
||||||
else if (Array.isArray(s.imageIds)) imageIds = s.imageIds;
|
|
||||||
else if (Array.isArray(s.i)) imageIds = s.i;
|
|
||||||
|
|
||||||
// Validation
|
const out: Array<ViewerDescriptor> = [];
|
||||||
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);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push({
|
parsed.forEach((item, itemIdx) => {
|
||||||
imageIds: toWadouri(imageIds),
|
if (item && typeof item === "object" && Array.isArray(item.stacks)) {
|
||||||
name: s.name || s.label || undefined,
|
const caseId = item.caseId || item.caseUID || item.case || undefined;
|
||||||
caseId,
|
const studyId = item.studyId || item.studyUID || item.studyInstanceUID || undefined;
|
||||||
studyId: s.studyId || studyId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stack object: { imageIds, i, name, caseId, studyId }
|
item.stacks.forEach((s: any, sIdx: number) => {
|
||||||
if (item && typeof item === 'object' && (Array.isArray(item.imageIds) || Array.isArray(item.i))) {
|
let imageIds: string[] = [];
|
||||||
const imageIds = Array.isArray(item.imageIds) ? item.imageIds : item.i;
|
if (Array.isArray(s)) imageIds = s;
|
||||||
|
else if (Array.isArray(s.imageIds)) imageIds = s.imageIds;
|
||||||
|
else if (Array.isArray(s.i)) imageIds = s.i;
|
||||||
|
|
||||||
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 stack object at parsed[${itemIdx}]`, item);
|
console.warn(`data-named-stacks: skipping invalid nested stack at parsed[${itemIdx}].stacks[${sIdx}]`, s);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
imageIds: toWadouri(imageIds),
|
imageIds: toWadouri(imageIds),
|
||||||
name: item.name || item.label || undefined,
|
name: s.name || s.label || undefined,
|
||||||
caseId: item.caseId || item.caseUID || undefined,
|
caseId,
|
||||||
studyId: item.studyId || item.studyUID || item.studyInstanceUID || undefined,
|
studyId: s.studyId || studyId,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain array (stack)
|
out.push({
|
||||||
if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") {
|
imageIds: toWadouri(imageIds),
|
||||||
out.push(toDescriptor(item));
|
name: item.name || item.label || undefined,
|
||||||
return;
|
caseId: item.caseId || item.caseUID || undefined,
|
||||||
}
|
studyId: item.studyId || item.studyUID || item.studyInstanceUID || undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown / invalid entry
|
if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") {
|
||||||
console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item);
|
out.push(toDescriptor(item));
|
||||||
});
|
return;
|
||||||
return out;
|
}
|
||||||
|
|
||||||
|
console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMountSignature = (container: HTMLElement): string => {
|
||||||
|
return JSON.stringify({
|
||||||
|
id: container.id || '',
|
||||||
|
empty: container.getAttribute('data-empty') || '',
|
||||||
|
namedStacks: container.getAttribute('data-named-stacks') || '',
|
||||||
|
images: container.getAttribute('data-images') || '',
|
||||||
|
autoCache: container.getAttribute('data-auto-cache-stack') || '',
|
||||||
|
annotation: container.getAttribute('data-annotationjson') || '',
|
||||||
|
viewerState: container.getAttribute('data-viewerstate') || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldMountContainer = (container: HTMLElement, options?: MountOptions): boolean => {
|
||||||
|
if (!options?.containerIds || options.containerIds.length === 0) return true;
|
||||||
|
return options.containerIds.includes(container.id || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountContainer = (container: HTMLElement, imageStacks: () => Promise<any[]>, options?: MountOptions) => {
|
||||||
|
const signature = getMountSignature(container);
|
||||||
|
const existing = rootRegistry.get(container);
|
||||||
|
|
||||||
|
if (existing && !options?.force && existing.signature === signature) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
if (existing) {
|
||||||
|
existing.root.unmount();
|
||||||
|
rootRegistry.delete(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<App
|
||||||
|
container_id={container.id || ''}
|
||||||
|
imageStacks={imageStacks}
|
||||||
|
autoCacheStack={(container.getAttribute('data-auto-cache-stack') === "true" || container.getAttribute('data-auto-cache-stack') === "1")}
|
||||||
|
annotationJson={container.getAttribute('data-annotationjson') || undefined}
|
||||||
|
viewerState={container.getAttribute('data-viewerstate') || undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
rootRegistry.set(container, { root, signature });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function mountDicomViewers(options?: MountOptions) {
|
||||||
|
// Test containers
|
||||||
|
const test_containers = document.querySelectorAll(".dicom-viewer-test-root");
|
||||||
|
test_containers.forEach((container) => {
|
||||||
|
if (!shouldMountContainer(container as HTMLElement, options)) return;
|
||||||
|
const id = container.id || '';
|
||||||
|
const imageStacks = () => availableImageIds();
|
||||||
|
mountContainer(container as HTMLElement, imageStacks, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main containers
|
||||||
|
const containers = document.querySelectorAll(".dicom-viewer-root");
|
||||||
|
containers.forEach((container) => {
|
||||||
|
if (!shouldMountContainer(container as HTMLElement, options)) return;
|
||||||
|
|
||||||
|
let imageStacks: () => Promise<any[]>;
|
||||||
|
const isEmpty = container.getAttribute('data-empty') === 'true';
|
||||||
|
|
||||||
|
const dataNamed = container.getAttribute('data-named-stacks');
|
||||||
|
const dataImages = container.getAttribute('data-images');
|
||||||
|
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
imageStacks = () => Promise.resolve([] as any[]);
|
imageStacks = () => Promise.resolve([] as any[]);
|
||||||
} else if (dataNamed) {
|
} else if (dataNamed) {
|
||||||
@@ -158,25 +201,6 @@ export function mountDicomViewers() {
|
|||||||
parsed = [];
|
parsed = [];
|
||||||
}
|
}
|
||||||
const descriptors = parseToDescriptors(parsed);
|
const descriptors = parseToDescriptors(parsed);
|
||||||
try {
|
|
||||||
console.debug(mountPrefix, 'parsed-data-named-stacks', {
|
|
||||||
descriptorCount: descriptors.length,
|
|
||||||
summary: descriptors.map((d, idx) => {
|
|
||||||
const imageIds = Array.isArray(d.imageIds) ? d.imageIds : [];
|
|
||||||
const sample = imageIds[Math.floor(imageIds.length / 2)] || imageIds[0] || '';
|
|
||||||
return {
|
|
||||||
idx,
|
|
||||||
name: d.name || null,
|
|
||||||
caseId: d.caseId || null,
|
|
||||||
studyId: d.studyId || null,
|
|
||||||
imageCount: imageIds.length,
|
|
||||||
sampleImageId: sample.length > 180 ? `${sample.slice(0, 180)}...(${sample.length} chars)` : sample,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// ignore debug logging failures
|
|
||||||
}
|
|
||||||
imageStacks = () => Promise.resolve(descriptors);
|
imageStacks = () => Promise.resolve(descriptors);
|
||||||
} else if (dataImages) {
|
} else if (dataImages) {
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -186,15 +210,6 @@ export function mountDicomViewers() {
|
|||||||
console.error("Invalid data-images JSON:", e);
|
console.error("Invalid data-images JSON:", e);
|
||||||
parsed = [];
|
parsed = [];
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
console.debug(mountPrefix, 'parsed-data-images', {
|
|
||||||
topLevelIsArray: Array.isArray(parsed),
|
|
||||||
topLevelLength: Array.isArray(parsed) ? parsed.length : 0,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// ignore debug logging failures
|
|
||||||
}
|
|
||||||
// Keep old behavior for data-images
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") {
|
if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") {
|
||||||
imageStacks = () => Promise.resolve([toWadouri(parsed)]);
|
imageStacks = () => Promise.resolve([toWadouri(parsed)]);
|
||||||
} else if (Array.isArray(parsed)) {
|
} else if (Array.isArray(parsed)) {
|
||||||
@@ -205,43 +220,18 @@ export function mountDicomViewers() {
|
|||||||
imageStacks = () => Promise.resolve([[]]);
|
imageStacks = () => Promise.resolve([[]]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No data provided: return no stacks (empty array) so viewer starts empty
|
|
||||||
imageStacks = () => Promise.resolve([] as any[]);
|
imageStacks = () => Promise.resolve([] as any[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoCacheStackAttr = container.getAttribute('data-auto-cache-stack');
|
mountContainer(container as HTMLElement, imageStacks, options);
|
||||||
const autoCacheStack = autoCacheStackAttr === "true" || autoCacheStackAttr === "1";
|
|
||||||
|
|
||||||
const annotationJson = container.getAttribute('data-annotationjson');
|
|
||||||
const viewerState = container.getAttribute('data-viewerstate');
|
|
||||||
console.log("Mounting DICOM viewer:")
|
|
||||||
console.log(viewerState, annotationJson, autoCacheStack);
|
|
||||||
try {
|
|
||||||
console.debug(mountPrefix, 'mount-props', {
|
|
||||||
hasViewerState: !!viewerState,
|
|
||||||
viewerStateLength: viewerState ? viewerState.length : 0,
|
|
||||||
hasAnnotationJson: !!annotationJson,
|
|
||||||
annotationJsonLength: annotationJson ? annotationJson.length : 0,
|
|
||||||
autoCacheStack,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// ignore debug logging failures
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(container).render(
|
|
||||||
<App
|
|
||||||
container_id={id}
|
|
||||||
imageStacks={imageStacks}
|
|
||||||
autoCacheStack={autoCacheStack}
|
|
||||||
annotationJson={annotationJson ? annotationJson : undefined}
|
|
||||||
viewerState={viewerState ? viewerState : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally, call it immediately for static containers:
|
// Optionally, call it immediately for static containers:
|
||||||
mountDicomViewers();
|
mountDicomViewers();
|
||||||
|
|
||||||
// Optionally, expose globally for dynamic use:
|
// Expose globally for dynamic use.
|
||||||
(window as any).mountDicomViewers = mountDicomViewers;
|
(window as any).mountDicomViewers = mountDicomViewers;
|
||||||
|
(window as any).remountDicomViewer = (containerId: string) => {
|
||||||
|
mountDicomViewers({ force: true, containerIds: [containerId] });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user