Compare commits
24 Commits
1674254a3c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f323dfd559 | |||
| 0c4da6e022 | |||
| d0f3431ef4 | |||
| b961a806d9 | |||
| 1ddc9a810a | |||
| b2bd2791a5 | |||
| b527eb1139 | |||
| 6a0552c843 | |||
| a5c530a907 | |||
| 41f13ade5e | |||
| b0d8c72636 | |||
| 4dd63668b0 | |||
| 77bbbd548e | |||
| 7c17a27ec3 | |||
| 68248737ec | |||
| 13361a30a5 | |||
| 627cd740f1 | |||
| af6e22fd91 | |||
| 7b786fcf9f | |||
| c9902a2e15 | |||
| 99df1cd1c1 | |||
| f169238057 | |||
| f0e0fe0ef6 | |||
| 19702b6052 |
@@ -0,0 +1,128 @@
|
||||
<!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:https://www.penracourses.org.uk/media/atlas/dicom/1.3.12.2.1107.5.4.5.180506.30000026031914043350300000126.4.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>
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5069
-707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
||||
export type LogFormat = "compact" | "verbose" | "json";
|
||||
|
||||
export type LoggerConfig = {
|
||||
minLevel: LogLevel;
|
||||
format: LogFormat;
|
||||
namespace: string;
|
||||
dedupeWindowMs: number;
|
||||
};
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: LoggerConfig = {
|
||||
minLevel: "warn",
|
||||
format: "compact",
|
||||
namespace: "dv3d",
|
||||
dedupeWindowMs: 1200,
|
||||
};
|
||||
|
||||
let loggerConfig: LoggerConfig = { ...DEFAULT_CONFIG };
|
||||
const lastMessageByFingerprint = new Map<string, number>();
|
||||
|
||||
type LogContext = {
|
||||
namespace?: string;
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function getConsoleMethod(level: LogLevel) {
|
||||
if (level === "debug") return console.debug.bind(console);
|
||||
if (level === "info") return console.info.bind(console);
|
||||
if (level === "warn") return console.warn.bind(console);
|
||||
return console.error.bind(console);
|
||||
}
|
||||
|
||||
function serializeArg(arg: unknown): unknown {
|
||||
if (arg instanceof Error) {
|
||||
return {
|
||||
name: arg.name,
|
||||
message: arg.message,
|
||||
stack: arg.stack,
|
||||
};
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel) {
|
||||
return LEVEL_ORDER[level] >= LEVEL_ORDER[loggerConfig.minLevel];
|
||||
}
|
||||
|
||||
function shouldDedupe(level: LogLevel) {
|
||||
return level === "debug" || level === "info";
|
||||
}
|
||||
|
||||
function buildFingerprint(level: LogLevel, args: unknown[], ctx?: LogContext): string {
|
||||
const base = ctx?.dedupeKey || `${ctx?.namespace || loggerConfig.namespace}:${level}`;
|
||||
const message = args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
try {
|
||||
return JSON.stringify(serializeArg(arg));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join("|");
|
||||
return `${base}:${message}`;
|
||||
}
|
||||
|
||||
function isDuplicate(level: LogLevel, args: unknown[], ctx?: LogContext): boolean {
|
||||
if (!shouldDedupe(level) || loggerConfig.dedupeWindowMs <= 0) {
|
||||
return false;
|
||||
}
|
||||
const fingerprint = buildFingerprint(level, args, ctx);
|
||||
const t = Date.now();
|
||||
const last = lastMessageByFingerprint.get(fingerprint);
|
||||
lastMessageByFingerprint.set(fingerprint, t);
|
||||
if (last === undefined) {
|
||||
return false;
|
||||
}
|
||||
return t - last < loggerConfig.dedupeWindowMs;
|
||||
}
|
||||
|
||||
function createPrefix(level: LogLevel, namespace?: string) {
|
||||
const ns = namespace || loggerConfig.namespace;
|
||||
if (loggerConfig.format === "compact") {
|
||||
return `[${ns}] ${level.toUpperCase()}`;
|
||||
}
|
||||
if (loggerConfig.format === "verbose") {
|
||||
return `[${nowIso()}] [${ns}] [${level.toUpperCase()}]`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function log(level: LogLevel, args: unknown[], ctx?: LogContext) {
|
||||
if (!shouldLog(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate(level, args, ctx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loggerConfig.format === "json") {
|
||||
getConsoleMethod(level)({
|
||||
ts: nowIso(),
|
||||
namespace: ctx?.namespace || loggerConfig.namespace,
|
||||
level,
|
||||
message: args.map(serializeArg),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = createPrefix(level, ctx?.namespace);
|
||||
getConsoleMethod(level)(prefix, ...args);
|
||||
}
|
||||
|
||||
export function setLoggerConfig(config: Partial<LoggerConfig>) {
|
||||
loggerConfig = {
|
||||
...loggerConfig,
|
||||
...config,
|
||||
};
|
||||
return getLoggerConfig();
|
||||
}
|
||||
|
||||
export function getLoggerConfig(): LoggerConfig {
|
||||
return { ...loggerConfig };
|
||||
}
|
||||
|
||||
export function parseLogFormat(value: unknown): LogFormat {
|
||||
if (value === "compact" || value === "verbose" || value === "json") {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_CONFIG.format;
|
||||
}
|
||||
|
||||
/** Parse a log level string. Falls back to the default ("warn") for unknown values. */
|
||||
export function parseLogLevel(value: unknown): LogLevel {
|
||||
if (value === "debug" || value === "info" || value === "warn" || value === "error" || value === "silent") {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_CONFIG.minLevel;
|
||||
}
|
||||
|
||||
/** Backward-compat helper: maps a truthy debug flag to minLevel "debug" or "warn". */
|
||||
export function parseDebugFlag(value: unknown): LogLevel {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "debug" : "warn";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
const truthy = normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
return truthy ? "debug" : "warn";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value !== 0 ? "debug" : "warn";
|
||||
}
|
||||
return "warn";
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (...args: unknown[]) => log("debug", args),
|
||||
info: (...args: unknown[]) => log("info", args),
|
||||
warn: (...args: unknown[]) => log("warn", args),
|
||||
error: (...args: unknown[]) => log("error", args),
|
||||
child: (namespace: string) => ({
|
||||
debug: (...args: unknown[]) => log("debug", args, { namespace }),
|
||||
info: (...args: unknown[]) => log("info", args, { namespace }),
|
||||
warn: (...args: unknown[]) => log("warn", args, { namespace }),
|
||||
error: (...args: unknown[]) => log("error", args, { namespace }),
|
||||
}),
|
||||
};
|
||||
+242
-107
@@ -1,10 +1,65 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ReactDOM 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 React from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { availableImageIds } from "./lib/createImageIds.ts";
|
||||
import {
|
||||
getLoggerConfig,
|
||||
logger,
|
||||
parseDebugFlag,
|
||||
parseLogFormat,
|
||||
parseLogLevel,
|
||||
setLoggerConfig,
|
||||
type LogFormat,
|
||||
type LogLevel,
|
||||
} from "./lib/logger";
|
||||
|
||||
type MountOptions = {
|
||||
force?: boolean;
|
||||
containerIds?: string[];
|
||||
logging?: {
|
||||
/** Minimum log level to emit. Takes precedence over `debug`. */
|
||||
minLevel?: LogLevel;
|
||||
/** @deprecated Use minLevel instead. true maps to "debug", false maps to "warn". */
|
||||
debug?: boolean;
|
||||
format?: LogFormat;
|
||||
};
|
||||
};
|
||||
|
||||
type ViewerDescriptor = {
|
||||
imageIds: string[];
|
||||
name?: string;
|
||||
caseId?: string;
|
||||
studyId?: string;
|
||||
series?: Array<{
|
||||
key?: string;
|
||||
label?: string;
|
||||
imageIds?: string[];
|
||||
i?: string[];
|
||||
name?: string;
|
||||
seriesId?: string | number;
|
||||
seriesInstanceUID?: string;
|
||||
bValue?: number | null;
|
||||
groupValues?: Record<string, string>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RootRecord = {
|
||||
root: Root;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const rootRegistry = new Map<HTMLElement, RootRecord>();
|
||||
const mainLogger = logger.child("main");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mountDicomViewers?: (options?: MountOptions) => void;
|
||||
remountDicomViewer?: (containerId: string) => void;
|
||||
setDicomViewerLogging?: (config: { minLevel?: LogLevel; debug?: boolean; format?: LogFormat }) => void;
|
||||
getDicomViewerLogging?: () => { minLevel: LogLevel; format: LogFormat; namespace: string; dedupeWindowMs: number };
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize incoming image ids/URLs and ensure DICOM entries use a valid wadouri: prefix.
|
||||
const normalizeImageId = (value: string) => {
|
||||
@@ -35,103 +90,189 @@ 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);
|
||||
if (!Array.isArray(imageIds) || imageIds.length === 0 || !imageIds.every((x) => typeof x === "string")) {
|
||||
mainLogger.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,
|
||||
series: Array.isArray(s.series) ? s.series : undefined,
|
||||
});
|
||||
});
|
||||
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")) {
|
||||
mainLogger.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,
|
||||
series: Array.isArray(item.series) ? item.series : 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;
|
||||
}
|
||||
|
||||
mainLogger.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 getContainerLogConfig = (container: HTMLElement, options?: MountOptions) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlDebug = urlParams.get("debug");
|
||||
const localSyncDebug = typeof window !== "undefined" && window.localStorage ? window.localStorage.getItem("multiuser_sync_debug_mode") : null;
|
||||
|
||||
const attrLevel = container.getAttribute("data-log-level");
|
||||
const attrDebug = container.getAttribute("data-log-debug");
|
||||
const attrFormat = container.getAttribute("data-log-format");
|
||||
|
||||
// Resolve minLevel: LocalStorage debug > URL debug parameter > data-log-level > options.minLevel > data-log-debug (legacy)
|
||||
let minLevel: LogLevel;
|
||||
if (localSyncDebug === "true") {
|
||||
minLevel = "debug";
|
||||
} else if (urlDebug !== null) {
|
||||
minLevel = parseDebugFlag(urlDebug);
|
||||
} else if (attrLevel !== null) {
|
||||
minLevel = parseLogLevel(attrLevel);
|
||||
} else if (options?.logging?.minLevel) {
|
||||
minLevel = options.logging.minLevel;
|
||||
} else if (attrDebug !== null) {
|
||||
minLevel = parseDebugFlag(attrDebug);
|
||||
} else if (options?.logging?.debug !== undefined) {
|
||||
minLevel = parseDebugFlag(options.logging.debug);
|
||||
} else {
|
||||
minLevel = "warn";
|
||||
}
|
||||
|
||||
return [];
|
||||
const format = parseLogFormat(attrFormat ?? options?.logging?.format);
|
||||
return { minLevel, format };
|
||||
};
|
||||
|
||||
const mountContainer = (container: HTMLElement, imageStacks: () => Promise<any[]>, options?: MountOptions) => {
|
||||
const signature = getMountSignature(container);
|
||||
const existing = rootRegistry.get(container);
|
||||
const initialLoggerConfig = getContainerLogConfig(container, options);
|
||||
|
||||
if (existing && !options?.force && existing.signature === signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.root.unmount();
|
||||
rootRegistry.delete(container);
|
||||
}
|
||||
|
||||
setLoggerConfig(initialLoggerConfig);
|
||||
|
||||
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}
|
||||
initialLoggerConfig={initialLoggerConfig}
|
||||
multiuserRoom={container.getAttribute("data-multiuser-room") || undefined}
|
||||
username={container.getAttribute("data-username") || undefined}
|
||||
isStaff={container.getAttribute("data-is-staff") === "true"}
|
||||
/>
|
||||
);
|
||||
|
||||
rootRegistry.set(container, { root, signature });
|
||||
};
|
||||
|
||||
export function mountDicomViewers(options?: MountOptions) {
|
||||
// Clean up any stale roots from elements that have been removed from the DOM
|
||||
for (const [container, entry] of rootRegistry.entries()) {
|
||||
if (!document.body.contains(container)) {
|
||||
try {
|
||||
mainLogger.info("Unmounting stale React root for container that was removed from DOM:", container.id);
|
||||
entry.root.unmount();
|
||||
} catch (e) {
|
||||
mainLogger.warn("Failed to unmount stale React root", e);
|
||||
}
|
||||
rootRegistry.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
// Test containers
|
||||
const testContainers = document.querySelectorAll(".dicom-viewer-test-root");
|
||||
testContainers.forEach((container) => {
|
||||
if (!shouldMountContainer(container as HTMLElement, options)) return;
|
||||
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) {
|
||||
@@ -139,7 +280,7 @@ export function mountDicomViewers() {
|
||||
try {
|
||||
parsed = JSON.parse(dataNamed);
|
||||
} catch (e) {
|
||||
console.error("Invalid data-named-stacks JSON:", e);
|
||||
mainLogger.error("Invalid data-named-stacks JSON:", e);
|
||||
parsed = [];
|
||||
}
|
||||
const descriptors = parseToDescriptors(parsed);
|
||||
@@ -149,46 +290,40 @@ export function mountDicomViewers() {
|
||||
try {
|
||||
parsed = JSON.parse(dataImages);
|
||||
} catch (e) {
|
||||
console.error("Invalid data-images JSON:", e);
|
||||
mainLogger.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)) {
|
||||
imageStacks = () => Promise.resolve(parsed.map(
|
||||
stack => Array.isArray(stack) ? toWadouri(stack) : []
|
||||
));
|
||||
imageStacks = () =>
|
||||
Promise.resolve(parsed.map((stack) => (Array.isArray(stack) ? toWadouri(stack) : [])));
|
||||
} else {
|
||||
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;
|
||||
window.mountDicomViewers = mountDicomViewers;
|
||||
window.remountDicomViewer = (containerId: string) => {
|
||||
mountDicomViewers({ force: true, containerIds: [containerId] });
|
||||
};
|
||||
window.setDicomViewerLogging = (config) => {
|
||||
const minLevel = config.minLevel
|
||||
? parseLogLevel(config.minLevel)
|
||||
: config.debug !== undefined
|
||||
? parseDebugFlag(config.debug)
|
||||
: undefined;
|
||||
setLoggerConfig({
|
||||
minLevel,
|
||||
format: config.format ? parseLogFormat(config.format) : undefined,
|
||||
});
|
||||
};
|
||||
window.getDicomViewerLogging = () => getLoggerConfig();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { logger } from './lib/logger'
|
||||
|
||||
const testLogger = logger.child('nonDicomTest')
|
||||
|
||||
// Sample external images (use CORS-friendly URLs). These are Wikimedia Commons images.
|
||||
const sampleImages = [
|
||||
@@ -38,7 +41,7 @@ function mount() {
|
||||
</React.StrictMode>
|
||||
)
|
||||
}).catch(err => {
|
||||
console.error('Failed to load App for non-dicom test', err)
|
||||
testLogger.error('Failed to load App for non-dicom test', err)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user