Compare commits

..
11 Commits
Author SHA1 Message Date
Ross 7c17a27ec3 try to fix fluro 2026-05-18 17:26:27 +01:00
Ross 68248737ec Enhance stack management by adding series groups support and improving multiframe handling 2026-05-18 16:31:15 +01:00
Ross 13361a30a5 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.
2026-05-18 14:07:33 +01:00
Ross 627cd740f1 Enhance debugging capabilities by adding detailed logging for thumbnail generation and viewer mounting processes 2026-05-18 13:14:15 +01:00
Ross af6e22fd91 Enhance viewport preset management by adding active preset tracking and applying presets on image changes 2026-05-18 12:39:08 +01:00
Ross 7b786fcf9f Refactor stack thumbnail management with dedicated setter and localStorage integration for stack panel width 2026-05-18 12:33:28 +01:00
Ross c9902a2e15 Enhance loading state management by adding 'displayset' option and improve user feedback during data loading 2026-05-18 12:29:42 +01:00
Ross 99df1cd1c1 Add stripWadouriPrefix function and refactor image ID handling for improved URL normalization 2026-05-18 12:17:35 +01:00
Ross f169238057 Enhance viewer functionality with volume diagnostics and fullscreen toggle 2026-05-18 11:51:38 +01:00
Ross f0e0fe0ef6 Implement drag-and-drop functionality for viewport stack management with visual drop zone previews 2026-05-18 11:37:34 +01:00
Ross 19702b6052 Refactor CT presets and enhance modality-specific preset options for improved functionality 2026-05-18 10:48:28 +01:00
58 changed files with 2403 additions and 484 deletions
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fluoro Multiframe Test</title>
<style>
html,body{height:100%;margin:0}
.dicom-viewer-root{width:100%;height:100vh;background:#222;color:#fff}
</style>
</head>
<body>
<div id="main_viewer" class="dicom-viewer-root" data-auto-cache-stack="false"></div>
<!-- Provide noop React Refresh preamble to avoid plugin-react runtime errors -->
<script>
window.$RefreshReg$ = window.$RefreshReg$ || function() {};
window.$RefreshSig$ = window.$RefreshSig$ || function() { return function(type) { return type; }; };
</script>
<script type="module" src="/src/main.tsx"></script>
<script>
// Viewer state for fluoro multiframe images (using relative paths from public/test_images/)
const state = {
"grid": {"rows": 1, "cols": 2},
"modes": ["stack", "stack"],
"stacks": [
{"i": ["wadouri:test_images/fluro/series_2369/1.3.12.2.1107.5.4.5.180506.30000026031914043350300000126.4.dcm"]},
{"i": ["wadouri:test_images/multibvalues/IMG_792_og2P5S6.dcm", "wadouri:test_images/multibvalues/IMG_793_3tKkg87.dcm"]}
],
"stackIdx": [0, 1],
"stackPos": [0, 1],
"props": [
{"voiRange": {"lower": 0, "upper": 500}, "invert": false, "interpolationType": 1, "VOILUTFunction": "LINEAR", "colormap": {"name": "Grayscale", "opacity": []}},
{"voiRange": {"lower": 0, "upper": 500}, "invert": false, "interpolationType": 1, "VOILUTFunction": "LINEAR", "colormap": {"name": "Grayscale", "opacity": []}}
],
"cam": [
{"viewUp": [0, 0, 1], "viewPlaneNormal": [1, 0, 0], "position": [150, 0, 0], "focalPoint": [0, 0, 0], "parallelProjection": true, "parallelScale": 100, "viewAngle": 90, "flipHorizontal": false, "flipVertical": false, "rotation": 0},
{"viewUp": [0, 0, 1], "viewPlaneNormal": [1, 0, 0], "position": [150, 0, 0], "focalPoint": [0, 0, 0], "parallelProjection": true, "parallelScale": 100, "viewAngle": 90, "flipHorizontal": false, "flipVertical": false, "rotation": 0}
],
"orientations": [null, null],
"volumeSliceIndices": [null, null]
};
function callWhenReady(fnName, payload) {
const maxWait = 5000;
const start = Date.now();
(function check() {
if (window[fnName] && typeof window[fnName] === 'function') {
try {
window[fnName](payload);
console.log('imported viewer state via', fnName);
} catch (e) {
console.error('importViewerState call failed', e);
}
return;
}
if (Date.now() - start > maxWait) {
console.warn('Timed out waiting for', fnName);
return;
}
setTimeout(check, 2000);
})();
}
document.addEventListener('DOMContentLoaded', () => {
callWhenReady('importViewerState', state);
});
</script>
</body>
</html>
+1
View File
@@ -23,6 +23,7 @@
<li><a href="/load-dir-test.html">load-dir-test.html</a> — directory loading test</li>
<li><a href="/non-dicom-test.html">non-dicom-test.html</a> — non-DICOM handling test</li>
<li><a href="/index.html">index.html</a> — project index</li>
<li><a href="/fluoro-test.html">fluoro-test.html</a> — fluoro multiframe test</li>
</ul>
<p>If you want these links added to the README, tell me and I will update it.</p>
+2190 -387
View File
File diff suppressed because it is too large Load Diff
+140 -97
View File
@@ -1,10 +1,34 @@
import React from 'react'
import { createRoot } from "react-dom/client";
import ReactDOM from 'react-dom/client'
import { createRoot, type Root } from "react-dom/client";
import App from './App.tsx'
import Nifti from './Nifti.tsx'
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.
const normalizeImageId = (value: string) => {
@@ -35,103 +59,137 @@ const toDescriptor = (arr: string[] | undefined) => {
return { imageIds: toWadouri(arr) };
};
export function mountDicomViewers() {
// 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 || '';
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');
const parseToDescriptors = (parsed: any): Array<any> => {
// Return array of descriptors: either simple string[] => { imageIds: string[] }
// or objects: { imageIds: string[], name?, caseId?, studyId? }
const parseToDescriptors = (parsed: any): Array<ViewerDescriptor> => {
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") {
return [toDescriptor(parsed)];
}
if (Array.isArray(parsed)) {
const out: any[] = [];
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;
if (!Array.isArray(parsed)) {
return [];
}
// Validation
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;
}
const out: Array<ViewerDescriptor> = [];
out.push({
imageIds: toWadouri(imageIds),
name: s.name || s.label || undefined,
caseId,
studyId: s.studyId || studyId,
});
});
return;
}
parsed.forEach((item, itemIdx) => {
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;
// 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;
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;
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;
}
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,
name: s.name || s.label || undefined,
caseId,
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;
}
// Plain array (stack)
if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") {
out.push(toDescriptor(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;
}
// Unknown / invalid entry
console.warn(`data-named-stacks: unknown or invalid entry at parsed[${itemIdx}] — skipping`, item);
});
return out;
if (Array.isArray(item) && item.length > 0 && typeof item[0] === "string") {
out.push(toDescriptor(item));
return;
}
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) {
imageStacks = () => Promise.resolve([] as any[]);
} else if (dataNamed) {
@@ -152,7 +210,6 @@ export function mountDicomViewers() {
console.error("Invalid data-images JSON:", e);
parsed = [];
}
// Keep old behavior for data-images
if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") {
imageStacks = () => Promise.resolve([toWadouri(parsed)]);
} else if (Array.isArray(parsed)) {
@@ -163,32 +220,18 @@ export function mountDicomViewers() {
imageStacks = () => Promise.resolve([[]]);
}
} else {
// No data provided: return no stacks (empty array) so viewer starts empty
imageStacks = () => Promise.resolve([] as any[]);
}
const autoCacheStackAttr = container.getAttribute('data-auto-cache-stack');
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);
createRoot(container).render(
<App
container_id={id}
imageStacks={imageStacks}
autoCacheStack={autoCacheStack}
annotationJson={annotationJson ? annotationJson : undefined}
viewerState={viewerState ? viewerState : undefined}
/>
);
mountContainer(container as HTMLElement, imageStacks, options);
});
}
// Optionally, call it immediately for static containers:
mountDicomViewers();
// Optionally, expose globally for dynamic use:
(window as any).mountDicomViewers = mountDicomViewers;
// Expose globally for dynamic use.
(window as any).mountDicomViewers = mountDicomViewers;
(window as any).remountDicomViewer = (containerId: string) => {
mountDicomViewers({ force: true, containerIds: [containerId] });
};