@niivue/nv-ohif 0.1.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -0
- package/dist/index.js +2952 -0
- package/dist/types/NiivueViewport.d.ts +14 -0
- package/dist/types/classifyDisplaySet.d.ts +7 -0
- package/dist/types/commands.d.ts +202 -0
- package/dist/types/dicomToNiivue.d.ts +21 -0
- package/dist/types/dicomWadoRs.d.ts +22 -0
- package/dist/types/displaySetToNiivue.d.ts +7 -0
- package/dist/types/getNiivueViewportModule.d.ts +4 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/niivueRegistry.d.ts +60 -0
- package/dist/types/ohif-types.d.ts +97 -0
- package/dist/types/reconstructP10.d.ts +35 -0
- package/dist/types/sopClassHandler.d.ts +16 -0
- package/dist/types/toolBridge.d.ts +16 -0
- package/dist/types/toolbar.d.ts +38 -0
- package/dist/types/volumeAnnotationOverlay.d.ts +20 -0
- package/dist/types/wsiSlideView.d.ts +30 -0
- package/dist/types/wsiThumbnail.d.ts +40 -0
- package/dist/types/wsiTileSource.d.ts +68 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2952 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/commands.ts
|
|
10
|
+
import {
|
|
11
|
+
SLICE_TYPE,
|
|
12
|
+
slice2DToMM
|
|
13
|
+
} from "@niivue/niivue";
|
|
14
|
+
|
|
15
|
+
// src/dicomWadoRs.ts
|
|
16
|
+
function retrieveInstanceUrlFromImageId(imageId) {
|
|
17
|
+
if (!imageId)
|
|
18
|
+
return null;
|
|
19
|
+
const raw = imageId.replace(/^(wadors|dicomweb|wadouri):/i, "");
|
|
20
|
+
if (!/^https?:\/\//i.test(raw))
|
|
21
|
+
return null;
|
|
22
|
+
const m = raw.match(/^(.*\/instances\/[^/?#]+)(?:[/?#].*)?$/i);
|
|
23
|
+
return m?.[1] ?? null;
|
|
24
|
+
}
|
|
25
|
+
function multipartBoundary(contentType) {
|
|
26
|
+
const m = /boundary=(?:"([^"]+)"|([^\s;]+))/i.exec(contentType);
|
|
27
|
+
const b = m?.[1] ?? m?.[2];
|
|
28
|
+
return b ? b.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
function boundaryFromBody(bytes) {
|
|
31
|
+
if (bytes[0] !== 45 || bytes[1] !== 45)
|
|
32
|
+
return null;
|
|
33
|
+
let end = 2;
|
|
34
|
+
const limit = Math.min(bytes.length, 256);
|
|
35
|
+
while (end < limit && bytes[end] !== 13 && bytes[end] !== 10)
|
|
36
|
+
end++;
|
|
37
|
+
if (end <= 2)
|
|
38
|
+
return null;
|
|
39
|
+
return new TextDecoder("latin1").decode(bytes.subarray(2, end));
|
|
40
|
+
}
|
|
41
|
+
function indexOfSubarray(haystack, needle, from) {
|
|
42
|
+
outer:
|
|
43
|
+
for (let i = from;i + needle.length <= haystack.length; i++) {
|
|
44
|
+
for (let j = 0;j < needle.length; j++) {
|
|
45
|
+
if (haystack[i + j] !== needle[j])
|
|
46
|
+
continue outer;
|
|
47
|
+
}
|
|
48
|
+
return i;
|
|
49
|
+
}
|
|
50
|
+
return -1;
|
|
51
|
+
}
|
|
52
|
+
function parseMultipartRelated(body, contentType) {
|
|
53
|
+
const bytes = body instanceof Uint8Array ? body : new Uint8Array(body);
|
|
54
|
+
const boundary = multipartBoundary(contentType) ?? boundaryFromBody(bytes);
|
|
55
|
+
if (!boundary) {
|
|
56
|
+
return bytes.length > 0 ? [bytes] : [];
|
|
57
|
+
}
|
|
58
|
+
const encoder = new TextEncoder;
|
|
59
|
+
const delimiter = encoder.encode(`--${boundary}`);
|
|
60
|
+
const crlfcrlf = encoder.encode(`\r
|
|
61
|
+
\r
|
|
62
|
+
`);
|
|
63
|
+
const parts = [];
|
|
64
|
+
let cursor = indexOfSubarray(bytes, delimiter, 0);
|
|
65
|
+
while (cursor !== -1) {
|
|
66
|
+
const partStart = cursor + delimiter.length;
|
|
67
|
+
if (bytes[partStart] === 45 && bytes[partStart + 1] === 45)
|
|
68
|
+
break;
|
|
69
|
+
const headerEnd = indexOfSubarray(bytes, crlfcrlf, partStart);
|
|
70
|
+
const next = indexOfSubarray(bytes, delimiter, partStart);
|
|
71
|
+
if (headerEnd === -1 || next === -1)
|
|
72
|
+
break;
|
|
73
|
+
const bodyStart = headerEnd + crlfcrlf.length;
|
|
74
|
+
let bodyEnd = next;
|
|
75
|
+
if (bytes[bodyEnd - 2] === 13 && bytes[bodyEnd - 1] === 10)
|
|
76
|
+
bodyEnd -= 2;
|
|
77
|
+
if (bodyEnd > bodyStart)
|
|
78
|
+
parts.push(bytes.slice(bodyStart, bodyEnd));
|
|
79
|
+
cursor = next;
|
|
80
|
+
}
|
|
81
|
+
return parts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/displaySetToNiivue.ts
|
|
85
|
+
var NIIVUE_URL_EXTS = [
|
|
86
|
+
".nii",
|
|
87
|
+
".nii.gz",
|
|
88
|
+
".nrrd",
|
|
89
|
+
".nhdr",
|
|
90
|
+
".mgh",
|
|
91
|
+
".mgz",
|
|
92
|
+
".mha",
|
|
93
|
+
".mhd",
|
|
94
|
+
".mif",
|
|
95
|
+
".npy",
|
|
96
|
+
".head",
|
|
97
|
+
".brik"
|
|
98
|
+
];
|
|
99
|
+
function looksLikeNiivueVolumeUrl(url) {
|
|
100
|
+
const path = url.split(/[?#]/)[0]?.toLowerCase() ?? "";
|
|
101
|
+
return NIIVUE_URL_EXTS.some((ext) => path.endsWith(ext));
|
|
102
|
+
}
|
|
103
|
+
function firstString(value) {
|
|
104
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
105
|
+
}
|
|
106
|
+
function displaySetToNiivue(displaySet) {
|
|
107
|
+
const name = firstString(displaySet.SeriesDescription) ?? firstString(displaySet.SeriesInstanceUID) ?? "series";
|
|
108
|
+
const direct = firstString(displaySet.url);
|
|
109
|
+
if (direct && looksLikeNiivueVolumeUrl(direct)) {
|
|
110
|
+
return { url: direct, name };
|
|
111
|
+
}
|
|
112
|
+
const firstInstance = displaySet.instances?.[0];
|
|
113
|
+
const instanceUrl = firstInstance ? firstString(firstInstance.url) : undefined;
|
|
114
|
+
if (instanceUrl && looksLikeNiivueVolumeUrl(instanceUrl)) {
|
|
115
|
+
return { url: instanceUrl, name };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/classifyDisplaySet.ts
|
|
121
|
+
var WSI_MODALITIES = new Set(["SM"]);
|
|
122
|
+
function hasWadoInstances(ds) {
|
|
123
|
+
const instances = ds.instances;
|
|
124
|
+
if (!instances || instances.length === 0)
|
|
125
|
+
return false;
|
|
126
|
+
return instances.some((inst) => {
|
|
127
|
+
const id = inst.imageId;
|
|
128
|
+
return typeof id === "string" && retrieveInstanceUrlFromImageId(id) !== null;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function classifyDisplaySet(ds) {
|
|
132
|
+
if (displaySetToNiivue(ds) !== null)
|
|
133
|
+
return "nifti";
|
|
134
|
+
const modality = typeof ds.Modality === "string" ? ds.Modality : undefined;
|
|
135
|
+
if (modality && WSI_MODALITIES.has(modality) && hasWadoInstances(ds)) {
|
|
136
|
+
return "wsi";
|
|
137
|
+
}
|
|
138
|
+
if (hasWadoInstances(ds))
|
|
139
|
+
return "dicom-volume";
|
|
140
|
+
return "unsupported";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/reconstructP10.ts
|
|
144
|
+
var EXPLICIT_VR_LITTLE_ENDIAN = "1.2.840.10008.1.2.1";
|
|
145
|
+
var IMPLEMENTATION_CLASS_UID = "1.2.826.0.1.3680043.9.7433.1.1";
|
|
146
|
+
function firstValue(el) {
|
|
147
|
+
return el?.Value?.[0];
|
|
148
|
+
}
|
|
149
|
+
function sanitizeDataset(source) {
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const [tag, el] of Object.entries(source)) {
|
|
152
|
+
if (tag.startsWith("0002") || tag === "7FE00010")
|
|
153
|
+
continue;
|
|
154
|
+
if (typeof el.vr !== "string" || el.vr.length !== 2)
|
|
155
|
+
continue;
|
|
156
|
+
if (el.Value === undefined)
|
|
157
|
+
continue;
|
|
158
|
+
if (el.vr === "SQ" && Array.isArray(el.Value)) {
|
|
159
|
+
out[tag] = {
|
|
160
|
+
...el,
|
|
161
|
+
Value: el.Value.map((item) => item && typeof item === "object" ? sanitizeDataset(item) : item)
|
|
162
|
+
};
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
out[tag] = el;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function framesUrlFromImageId(imageId) {
|
|
170
|
+
if (typeof imageId !== "string")
|
|
171
|
+
return null;
|
|
172
|
+
const raw = imageId.replace(/^(wadors|dicomweb):/i, "");
|
|
173
|
+
return /^https?:\/\/.*\/frames\//i.test(raw) ? raw : null;
|
|
174
|
+
}
|
|
175
|
+
function seriesMetadataUrlFromFramesUrl(framesUrl) {
|
|
176
|
+
const seriesBase = framesUrl.split("/instances/")[0];
|
|
177
|
+
return seriesBase ? `${seriesBase}/metadata` : null;
|
|
178
|
+
}
|
|
179
|
+
function sopFromFramesUrl(framesUrl) {
|
|
180
|
+
return framesUrl.split("/instances/")[1]?.split(/[/?#]/)[0] ?? null;
|
|
181
|
+
}
|
|
182
|
+
var UNCOMPRESSED_SYNTAXES = new Set([
|
|
183
|
+
"1.2.840.10008.1.2",
|
|
184
|
+
"1.2.840.10008.1.2.1",
|
|
185
|
+
"1.2.840.10008.1.2.2"
|
|
186
|
+
]);
|
|
187
|
+
function assemblePart10(DicomDict, metadata, pixelBytes, transferSyntaxUID) {
|
|
188
|
+
const dataset = sanitizeDataset(metadata);
|
|
189
|
+
const syntax = transferSyntaxUID || EXPLICIT_VR_LITTLE_ENDIAN;
|
|
190
|
+
const pixelBuffer = new ArrayBuffer(pixelBytes.byteLength);
|
|
191
|
+
new Uint8Array(pixelBuffer).set(pixelBytes);
|
|
192
|
+
if (UNCOMPRESSED_SYNTAXES.has(syntax)) {
|
|
193
|
+
const bitsAllocated = firstValue(metadata["00280100"]) ?? 16;
|
|
194
|
+
dataset["7FE00010"] = {
|
|
195
|
+
vr: bitsAllocated > 8 ? "OW" : "OB",
|
|
196
|
+
Value: [pixelBuffer]
|
|
197
|
+
};
|
|
198
|
+
} else {
|
|
199
|
+
dataset["7FE00010"] = { vr: "OB", Value: [pixelBuffer] };
|
|
200
|
+
}
|
|
201
|
+
const meta = {
|
|
202
|
+
"00020010": { vr: "UI", Value: [syntax] },
|
|
203
|
+
"00020002": { vr: "UI", Value: [firstValue(metadata["00080016"])] },
|
|
204
|
+
"00020003": { vr: "UI", Value: [firstValue(metadata["00080018"])] },
|
|
205
|
+
"00020012": { vr: "UI", Value: [IMPLEMENTATION_CLASS_UID] }
|
|
206
|
+
};
|
|
207
|
+
const dicomDict = new DicomDict(meta);
|
|
208
|
+
dicomDict.dict = dataset;
|
|
209
|
+
return dicomDict.write({
|
|
210
|
+
allowInvalidVRLength: true,
|
|
211
|
+
fragmentMultiframe: false
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function loadDicomDictCtor() {
|
|
215
|
+
const mod = await import("dcmjs");
|
|
216
|
+
const dcmjs = mod.default ?? mod;
|
|
217
|
+
return dcmjs.data.DicomDict;
|
|
218
|
+
}
|
|
219
|
+
function transferSyntaxOf(contentType, buffer) {
|
|
220
|
+
const fromResp = /transfer-syntax=([0-9.]+)/i.exec(contentType)?.[1];
|
|
221
|
+
if (fromResp)
|
|
222
|
+
return fromResp;
|
|
223
|
+
const head = new TextDecoder("latin1").decode(new Uint8Array(buffer).slice(0, 1024));
|
|
224
|
+
return /transfer-syntax=([0-9.]+)/i.exec(head)?.[1] ?? null;
|
|
225
|
+
}
|
|
226
|
+
async function fetchFrame(url, headers, signal) {
|
|
227
|
+
const resp = await fetch(url, {
|
|
228
|
+
headers: {
|
|
229
|
+
Accept: 'multipart/related; type="application/octet-stream"',
|
|
230
|
+
...headers
|
|
231
|
+
},
|
|
232
|
+
signal
|
|
233
|
+
});
|
|
234
|
+
if (!resp.ok)
|
|
235
|
+
throw new Error(`frame fetch failed (${resp.status} ${resp.statusText})`);
|
|
236
|
+
const contentType = resp.headers.get("content-type") ?? "";
|
|
237
|
+
const buffer = await resp.arrayBuffer();
|
|
238
|
+
const transferSyntaxUID = transferSyntaxOf(contentType, buffer);
|
|
239
|
+
const parts = parseMultipartRelated(buffer, contentType);
|
|
240
|
+
let bytes;
|
|
241
|
+
if (parts.length === 0)
|
|
242
|
+
bytes = new Uint8Array(buffer);
|
|
243
|
+
else if (parts.length === 1)
|
|
244
|
+
bytes = parts[0] ?? new Uint8Array;
|
|
245
|
+
else {
|
|
246
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
247
|
+
bytes = new Uint8Array(total);
|
|
248
|
+
let o = 0;
|
|
249
|
+
for (const p of parts) {
|
|
250
|
+
bytes.set(p, o);
|
|
251
|
+
o += p.length;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { bytes, transferSyntaxUID };
|
|
255
|
+
}
|
|
256
|
+
async function reconstructInstanceFiles(framesUrls, options = {}) {
|
|
257
|
+
const { headers = {}, onProgress, signal, concurrency = 6 } = options;
|
|
258
|
+
if (framesUrls.length === 0)
|
|
259
|
+
return [];
|
|
260
|
+
const firstUrl = framesUrls[0];
|
|
261
|
+
if (firstUrl === undefined)
|
|
262
|
+
return [];
|
|
263
|
+
const metadataUrl = seriesMetadataUrlFromFramesUrl(firstUrl);
|
|
264
|
+
if (!metadataUrl)
|
|
265
|
+
throw new Error("Could not derive series metadata URL");
|
|
266
|
+
const [DicomDict, metaResp] = await Promise.all([
|
|
267
|
+
loadDicomDictCtor(),
|
|
268
|
+
fetch(metadataUrl, {
|
|
269
|
+
headers: { Accept: "application/dicom+json", ...headers },
|
|
270
|
+
signal
|
|
271
|
+
})
|
|
272
|
+
]);
|
|
273
|
+
if (!metaResp.ok) {
|
|
274
|
+
throw new Error(`series metadata fetch failed (${metaResp.status})`);
|
|
275
|
+
}
|
|
276
|
+
const metaJson = await metaResp.json();
|
|
277
|
+
const metaList = Array.isArray(metaJson) ? metaJson : [metaJson];
|
|
278
|
+
const metaBySop = new Map;
|
|
279
|
+
for (const m of metaList) {
|
|
280
|
+
const sop = firstValue(m["00080018"]);
|
|
281
|
+
if (sop)
|
|
282
|
+
metaBySop.set(sop, m);
|
|
283
|
+
}
|
|
284
|
+
const files = [];
|
|
285
|
+
let done = 0;
|
|
286
|
+
let next = 0;
|
|
287
|
+
const worker = async () => {
|
|
288
|
+
while (next < framesUrls.length) {
|
|
289
|
+
const idx = next++;
|
|
290
|
+
const framesUrl = framesUrls[idx];
|
|
291
|
+
if (framesUrl === undefined)
|
|
292
|
+
break;
|
|
293
|
+
const sop = sopFromFramesUrl(framesUrl);
|
|
294
|
+
const metadata = sop ? metaBySop.get(sop) : undefined;
|
|
295
|
+
if (metadata && sop) {
|
|
296
|
+
const frame = await fetchFrame(framesUrl, headers, signal);
|
|
297
|
+
const p10 = assemblePart10(DicomDict, metadata, frame.bytes, frame.transferSyntaxUID ?? undefined);
|
|
298
|
+
files.push(new File([p10], `${sop}-${idx}.dcm`, { type: "application/dicom" }));
|
|
299
|
+
}
|
|
300
|
+
onProgress?.(++done, framesUrls.length);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, framesUrls.length) }, () => worker()));
|
|
304
|
+
if (files.length < framesUrls.length) {
|
|
305
|
+
console.warn(`[nv-ohif] reconstructed ${files.length} of ${framesUrls.length} DICOM instances (${framesUrls.length - files.length} skipped for missing metadata); the converted volume may be missing slices.`);
|
|
306
|
+
}
|
|
307
|
+
return files;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/dicomToNiivue.ts
|
|
311
|
+
function instanceFramesUrls(ds) {
|
|
312
|
+
const urls = [];
|
|
313
|
+
const seen = new Set;
|
|
314
|
+
for (const inst of ds.instances ?? []) {
|
|
315
|
+
const id = inst.imageId;
|
|
316
|
+
const url = typeof id === "string" ? framesUrlFromImageId(id) : null;
|
|
317
|
+
if (url && !seen.has(url)) {
|
|
318
|
+
seen.add(url);
|
|
319
|
+
urls.push(url);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return urls;
|
|
323
|
+
}
|
|
324
|
+
async function fetchDicomInstanceFiles(ds, options = {}) {
|
|
325
|
+
const { headers, onProgress, signal, concurrency } = options;
|
|
326
|
+
const framesUrls = instanceFramesUrls(ds);
|
|
327
|
+
if (framesUrls.length === 0)
|
|
328
|
+
return [];
|
|
329
|
+
return reconstructInstanceFiles(framesUrls, {
|
|
330
|
+
headers,
|
|
331
|
+
signal,
|
|
332
|
+
concurrency,
|
|
333
|
+
onProgress: (loaded, total) => onProgress?.("fetching", loaded, total)
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async function convertDisplaySetToNifti(ds, options = {}) {
|
|
337
|
+
const files = await fetchDicomInstanceFiles(ds, options);
|
|
338
|
+
if (files.length === 0)
|
|
339
|
+
return null;
|
|
340
|
+
options.onProgress?.("converting", 0, 1);
|
|
341
|
+
const { Dcm2niix } = await import("@niivue/dcm2niix");
|
|
342
|
+
const dcm2niix = new Dcm2niix;
|
|
343
|
+
try {
|
|
344
|
+
await dcm2niix.init();
|
|
345
|
+
dcm2niix.worker?.addEventListener("message", (e) => {
|
|
346
|
+
const data = e.data;
|
|
347
|
+
if (data?.type === "error") {
|
|
348
|
+
console.error("[nv-ohif] dcm2niix worker error", data.message, data.error);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
const result = await dcm2niix.input(files).run();
|
|
352
|
+
const nifti = result.filter((f) => /\.nii(\.gz)?$/i.test(f.name)).sort((a, b) => b.size - a.size);
|
|
353
|
+
if (nifti.length > 1) {
|
|
354
|
+
console.warn(`[nv-ohif] dcm2niix produced ${nifti.length} NIfTI volumes for this series; showing the largest ("${nifti[0]?.name}"). Others not shown: ` + nifti.slice(1).map((f) => f.name).join(", "));
|
|
355
|
+
}
|
|
356
|
+
return nifti[0] ?? null;
|
|
357
|
+
} finally {
|
|
358
|
+
dcm2niix.worker?.terminate();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/niivueRegistry.ts
|
|
363
|
+
var instances = new Map;
|
|
364
|
+
function registerNiivue(viewportId, nv) {
|
|
365
|
+
instances.set(viewportId, {
|
|
366
|
+
viewportId,
|
|
367
|
+
nv,
|
|
368
|
+
displaySets: [],
|
|
369
|
+
overlayUIDs: [],
|
|
370
|
+
overlayLoading: false,
|
|
371
|
+
clipPlane: "none"
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function unregisterNiivue(viewportId) {
|
|
375
|
+
instances.delete(viewportId);
|
|
376
|
+
}
|
|
377
|
+
function updateNiivueViewport(viewportId, patch) {
|
|
378
|
+
const entry = instances.get(viewportId);
|
|
379
|
+
if (entry)
|
|
380
|
+
Object.assign(entry, patch);
|
|
381
|
+
}
|
|
382
|
+
function getNiivueEntry(viewportId) {
|
|
383
|
+
return viewportId === undefined ? undefined : instances.get(viewportId);
|
|
384
|
+
}
|
|
385
|
+
function ohifServices(servicesManager) {
|
|
386
|
+
if (servicesManager?.services)
|
|
387
|
+
return servicesManager.services;
|
|
388
|
+
const g = globalThis;
|
|
389
|
+
return g.services;
|
|
390
|
+
}
|
|
391
|
+
function getNiivueEntryForViewport(viewportId) {
|
|
392
|
+
const exact = getNiivueEntry(viewportId);
|
|
393
|
+
if (exact)
|
|
394
|
+
return exact;
|
|
395
|
+
if (instances.size === 1) {
|
|
396
|
+
for (const entry of instances.values())
|
|
397
|
+
return entry;
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
function getNiivueForViewport(viewportId) {
|
|
402
|
+
return getNiivueEntryForViewport(viewportId)?.nv;
|
|
403
|
+
}
|
|
404
|
+
function getActiveNiivueEntry(servicesManager) {
|
|
405
|
+
const grid = ohifServices(servicesManager)?.viewportGridService;
|
|
406
|
+
return getNiivueEntryForViewport(grid?.getActiveViewportId?.());
|
|
407
|
+
}
|
|
408
|
+
function getActiveNiivue(servicesManager) {
|
|
409
|
+
return getActiveNiivueEntry(servicesManager)?.nv;
|
|
410
|
+
}
|
|
411
|
+
function refreshToolbar(servicesManager, viewportId) {
|
|
412
|
+
const svc = ohifServices(servicesManager)?.toolbarService;
|
|
413
|
+
svc?.refreshToolbarState?.({ viewportId });
|
|
414
|
+
}
|
|
415
|
+
function ohifCommandsManager(commandsManager) {
|
|
416
|
+
if (commandsManager?.runCommand)
|
|
417
|
+
return commandsManager;
|
|
418
|
+
const g = globalThis;
|
|
419
|
+
return g.commandsManager;
|
|
420
|
+
}
|
|
421
|
+
function authHeaders(servicesManager) {
|
|
422
|
+
const svc = ohifServices(servicesManager)?.userAuthenticationService;
|
|
423
|
+
const header = svc?.getAuthorizationHeader?.();
|
|
424
|
+
return header?.Authorization ? { Authorization: header.Authorization } : {};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/commands.ts
|
|
428
|
+
var NIIVUE_SLICE_TYPES = {
|
|
429
|
+
axial: SLICE_TYPE.AXIAL,
|
|
430
|
+
coronal: SLICE_TYPE.CORONAL,
|
|
431
|
+
sagittal: SLICE_TYPE.SAGITTAL,
|
|
432
|
+
multiplanar: SLICE_TYPE.MULTIPLANAR,
|
|
433
|
+
render: SLICE_TYPE.RENDER
|
|
434
|
+
};
|
|
435
|
+
var NIIVUE_CLIP_PLANES = {
|
|
436
|
+
none: [2, 0, 0],
|
|
437
|
+
right: [0, 90, 0],
|
|
438
|
+
left: [0, 270, 0],
|
|
439
|
+
anterior: [0, 180, 0],
|
|
440
|
+
posterior: [0, 0, 0],
|
|
441
|
+
superior: [0, 0, 90],
|
|
442
|
+
inferior: [0, 0, -90]
|
|
443
|
+
};
|
|
444
|
+
var OVERLAY_COLORMAP = "warm";
|
|
445
|
+
var OVERLAY_OPACITY = 0.5;
|
|
446
|
+
var NIIVUE_COLORMAPS = [
|
|
447
|
+
{ name: "gray", label: "Gray" },
|
|
448
|
+
{ name: "hot", label: "Hot" },
|
|
449
|
+
{ name: "bone", label: "Bone" },
|
|
450
|
+
{ name: "cool", label: "Cool" },
|
|
451
|
+
{ name: "warm", label: "Warm" },
|
|
452
|
+
{ name: "viridis", label: "Viridis" },
|
|
453
|
+
{ name: "plasma", label: "Plasma" },
|
|
454
|
+
{ name: "inferno", label: "Inferno" },
|
|
455
|
+
{ name: "turbo", label: "Turbo" },
|
|
456
|
+
{ name: "jet", label: "Jet" }
|
|
457
|
+
];
|
|
458
|
+
var FALLBACK_WL_PRESETS = {
|
|
459
|
+
CT: [
|
|
460
|
+
{
|
|
461
|
+
id: "ct-soft-tissue",
|
|
462
|
+
description: "Soft tissue",
|
|
463
|
+
window: 400,
|
|
464
|
+
level: 40
|
|
465
|
+
},
|
|
466
|
+
{ id: "ct-lung", description: "Lung", window: 1500, level: -600 },
|
|
467
|
+
{ id: "ct-liver", description: "Liver", window: 150, level: 90 },
|
|
468
|
+
{ id: "ct-bone", description: "Bone", window: 2500, level: 480 },
|
|
469
|
+
{ id: "ct-brain", description: "Brain", window: 80, level: 40 }
|
|
470
|
+
],
|
|
471
|
+
PT: [
|
|
472
|
+
{ id: "pt-default", description: "Default", window: 5, level: 2.5 },
|
|
473
|
+
{ id: "pt-suv-5", description: "SUV 5", window: 0, level: 5 },
|
|
474
|
+
{ id: "pt-suv-10", description: "SUV 10", window: 0, level: 10 }
|
|
475
|
+
]
|
|
476
|
+
};
|
|
477
|
+
var VIEW_DEFAULTS = {
|
|
478
|
+
azimuth: 110,
|
|
479
|
+
elevation: 10,
|
|
480
|
+
scaleMultiplier: 1,
|
|
481
|
+
pan2Dxyzmm: [0, 0, 0, 1],
|
|
482
|
+
renderPan: [0, 0],
|
|
483
|
+
crosshairPos: [0.5, 0.5, 0.5]
|
|
484
|
+
};
|
|
485
|
+
function findOverlayCandidate(entry, activeDisplaySets) {
|
|
486
|
+
const loaded = new Set(entry.overlayUIDs);
|
|
487
|
+
for (const ds of entry.displaySets) {
|
|
488
|
+
if (typeof ds.displaySetInstanceUID === "string")
|
|
489
|
+
loaded.add(ds.displaySetInstanceUID);
|
|
490
|
+
}
|
|
491
|
+
return activeDisplaySets.find((ds) => {
|
|
492
|
+
const uid = ds.displaySetInstanceUID;
|
|
493
|
+
if (typeof uid !== "string" || loaded.has(uid))
|
|
494
|
+
return false;
|
|
495
|
+
const kind = classifyDisplaySet(ds);
|
|
496
|
+
return kind === "nifti" || kind === "dicom-volume";
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
var statusTokens = new WeakMap;
|
|
500
|
+
function writeStatus(entry, message) {
|
|
501
|
+
statusTokens.set(entry, (statusTokens.get(entry) ?? 0) + 1);
|
|
502
|
+
entry.setStatus?.(message);
|
|
503
|
+
}
|
|
504
|
+
function flashStatus(entry, message, ms = 4000) {
|
|
505
|
+
writeStatus(entry, message);
|
|
506
|
+
const token = statusTokens.get(entry);
|
|
507
|
+
setTimeout(() => {
|
|
508
|
+
if (statusTokens.get(entry) === token)
|
|
509
|
+
writeStatus(entry, null);
|
|
510
|
+
}, ms);
|
|
511
|
+
}
|
|
512
|
+
function baseModality(entry) {
|
|
513
|
+
const modality = entry.displaySets[0]?.Modality;
|
|
514
|
+
return typeof modality === "string" ? modality : undefined;
|
|
515
|
+
}
|
|
516
|
+
function windowLevelPresets(servicesManager) {
|
|
517
|
+
const svc = ohifServices(servicesManager)?.customizationService;
|
|
518
|
+
const custom = svc?.getCustomization?.("cornerstone.windowLevelPresets");
|
|
519
|
+
if (custom && typeof custom === "object") {
|
|
520
|
+
return custom;
|
|
521
|
+
}
|
|
522
|
+
return FALLBACK_WL_PRESETS;
|
|
523
|
+
}
|
|
524
|
+
function resolveWindowLevel(presets, modality, presetId, presetIndex) {
|
|
525
|
+
const list = modality ? presets[modality] : undefined;
|
|
526
|
+
if (!list || list.length === 0)
|
|
527
|
+
return;
|
|
528
|
+
const preset = (presetId ? list.find((p) => p.id === presetId) : undefined) ?? (presetIndex !== undefined ? list[presetIndex] : undefined);
|
|
529
|
+
if (!preset)
|
|
530
|
+
return;
|
|
531
|
+
const width = Number(preset.window);
|
|
532
|
+
const center = Number(preset.level);
|
|
533
|
+
if (!Number.isFinite(width) || !Number.isFinite(center))
|
|
534
|
+
return;
|
|
535
|
+
if (width === 0)
|
|
536
|
+
return [0, center];
|
|
537
|
+
return [center - width / 2, center + width / 2];
|
|
538
|
+
}
|
|
539
|
+
function readBaseWindowLevel(nv) {
|
|
540
|
+
const vol = nv.volumes[0];
|
|
541
|
+
if (!vol)
|
|
542
|
+
return;
|
|
543
|
+
const { calMin, calMax } = vol;
|
|
544
|
+
if (calMin === undefined || calMax === undefined || !Number.isFinite(calMin) || !Number.isFinite(calMax))
|
|
545
|
+
return;
|
|
546
|
+
return { window: calMax - calMin, level: (calMin + calMax) / 2 };
|
|
547
|
+
}
|
|
548
|
+
function recordWindowLevel(entry, calMin, calMax) {
|
|
549
|
+
if (entry)
|
|
550
|
+
entry.windowLevel = {
|
|
551
|
+
window: calMax - calMin,
|
|
552
|
+
level: (calMin + calMax) / 2
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function sameWindowLevel(a, b) {
|
|
556
|
+
const eps = Math.max(0.001, Math.abs(a.window) * 0.001);
|
|
557
|
+
return Math.abs(a.window - b.window) < eps && Math.abs(a.level - b.level) < eps;
|
|
558
|
+
}
|
|
559
|
+
function viewportEntries(servicesManager) {
|
|
560
|
+
const grid = ohifServices(servicesManager)?.viewportGridService;
|
|
561
|
+
const viewports = grid?.getState?.().viewports;
|
|
562
|
+
if (!viewports)
|
|
563
|
+
return [];
|
|
564
|
+
const raw = viewports instanceof Map ? [...viewports.entries()] : Object.entries(viewports);
|
|
565
|
+
return raw.map(([id, vp]) => [id, vp?.displaySetInstanceUIDs ?? []]);
|
|
566
|
+
}
|
|
567
|
+
function syncWindowLevelToSiblings(entry, viewportId, wl, servicesManager, commandsManager) {
|
|
568
|
+
const baseUIDs = new Set(entry.displaySets.map((ds) => ds.displaySetInstanceUID).filter((u) => typeof u === "string"));
|
|
569
|
+
if (baseUIDs.size === 0)
|
|
570
|
+
return;
|
|
571
|
+
const cm = ohifCommandsManager(commandsManager);
|
|
572
|
+
if (!cm)
|
|
573
|
+
return;
|
|
574
|
+
for (const [id, uids] of viewportEntries(servicesManager)) {
|
|
575
|
+
if (id === viewportId || !uids.some((u) => baseUIDs.has(u)))
|
|
576
|
+
continue;
|
|
577
|
+
try {
|
|
578
|
+
cm.runCommand?.("setViewportWindowLevel", {
|
|
579
|
+
viewportId: id,
|
|
580
|
+
windowWidth: wl.window,
|
|
581
|
+
windowCenter: wl.level
|
|
582
|
+
});
|
|
583
|
+
} catch (err) {
|
|
584
|
+
console.warn("[nv-ohif] setViewportWindowLevel sync failed", err);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
var POLYLINE_VALUE_TYPE = "value_type::polyline";
|
|
589
|
+
function annotationToolType(a) {
|
|
590
|
+
return a.shape?.type ?? (a.polygons[0]?.outer.length ? "freehand" : undefined);
|
|
591
|
+
}
|
|
592
|
+
var DEFAULT_LABEL_PREFIX = {
|
|
593
|
+
measureLine: "Length",
|
|
594
|
+
arrow: "Arrow",
|
|
595
|
+
measureBidirectional: "Bidirectional",
|
|
596
|
+
measureEllipse: "ROI",
|
|
597
|
+
measureRect: "ROI",
|
|
598
|
+
measureCircle: "ROI",
|
|
599
|
+
measureSpline: "ROI",
|
|
600
|
+
measureLivewire: "ROI",
|
|
601
|
+
freehand: "ROI"
|
|
602
|
+
};
|
|
603
|
+
function defaultLabelPrefix(a) {
|
|
604
|
+
const type = annotationToolType(a);
|
|
605
|
+
return type && DEFAULT_LABEL_PREFIX[type] || "ROI";
|
|
606
|
+
}
|
|
607
|
+
function applyDefaultAnnotationText(annotation, annotations) {
|
|
608
|
+
if (annotation.text?.length)
|
|
609
|
+
return false;
|
|
610
|
+
if (annotationToolType(annotation) === "measureLine")
|
|
611
|
+
return false;
|
|
612
|
+
const prefix = defaultLabelPrefix(annotation);
|
|
613
|
+
const re = new RegExp(`^${prefix} #(\\d+)$`);
|
|
614
|
+
let maxN = 0;
|
|
615
|
+
for (const candidate of annotations) {
|
|
616
|
+
const m = candidate.text ? re.exec(candidate.text) : null;
|
|
617
|
+
if (m) {
|
|
618
|
+
const n = Number(m[1]);
|
|
619
|
+
if (n > maxN)
|
|
620
|
+
maxN = n;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const text = `${prefix} #${maxN + 1}`;
|
|
624
|
+
annotation.text = text;
|
|
625
|
+
const stored = annotations.find((c) => c.id === annotation.id);
|
|
626
|
+
if (stored)
|
|
627
|
+
stored.text = text;
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
var ELLIPSE_VALUE_TYPE = "value_type::ellipse";
|
|
631
|
+
var CIRCLE_VALUE_TYPE = "value_type::circle";
|
|
632
|
+
var POINT_VALUE_TYPE = "value_type::point";
|
|
633
|
+
var BIDIRECTIONAL_VALUE_TYPE = "value_type::bidirectional";
|
|
634
|
+
var ANNOTATION_TO_OHIF = {
|
|
635
|
+
measureEllipse: {
|
|
636
|
+
toolName: "EllipticalROI",
|
|
637
|
+
valueType: ELLIPSE_VALUE_TYPE,
|
|
638
|
+
minPoints: 4
|
|
639
|
+
},
|
|
640
|
+
measureRect: {
|
|
641
|
+
toolName: "RectangleROI",
|
|
642
|
+
valueType: POLYLINE_VALUE_TYPE,
|
|
643
|
+
minPoints: 4
|
|
644
|
+
},
|
|
645
|
+
measureCircle: {
|
|
646
|
+
toolName: "CircleROI",
|
|
647
|
+
valueType: CIRCLE_VALUE_TYPE,
|
|
648
|
+
minPoints: 2
|
|
649
|
+
},
|
|
650
|
+
measureLine: {
|
|
651
|
+
toolName: "Length",
|
|
652
|
+
valueType: POLYLINE_VALUE_TYPE,
|
|
653
|
+
minPoints: 2,
|
|
654
|
+
points: 2
|
|
655
|
+
},
|
|
656
|
+
measureSpline: {
|
|
657
|
+
toolName: "SplineROI",
|
|
658
|
+
valueType: POLYLINE_VALUE_TYPE,
|
|
659
|
+
minPoints: 2
|
|
660
|
+
},
|
|
661
|
+
measureLivewire: {
|
|
662
|
+
toolName: "LivewireContour",
|
|
663
|
+
valueType: POLYLINE_VALUE_TYPE,
|
|
664
|
+
minPoints: 2
|
|
665
|
+
},
|
|
666
|
+
measureBidirectional: {
|
|
667
|
+
toolName: "Bidirectional",
|
|
668
|
+
valueType: BIDIRECTIONAL_VALUE_TYPE,
|
|
669
|
+
minPoints: 4
|
|
670
|
+
},
|
|
671
|
+
freehand: {
|
|
672
|
+
toolName: "PlanarFreehandROI",
|
|
673
|
+
valueType: POLYLINE_VALUE_TYPE,
|
|
674
|
+
minPoints: 2
|
|
675
|
+
},
|
|
676
|
+
arrow: {
|
|
677
|
+
toolName: "ArrowAnnotate",
|
|
678
|
+
valueType: POINT_VALUE_TYPE,
|
|
679
|
+
minPoints: 2,
|
|
680
|
+
points: 2
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
var measurementSources = new WeakMap;
|
|
684
|
+
function niivueMeasurementSource(measurementService) {
|
|
685
|
+
const cached = measurementSources.get(measurementService);
|
|
686
|
+
if (cached)
|
|
687
|
+
return cached;
|
|
688
|
+
const source = measurementService.createSource("NiiVue", "1.0");
|
|
689
|
+
for (const { toolName, valueType, points } of Object.values(ANNOTATION_TO_OHIF)) {
|
|
690
|
+
measurementService.addMapping(source, toolName, [{ valueType, ...points === undefined ? {} : { points } }], () => ({}), (data) => data.measurement);
|
|
691
|
+
}
|
|
692
|
+
measurementSources.set(measurementService, source);
|
|
693
|
+
return source;
|
|
694
|
+
}
|
|
695
|
+
var niivueMeasurementCounter = 0;
|
|
696
|
+
function resolveBackingSeries(entry, displaySetService) {
|
|
697
|
+
for (const ds of entry.displaySets) {
|
|
698
|
+
if (!ds.SeriesInstanceUID)
|
|
699
|
+
continue;
|
|
700
|
+
const resolved = displaySetService.getDisplaySetsForSeries?.(ds.SeriesInstanceUID);
|
|
701
|
+
const withInstances = resolved?.find((r) => (r.instances?.length ?? 0) > 0);
|
|
702
|
+
if (withInstances?.SeriesInstanceUID)
|
|
703
|
+
return withInstances;
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
function rasToLps(p) {
|
|
708
|
+
return [p[0] === 0 ? 0 : -p[0], p[1] === 0 ? 0 : -p[1], p[2]];
|
|
709
|
+
}
|
|
710
|
+
function annotationPointToLps(annotation, point) {
|
|
711
|
+
return rasToLps(slice2DToMM(point, annotation.slicePosition, annotation.sliceType));
|
|
712
|
+
}
|
|
713
|
+
function annotationPointsLps(annotation) {
|
|
714
|
+
const shape = annotation.shape;
|
|
715
|
+
if (!shape) {
|
|
716
|
+
const outer = annotation.polygons[0]?.outer;
|
|
717
|
+
if (outer && outer.length >= 2)
|
|
718
|
+
return outer.map((point) => annotationPointToLps(annotation, point));
|
|
719
|
+
return annotation.anchorMM ? [rasToLps(annotation.anchorMM)] : [];
|
|
720
|
+
}
|
|
721
|
+
const { start, end } = shape;
|
|
722
|
+
const cx = (start.x + end.x) / 2;
|
|
723
|
+
const cy = (start.y + end.y) / 2;
|
|
724
|
+
const minX = Math.min(start.x, end.x);
|
|
725
|
+
const maxX = Math.max(start.x, end.x);
|
|
726
|
+
const minY = Math.min(start.y, end.y);
|
|
727
|
+
const maxY = Math.max(start.y, end.y);
|
|
728
|
+
let points;
|
|
729
|
+
switch (shape.type) {
|
|
730
|
+
case "measureEllipse":
|
|
731
|
+
points = [
|
|
732
|
+
{ x: cx, y: minY },
|
|
733
|
+
{ x: cx, y: maxY },
|
|
734
|
+
{ x: minX, y: cy },
|
|
735
|
+
{ x: maxX, y: cy }
|
|
736
|
+
];
|
|
737
|
+
break;
|
|
738
|
+
case "measureRect":
|
|
739
|
+
points = [
|
|
740
|
+
{ x: minX, y: minY },
|
|
741
|
+
{ x: maxX, y: minY },
|
|
742
|
+
{ x: maxX, y: maxY },
|
|
743
|
+
{ x: minX, y: maxY }
|
|
744
|
+
];
|
|
745
|
+
break;
|
|
746
|
+
case "measureCircle":
|
|
747
|
+
points = [
|
|
748
|
+
{ x: cx, y: cy },
|
|
749
|
+
{ x: maxX, y: cy }
|
|
750
|
+
];
|
|
751
|
+
break;
|
|
752
|
+
case "measureBidirectional":
|
|
753
|
+
points = shape.start2 && shape.end2 ? [start, end, shape.start2, shape.end2] : [start, end];
|
|
754
|
+
break;
|
|
755
|
+
case "measureSpline":
|
|
756
|
+
case "measureLivewire":
|
|
757
|
+
case "freehand": {
|
|
758
|
+
const outer = annotation.polygons[0]?.outer;
|
|
759
|
+
points = outer && outer.length >= 2 ? outer : [start, end];
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
case "measureLine":
|
|
763
|
+
points = [start, end];
|
|
764
|
+
break;
|
|
765
|
+
case "arrow":
|
|
766
|
+
points = [end, start];
|
|
767
|
+
break;
|
|
768
|
+
default:
|
|
769
|
+
points = [start];
|
|
770
|
+
}
|
|
771
|
+
return points.map((point) => annotationPointToLps(annotation, point));
|
|
772
|
+
}
|
|
773
|
+
var reflectedAnnotations = new Map;
|
|
774
|
+
function annotationContentHash(a) {
|
|
775
|
+
const s = a.stats;
|
|
776
|
+
const shape = a.shape;
|
|
777
|
+
return JSON.stringify([
|
|
778
|
+
a.text ?? "",
|
|
779
|
+
a.sliceType,
|
|
780
|
+
a.slicePosition,
|
|
781
|
+
a.anchorMM ?? null,
|
|
782
|
+
s ? [s.area, s.min, s.mean, s.max, s.stdDev, s.length, s.shortLength] : 0,
|
|
783
|
+
shape ? [shape.type, shape.start, shape.end, shape.start2, shape.end2] : 0,
|
|
784
|
+
a.polygons.map((polygon) => [
|
|
785
|
+
polygon.outer.map((p) => [p.x, p.y]),
|
|
786
|
+
polygon.holes.map((hole) => hole.map((p) => [p.x, p.y]))
|
|
787
|
+
])
|
|
788
|
+
]);
|
|
789
|
+
}
|
|
790
|
+
var measurementToAnnotation = new Map;
|
|
791
|
+
var removingUids = new Set;
|
|
792
|
+
var hiddenAnnotations = new Map;
|
|
793
|
+
var labelSyncSubscriptions = new WeakMap;
|
|
794
|
+
function releaseLabelSync(service) {
|
|
795
|
+
const subscription = labelSyncSubscriptions.get(service);
|
|
796
|
+
if (!subscription)
|
|
797
|
+
return;
|
|
798
|
+
subscription.refCount -= 1;
|
|
799
|
+
if (subscription.refCount > 0)
|
|
800
|
+
return;
|
|
801
|
+
subscription.unsubscribe();
|
|
802
|
+
labelSyncSubscriptions.delete(service);
|
|
803
|
+
}
|
|
804
|
+
function modalityUnit(entry) {
|
|
805
|
+
const modality = baseModality(entry);
|
|
806
|
+
if (modality === "CT")
|
|
807
|
+
return "HU";
|
|
808
|
+
if (modality === "PT")
|
|
809
|
+
return "SUV";
|
|
810
|
+
return "";
|
|
811
|
+
}
|
|
812
|
+
function buildAnnotationDisplay(toolName, stats, unit) {
|
|
813
|
+
const label = `NiiVue ${toolName}`;
|
|
814
|
+
if (toolName === "ArrowAnnotate" || !stats) {
|
|
815
|
+
return { primary: [label], data: {}, label };
|
|
816
|
+
}
|
|
817
|
+
if (toolName === "Length" && stats.length !== undefined) {
|
|
818
|
+
return {
|
|
819
|
+
primary: [`${stats.length.toFixed(1)} mm`],
|
|
820
|
+
data: { length: stats.length, unit: "mm" },
|
|
821
|
+
label
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
if (toolName === "Bidirectional" && stats.length !== undefined) {
|
|
825
|
+
const short = stats.shortLength ?? 0;
|
|
826
|
+
return {
|
|
827
|
+
primary: [
|
|
828
|
+
`L: ${stats.length.toFixed(1)} mm`,
|
|
829
|
+
`W: ${short.toFixed(1)} mm`
|
|
830
|
+
],
|
|
831
|
+
data: { length: stats.length, width: short, unit: "mm" },
|
|
832
|
+
label
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
const withUnit = (v, key) => unit ? `${key}: ${v.toFixed(1)} ${unit}` : `${key}: ${v.toFixed(1)}`;
|
|
836
|
+
return {
|
|
837
|
+
primary: [
|
|
838
|
+
`${stats.area.toFixed(1)} mm²`,
|
|
839
|
+
withUnit(stats.max, "Max"),
|
|
840
|
+
withUnit(stats.mean, "Mean")
|
|
841
|
+
],
|
|
842
|
+
data: {
|
|
843
|
+
area: stats.area,
|
|
844
|
+
mean: stats.mean,
|
|
845
|
+
stdDev: stats.stdDev,
|
|
846
|
+
max: stats.max,
|
|
847
|
+
min: stats.min,
|
|
848
|
+
unit,
|
|
849
|
+
areaUnit: "mm²"
|
|
850
|
+
},
|
|
851
|
+
label
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
function reflectNiivueAnnotationResult(viewportId, servicesManager, annotation, existingUid) {
|
|
855
|
+
const services = ohifServices(servicesManager);
|
|
856
|
+
const measurementService = services?.measurementService;
|
|
857
|
+
const displaySetService = services?.displaySetService;
|
|
858
|
+
if (!measurementService?.addRawMeasurement || !displaySetService?.getDisplaySetsForSeries)
|
|
859
|
+
return { status: "retryableFailure" };
|
|
860
|
+
const toolType = annotationToolType(annotation);
|
|
861
|
+
const mapping = toolType ? ANNOTATION_TO_OHIF[toolType] : undefined;
|
|
862
|
+
if (!mapping)
|
|
863
|
+
return { status: "permanentlyUnsupported" };
|
|
864
|
+
if (toolType === "freehand" && (annotation.polygons.length !== 1 || (annotation.polygons[0]?.holes.length ?? 0) > 0))
|
|
865
|
+
return { status: "permanentlyUnsupported" };
|
|
866
|
+
const entry = getNiivueEntry(viewportId);
|
|
867
|
+
if (!entry)
|
|
868
|
+
return { status: "retryableFailure" };
|
|
869
|
+
const backing = resolveBackingSeries(entry, displaySetService);
|
|
870
|
+
if (!backing?.SeriesInstanceUID)
|
|
871
|
+
return { status: "retryableFailure" };
|
|
872
|
+
const source = niivueMeasurementSource(measurementService);
|
|
873
|
+
const uid = existingUid ?? `niivue-${mapping.toolName}-${++niivueMeasurementCounter}`;
|
|
874
|
+
const forUID = backing.instances?.[0]?.FrameOfReferenceUID;
|
|
875
|
+
const points = annotationPointsLps(annotation);
|
|
876
|
+
if (points.length < (mapping.minPoints ?? 0))
|
|
877
|
+
return { status: "permanentlyUnsupported" };
|
|
878
|
+
const unit = modalityUnit(entry);
|
|
879
|
+
const { primary, data } = buildAnnotationDisplay(mapping.toolName, annotation.stats, unit);
|
|
880
|
+
const rowLabel = annotation.text ?? "";
|
|
881
|
+
let acceptedUid;
|
|
882
|
+
try {
|
|
883
|
+
acceptedUid = measurementService.addRawMeasurement(source, mapping.toolName, {
|
|
884
|
+
uid,
|
|
885
|
+
annotation: { data: {} },
|
|
886
|
+
measurement: {
|
|
887
|
+
uid,
|
|
888
|
+
toolName: mapping.toolName,
|
|
889
|
+
label: rowLabel,
|
|
890
|
+
referenceSeriesUID: backing.SeriesInstanceUID,
|
|
891
|
+
referenceStudyUID: backing.StudyInstanceUID,
|
|
892
|
+
displaySetInstanceUID: backing.displaySetInstanceUID,
|
|
893
|
+
FrameOfReferenceUID: forUID,
|
|
894
|
+
points,
|
|
895
|
+
displayText: { primary, secondary: [] },
|
|
896
|
+
data,
|
|
897
|
+
type: mapping.valueType,
|
|
898
|
+
metadata: { toolName: mapping.toolName, FrameOfReferenceUID: forUID }
|
|
899
|
+
}
|
|
900
|
+
}, (d) => d.measurement);
|
|
901
|
+
} catch {
|
|
902
|
+
return { status: "retryableFailure" };
|
|
903
|
+
}
|
|
904
|
+
if (!acceptedUid)
|
|
905
|
+
return { status: "retryableFailure" };
|
|
906
|
+
let byView = reflectedAnnotations.get(viewportId);
|
|
907
|
+
if (!byView) {
|
|
908
|
+
byView = new Map;
|
|
909
|
+
reflectedAnnotations.set(viewportId, byView);
|
|
910
|
+
}
|
|
911
|
+
byView.set(annotation.id, {
|
|
912
|
+
uid: acceptedUid,
|
|
913
|
+
hash: annotationContentHash(annotation)
|
|
914
|
+
});
|
|
915
|
+
measurementToAnnotation.set(acceptedUid, {
|
|
916
|
+
viewportId,
|
|
917
|
+
annotationId: annotation.id
|
|
918
|
+
});
|
|
919
|
+
return { status: "success" };
|
|
920
|
+
}
|
|
921
|
+
function reflectNiivueAnnotation(viewportId, servicesManager, annotation, existingUid) {
|
|
922
|
+
return reflectNiivueAnnotationResult(viewportId, servicesManager, annotation, existingUid).status === "success";
|
|
923
|
+
}
|
|
924
|
+
function applyOhifLabelToAnnotation(measurementUid, label) {
|
|
925
|
+
const ref = measurementToAnnotation.get(measurementUid);
|
|
926
|
+
if (!ref)
|
|
927
|
+
return;
|
|
928
|
+
const entry = getNiivueEntry(ref.viewportId);
|
|
929
|
+
if (!entry)
|
|
930
|
+
return;
|
|
931
|
+
const current = entry.nv.annotations.find((a) => a.id === ref.annotationId);
|
|
932
|
+
if (current && (current.text ?? "") === (label ?? ""))
|
|
933
|
+
return;
|
|
934
|
+
entry.nv.setAnnotationText(ref.annotationId, label ?? "");
|
|
935
|
+
}
|
|
936
|
+
function jumpToNiivueMeasurement(measurementUid, servicesManager) {
|
|
937
|
+
const ref = measurementToAnnotation.get(measurementUid);
|
|
938
|
+
if (!ref)
|
|
939
|
+
return;
|
|
940
|
+
const entry = getNiivueEntry(ref.viewportId);
|
|
941
|
+
const annotation = entry?.nv.annotations.find((candidate) => candidate.id === ref.annotationId);
|
|
942
|
+
if (!entry || !annotation)
|
|
943
|
+
return;
|
|
944
|
+
const services = ohifServices(servicesManager);
|
|
945
|
+
const viewportGridService = services?.viewportGridService;
|
|
946
|
+
viewportGridService?.setActiveViewportId?.(ref.viewportId);
|
|
947
|
+
let target;
|
|
948
|
+
const shape = annotation.shape;
|
|
949
|
+
if (shape) {
|
|
950
|
+
const at = shape.type === "arrow" ? shape.end : {
|
|
951
|
+
x: (shape.start.x + shape.end.x) / 2,
|
|
952
|
+
y: (shape.start.y + shape.end.y) / 2
|
|
953
|
+
};
|
|
954
|
+
target = slice2DToMM(at, annotation.slicePosition, annotation.sliceType);
|
|
955
|
+
} else {
|
|
956
|
+
const outer = annotation.polygons[0]?.outer;
|
|
957
|
+
if (outer && outer.length > 0) {
|
|
958
|
+
const sum = outer.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { x: 0, y: 0 });
|
|
959
|
+
target = slice2DToMM({ x: sum.x / outer.length, y: sum.y / outer.length }, annotation.slicePosition, annotation.sliceType);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (!target)
|
|
963
|
+
target = annotation.anchorMM;
|
|
964
|
+
if (target)
|
|
965
|
+
entry.nv.setCrosshairPos(target);
|
|
966
|
+
entry.nv.selectAnnotation(annotation.id);
|
|
967
|
+
}
|
|
968
|
+
function applyOhifRemoveToAnnotation(measurementUid) {
|
|
969
|
+
if (removingUids.has(measurementUid))
|
|
970
|
+
return;
|
|
971
|
+
const ref = measurementToAnnotation.get(measurementUid);
|
|
972
|
+
if (!ref)
|
|
973
|
+
return;
|
|
974
|
+
const byView = reflectedAnnotations.get(ref.viewportId);
|
|
975
|
+
byView?.delete(ref.annotationId);
|
|
976
|
+
if (byView && byView.size === 0)
|
|
977
|
+
reflectedAnnotations.delete(ref.viewportId);
|
|
978
|
+
measurementToAnnotation.delete(measurementUid);
|
|
979
|
+
clearHiddenAnnotation(ref.viewportId, ref.annotationId);
|
|
980
|
+
getNiivueEntry(ref.viewportId)?.nv.removeAnnotation(ref.annotationId);
|
|
981
|
+
}
|
|
982
|
+
function clearHiddenAnnotation(viewportId, annotationId) {
|
|
983
|
+
const set = hiddenAnnotations.get(viewportId);
|
|
984
|
+
if (!set)
|
|
985
|
+
return;
|
|
986
|
+
set.delete(annotationId);
|
|
987
|
+
if (set.size === 0)
|
|
988
|
+
hiddenAnnotations.delete(viewportId);
|
|
989
|
+
}
|
|
990
|
+
function visibleAnnotationScreenShapes(viewportId, shapes) {
|
|
991
|
+
const hidden = hiddenAnnotations.get(viewportId);
|
|
992
|
+
if (!hidden || hidden.size === 0)
|
|
993
|
+
return shapes;
|
|
994
|
+
return shapes.filter((shape) => !hidden.has(shape.id));
|
|
995
|
+
}
|
|
996
|
+
function applyOhifVisibilityToAnnotation(measurementUid, isVisible) {
|
|
997
|
+
const ref = measurementToAnnotation.get(measurementUid);
|
|
998
|
+
if (!ref)
|
|
999
|
+
return;
|
|
1000
|
+
const set = hiddenAnnotations.get(ref.viewportId);
|
|
1001
|
+
const currentlyHidden = set?.has(ref.annotationId) ?? false;
|
|
1002
|
+
if (isVisible === !currentlyHidden)
|
|
1003
|
+
return;
|
|
1004
|
+
if (isVisible) {
|
|
1005
|
+
clearHiddenAnnotation(ref.viewportId, ref.annotationId);
|
|
1006
|
+
} else {
|
|
1007
|
+
const target = set ?? new Set;
|
|
1008
|
+
target.add(ref.annotationId);
|
|
1009
|
+
hiddenAnnotations.set(ref.viewportId, target);
|
|
1010
|
+
}
|
|
1011
|
+
getNiivueEntry(ref.viewportId)?.nv.drawScene();
|
|
1012
|
+
}
|
|
1013
|
+
function subscribeOhifLabelSync(servicesManager) {
|
|
1014
|
+
const svc = ohifServices(servicesManager)?.measurementService;
|
|
1015
|
+
const labelEvent = svc?.EVENTS?.MEASUREMENT_UPDATED;
|
|
1016
|
+
const jumpEvent = svc?.EVENTS?.JUMP_TO_MEASUREMENT;
|
|
1017
|
+
const removeEvent = svc?.EVENTS?.MEASUREMENT_REMOVED;
|
|
1018
|
+
if (!svc?.subscribe || !labelEvent && !jumpEvent && !removeEvent)
|
|
1019
|
+
return;
|
|
1020
|
+
const existing = labelSyncSubscriptions.get(svc);
|
|
1021
|
+
if (existing) {
|
|
1022
|
+
existing.refCount += 1;
|
|
1023
|
+
return () => releaseLabelSync(svc);
|
|
1024
|
+
}
|
|
1025
|
+
const subscriptions = [];
|
|
1026
|
+
if (labelEvent) {
|
|
1027
|
+
subscriptions.push(svc.subscribe(labelEvent, (payload) => {
|
|
1028
|
+
const m = payload?.measurement;
|
|
1029
|
+
if (typeof m !== "object" || !m?.uid)
|
|
1030
|
+
return;
|
|
1031
|
+
if (typeof m.label === "string")
|
|
1032
|
+
applyOhifLabelToAnnotation(m.uid, m.label);
|
|
1033
|
+
if (typeof m.isVisible === "boolean")
|
|
1034
|
+
applyOhifVisibilityToAnnotation(m.uid, m.isVisible);
|
|
1035
|
+
}));
|
|
1036
|
+
}
|
|
1037
|
+
if (jumpEvent) {
|
|
1038
|
+
subscriptions.push(svc.subscribe(jumpEvent, (payload) => {
|
|
1039
|
+
const m = payload?.measurement;
|
|
1040
|
+
const uid = typeof m === "object" ? m?.uid : undefined;
|
|
1041
|
+
if (uid)
|
|
1042
|
+
jumpToNiivueMeasurement(uid, servicesManager);
|
|
1043
|
+
}));
|
|
1044
|
+
}
|
|
1045
|
+
if (removeEvent) {
|
|
1046
|
+
subscriptions.push(svc.subscribe(removeEvent, (payload) => {
|
|
1047
|
+
const m = payload?.measurement;
|
|
1048
|
+
const uid = typeof m === "string" ? m : m?.uid;
|
|
1049
|
+
if (uid)
|
|
1050
|
+
applyOhifRemoveToAnnotation(uid);
|
|
1051
|
+
}));
|
|
1052
|
+
}
|
|
1053
|
+
labelSyncSubscriptions.set(svc, {
|
|
1054
|
+
refCount: 1,
|
|
1055
|
+
unsubscribe: () => {
|
|
1056
|
+
for (const subscription of subscriptions)
|
|
1057
|
+
subscription?.unsubscribe?.();
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
return () => releaseLabelSync(svc);
|
|
1061
|
+
}
|
|
1062
|
+
function removeNiivueAnnotation(viewportId, servicesManager, annotationId) {
|
|
1063
|
+
const byView = reflectedAnnotations.get(viewportId);
|
|
1064
|
+
const row = byView?.get(annotationId);
|
|
1065
|
+
if (!byView || !row)
|
|
1066
|
+
return true;
|
|
1067
|
+
const measurementService = ohifServices(servicesManager)?.measurementService;
|
|
1068
|
+
if (row.uid) {
|
|
1069
|
+
if (measurementService?.remove) {
|
|
1070
|
+
removingUids.add(row.uid);
|
|
1071
|
+
try {
|
|
1072
|
+
measurementService.remove(row.uid);
|
|
1073
|
+
} catch {
|
|
1074
|
+
return false;
|
|
1075
|
+
} finally {
|
|
1076
|
+
removingUids.delete(row.uid);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
measurementToAnnotation.delete(row.uid);
|
|
1080
|
+
}
|
|
1081
|
+
byView.delete(annotationId);
|
|
1082
|
+
if (byView.size === 0)
|
|
1083
|
+
reflectedAnnotations.delete(viewportId);
|
|
1084
|
+
const stillPresent = getNiivueEntry(viewportId)?.nv.annotations.some((a) => a.id === annotationId) ?? false;
|
|
1085
|
+
if (!stillPresent)
|
|
1086
|
+
clearHiddenAnnotation(viewportId, annotationId);
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
function clearNiivueAnnotations(viewportId, servicesManager) {
|
|
1090
|
+
const byView = reflectedAnnotations.get(viewportId);
|
|
1091
|
+
if (!byView)
|
|
1092
|
+
return;
|
|
1093
|
+
const measurementService = ohifServices(servicesManager)?.measurementService;
|
|
1094
|
+
for (const { uid } of byView.values()) {
|
|
1095
|
+
if (!uid)
|
|
1096
|
+
continue;
|
|
1097
|
+
removingUids.add(uid);
|
|
1098
|
+
try {
|
|
1099
|
+
measurementService?.remove?.(uid);
|
|
1100
|
+
} catch {} finally {
|
|
1101
|
+
removingUids.delete(uid);
|
|
1102
|
+
}
|
|
1103
|
+
measurementToAnnotation.delete(uid);
|
|
1104
|
+
}
|
|
1105
|
+
reflectedAnnotations.delete(viewportId);
|
|
1106
|
+
hiddenAnnotations.delete(viewportId);
|
|
1107
|
+
}
|
|
1108
|
+
var reconcilingViewports = new Set;
|
|
1109
|
+
function reconcileNiivueAnnotations(viewportId, servicesManager) {
|
|
1110
|
+
if (reconcilingViewports.has(viewportId))
|
|
1111
|
+
return;
|
|
1112
|
+
const entry = getNiivueEntry(viewportId);
|
|
1113
|
+
if (!entry)
|
|
1114
|
+
return;
|
|
1115
|
+
reconcilingViewports.add(viewportId);
|
|
1116
|
+
try {
|
|
1117
|
+
const live = entry.nv.annotations;
|
|
1118
|
+
const liveIds = new Set(live.map((a) => a.id));
|
|
1119
|
+
const byView = reflectedAnnotations.get(viewportId);
|
|
1120
|
+
if (byView) {
|
|
1121
|
+
for (const annotationId of [...byView.keys()]) {
|
|
1122
|
+
if (!liveIds.has(annotationId))
|
|
1123
|
+
removeNiivueAnnotation(viewportId, servicesManager, annotationId);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
for (const annotation of live) {
|
|
1127
|
+
const existing = reflectedAnnotations.get(viewportId)?.get(annotation.id);
|
|
1128
|
+
const hash = annotationContentHash(annotation);
|
|
1129
|
+
if (existing && existing.hash === hash)
|
|
1130
|
+
continue;
|
|
1131
|
+
const result = reflectNiivueAnnotationResult(viewportId, servicesManager, annotation, existing?.uid);
|
|
1132
|
+
if (result.status === "permanentlyUnsupported") {
|
|
1133
|
+
const removed = existing?.uid ? removeNiivueAnnotation(viewportId, servicesManager, annotation.id) : true;
|
|
1134
|
+
if (!removed)
|
|
1135
|
+
continue;
|
|
1136
|
+
let map = reflectedAnnotations.get(viewportId);
|
|
1137
|
+
if (!map) {
|
|
1138
|
+
map = new Map;
|
|
1139
|
+
reflectedAnnotations.set(viewportId, map);
|
|
1140
|
+
}
|
|
1141
|
+
map.set(annotation.id, { hash });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
} finally {
|
|
1145
|
+
reconcilingViewports.delete(viewportId);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function syncNiivueWindowLevelToOhif(viewportId, servicesManager, commandsManager) {
|
|
1149
|
+
const entry = getNiivueEntry(viewportId);
|
|
1150
|
+
if (!entry)
|
|
1151
|
+
return;
|
|
1152
|
+
const wl = readBaseWindowLevel(entry.nv);
|
|
1153
|
+
if (!wl)
|
|
1154
|
+
return;
|
|
1155
|
+
if (entry.windowLevel && sameWindowLevel(entry.windowLevel, wl))
|
|
1156
|
+
return;
|
|
1157
|
+
const seeded = entry.windowLevel !== undefined;
|
|
1158
|
+
entry.windowLevel = wl;
|
|
1159
|
+
if (!seeded)
|
|
1160
|
+
return;
|
|
1161
|
+
syncWindowLevelToSiblings(entry, viewportId, wl, servicesManager, commandsManager);
|
|
1162
|
+
return wl;
|
|
1163
|
+
}
|
|
1164
|
+
function getNiivueCommandsModule({
|
|
1165
|
+
servicesManager,
|
|
1166
|
+
commandsManager
|
|
1167
|
+
}) {
|
|
1168
|
+
const actions = {
|
|
1169
|
+
niivueSetSliceType: ({ sliceType } = {}) => {
|
|
1170
|
+
const nv = getActiveNiivue(servicesManager);
|
|
1171
|
+
const mapped = sliceType ? NIIVUE_SLICE_TYPES[sliceType] : undefined;
|
|
1172
|
+
if (!nv || mapped === undefined)
|
|
1173
|
+
return;
|
|
1174
|
+
nv.sliceType = mapped;
|
|
1175
|
+
},
|
|
1176
|
+
niivueSetMeasurementMode: () => {
|
|
1177
|
+
const cmds = ohifCommandsManager(commandsManager);
|
|
1178
|
+
if (cmds?.runCommand) {
|
|
1179
|
+
cmds.runCommand("setToolActiveToolbar", { toolName: "Length" });
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
const nv = getActiveNiivue(servicesManager);
|
|
1183
|
+
if (nv) {
|
|
1184
|
+
nv.annotationTool = "measureLine";
|
|
1185
|
+
nv.annotationIsEnabled = true;
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
niivueResetView: () => {
|
|
1189
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1190
|
+
if (!entry)
|
|
1191
|
+
return;
|
|
1192
|
+
if (entry.slideView) {
|
|
1193
|
+
entry.slideView.resetView();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const { nv } = entry;
|
|
1197
|
+
nv.azimuth = VIEW_DEFAULTS.azimuth;
|
|
1198
|
+
nv.elevation = VIEW_DEFAULTS.elevation;
|
|
1199
|
+
nv.scaleMultiplier = VIEW_DEFAULTS.scaleMultiplier;
|
|
1200
|
+
nv.pan2Dxyzmm = [...VIEW_DEFAULTS.pan2Dxyzmm];
|
|
1201
|
+
nv.renderPan = [...VIEW_DEFAULTS.renderPan];
|
|
1202
|
+
nv.crosshairPos = [...VIEW_DEFAULTS.crosshairPos];
|
|
1203
|
+
},
|
|
1204
|
+
niivueSaveBitmap: async () => {
|
|
1205
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1206
|
+
if (!entry)
|
|
1207
|
+
return;
|
|
1208
|
+
if (entry.slideView) {
|
|
1209
|
+
await entry.slideView.saveBitmap();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
await entry.nv.saveBitmap("niivue.png");
|
|
1213
|
+
},
|
|
1214
|
+
niivueSetClipPlane: ({ plane } = {}) => {
|
|
1215
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1216
|
+
const preset = plane ? NIIVUE_CLIP_PLANES[plane] : undefined;
|
|
1217
|
+
if (!entry || !preset || plane === undefined)
|
|
1218
|
+
return;
|
|
1219
|
+
entry.nv.setClipPlane([...preset]);
|
|
1220
|
+
entry.clipPlane = plane;
|
|
1221
|
+
},
|
|
1222
|
+
niivueToggleOverlay: async () => {
|
|
1223
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1224
|
+
if (!entry || entry.overlayLoading)
|
|
1225
|
+
return;
|
|
1226
|
+
const { nv, viewportId } = entry;
|
|
1227
|
+
if (entry.overlayUIDs.length > 0) {
|
|
1228
|
+
while (nv.volumes.length > 1) {
|
|
1229
|
+
nv.model.removeVolume(nv.volumes.length - 1);
|
|
1230
|
+
}
|
|
1231
|
+
entry.overlayUIDs = [];
|
|
1232
|
+
await nv.updateGLVolume();
|
|
1233
|
+
refreshToolbar(servicesManager, viewportId);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (nv.volumes.length === 0)
|
|
1237
|
+
return;
|
|
1238
|
+
const dsService = ohifServices(servicesManager)?.displaySetService;
|
|
1239
|
+
const candidate = findOverlayCandidate(entry, dsService?.getActiveDisplaySets?.() ?? []);
|
|
1240
|
+
if (!candidate) {
|
|
1241
|
+
flashStatus(entry, "No other loadable series in this study.");
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const uid = String(candidate.displaySetInstanceUID);
|
|
1245
|
+
const label = typeof candidate.SeriesDescription === "string" ? candidate.SeriesDescription : "overlay";
|
|
1246
|
+
const baseDisplaySets = entry.displaySets;
|
|
1247
|
+
const stillLive = () => getNiivueEntry(viewportId) === entry && entry.displaySets === baseDisplaySets;
|
|
1248
|
+
const addOverlayGuarded = async (spec) => {
|
|
1249
|
+
const before = nv.volumes.length;
|
|
1250
|
+
await nv.addVolume(spec);
|
|
1251
|
+
if (stillLive())
|
|
1252
|
+
return true;
|
|
1253
|
+
if (nv.volumes.length === before + 1) {
|
|
1254
|
+
nv.model.removeVolume(nv.volumes.length - 1);
|
|
1255
|
+
await nv.updateGLVolume();
|
|
1256
|
+
}
|
|
1257
|
+
return false;
|
|
1258
|
+
};
|
|
1259
|
+
entry.overlayLoading = true;
|
|
1260
|
+
try {
|
|
1261
|
+
const direct = displaySetToNiivue(candidate);
|
|
1262
|
+
let added;
|
|
1263
|
+
if (direct) {
|
|
1264
|
+
if (!stillLive())
|
|
1265
|
+
return;
|
|
1266
|
+
added = await addOverlayGuarded({
|
|
1267
|
+
...direct,
|
|
1268
|
+
colormap: OVERLAY_COLORMAP,
|
|
1269
|
+
opacity: OVERLAY_OPACITY
|
|
1270
|
+
});
|
|
1271
|
+
} else {
|
|
1272
|
+
writeStatus(entry, `Fetching overlay: ${label}...`);
|
|
1273
|
+
const niftiFile = await convertDisplaySetToNifti(candidate, {
|
|
1274
|
+
headers: authHeaders(servicesManager),
|
|
1275
|
+
onProgress: (phase, loaded, total) => {
|
|
1276
|
+
writeStatus(entry, phase === "fetching" ? `Fetching overlay: ${label}... ${loaded}/${total}` : `Converting overlay: ${label} (dcm2niix)...`);
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
if (!stillLive())
|
|
1280
|
+
return;
|
|
1281
|
+
if (!niftiFile)
|
|
1282
|
+
throw new Error("conversion produced no volume");
|
|
1283
|
+
added = await addOverlayGuarded({
|
|
1284
|
+
url: niftiFile,
|
|
1285
|
+
name: niftiFile.name,
|
|
1286
|
+
colormap: OVERLAY_COLORMAP,
|
|
1287
|
+
opacity: OVERLAY_OPACITY
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
if (!added)
|
|
1291
|
+
return;
|
|
1292
|
+
entry.overlayUIDs.push(uid);
|
|
1293
|
+
writeStatus(entry, null);
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
console.error("[nv-ohif] overlay load failed", err);
|
|
1296
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1297
|
+
flashStatus(entry, `Overlay load failed: ${message || "unknown error"}`);
|
|
1298
|
+
} finally {
|
|
1299
|
+
if (stillLive()) {
|
|
1300
|
+
entry.overlayLoading = false;
|
|
1301
|
+
refreshToolbar(servicesManager, viewportId);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
},
|
|
1305
|
+
niivueSetWindowLevel: ({
|
|
1306
|
+
window: window2,
|
|
1307
|
+
level
|
|
1308
|
+
} = {}) => {
|
|
1309
|
+
const nv = getActiveNiivue(servicesManager);
|
|
1310
|
+
if (!nv || nv.volumes.length === 0)
|
|
1311
|
+
return;
|
|
1312
|
+
if (window2 === undefined || level === undefined || !Number.isFinite(window2) || !Number.isFinite(level))
|
|
1313
|
+
return;
|
|
1314
|
+
const [calMin, calMax] = window2 === 0 ? [0, level] : [level - window2 / 2, level + window2 / 2];
|
|
1315
|
+
nv.setVolume(0, { calMin, calMax });
|
|
1316
|
+
recordWindowLevel(getActiveNiivueEntry(servicesManager), calMin, calMax);
|
|
1317
|
+
},
|
|
1318
|
+
niivueSetWindowLevelPreset: ({
|
|
1319
|
+
presetId,
|
|
1320
|
+
presetIndex
|
|
1321
|
+
} = {}) => {
|
|
1322
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1323
|
+
if (!entry || entry.nv.volumes.length === 0)
|
|
1324
|
+
return;
|
|
1325
|
+
const range = resolveWindowLevel(windowLevelPresets(servicesManager), baseModality(entry), presetId, presetIndex);
|
|
1326
|
+
if (!range)
|
|
1327
|
+
return;
|
|
1328
|
+
entry.nv.setVolume(0, { calMin: range[0], calMax: range[1] });
|
|
1329
|
+
recordWindowLevel(entry, range[0], range[1]);
|
|
1330
|
+
},
|
|
1331
|
+
niivueAutoWindowLevel: () => {
|
|
1332
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1333
|
+
if (!entry || entry.nv.volumes.length === 0)
|
|
1334
|
+
return;
|
|
1335
|
+
entry.nv.recalculateCalMinMax(0).then(() => {
|
|
1336
|
+
const wl = readBaseWindowLevel(entry.nv);
|
|
1337
|
+
if (wl)
|
|
1338
|
+
entry.windowLevel = wl;
|
|
1339
|
+
});
|
|
1340
|
+
},
|
|
1341
|
+
niivueSetColormap: ({ colormap } = {}) => {
|
|
1342
|
+
const nv = getActiveNiivue(servicesManager);
|
|
1343
|
+
if (!nv || nv.volumes.length === 0 || !colormap)
|
|
1344
|
+
return;
|
|
1345
|
+
nv.setVolume(0, { colormap });
|
|
1346
|
+
},
|
|
1347
|
+
niivueToggleColorbar: () => {
|
|
1348
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1349
|
+
if (!entry)
|
|
1350
|
+
return;
|
|
1351
|
+
entry.nv.isColorbarVisible = !entry.nv.isColorbarVisible;
|
|
1352
|
+
refreshToolbar(servicesManager, entry.viewportId);
|
|
1353
|
+
},
|
|
1354
|
+
niivueToggleInterpolation: () => {
|
|
1355
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1356
|
+
if (!entry)
|
|
1357
|
+
return;
|
|
1358
|
+
entry.nv.volumeIsNearestInterpolation = !entry.nv.volumeIsNearestInterpolation;
|
|
1359
|
+
refreshToolbar(servicesManager, entry.viewportId);
|
|
1360
|
+
},
|
|
1361
|
+
niivueToggleCrosshair: () => {
|
|
1362
|
+
const entry = getActiveNiivueEntry(servicesManager);
|
|
1363
|
+
if (!entry)
|
|
1364
|
+
return;
|
|
1365
|
+
const visible = entry.nv.crosshairWidth > 0;
|
|
1366
|
+
entry.nv.crosshairWidth = visible ? 0 : 1;
|
|
1367
|
+
entry.nv.is3DCrosshairVisible = !visible;
|
|
1368
|
+
entry.nv.drawScene();
|
|
1369
|
+
refreshToolbar(servicesManager, entry.viewportId);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
return {
|
|
1373
|
+
actions,
|
|
1374
|
+
definitions: {
|
|
1375
|
+
niivueSetSliceType: actions.niivueSetSliceType,
|
|
1376
|
+
niivueSetMeasurementMode: actions.niivueSetMeasurementMode,
|
|
1377
|
+
niivueResetView: actions.niivueResetView,
|
|
1378
|
+
niivueSaveBitmap: actions.niivueSaveBitmap,
|
|
1379
|
+
niivueSetClipPlane: actions.niivueSetClipPlane,
|
|
1380
|
+
niivueToggleOverlay: actions.niivueToggleOverlay,
|
|
1381
|
+
niivueSetWindowLevel: actions.niivueSetWindowLevel,
|
|
1382
|
+
niivueSetWindowLevelPreset: actions.niivueSetWindowLevelPreset,
|
|
1383
|
+
niivueAutoWindowLevel: actions.niivueAutoWindowLevel,
|
|
1384
|
+
niivueSetColormap: actions.niivueSetColormap,
|
|
1385
|
+
niivueToggleColorbar: actions.niivueToggleColorbar,
|
|
1386
|
+
niivueToggleInterpolation: actions.niivueToggleInterpolation,
|
|
1387
|
+
niivueToggleCrosshair: actions.niivueToggleCrosshair
|
|
1388
|
+
},
|
|
1389
|
+
defaultContext: "NIIVUE"
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/NiivueViewport.tsx
|
|
1394
|
+
import NiiVue, { NVSlide, SLICE_TYPE as SLICE_TYPE2 } from "@niivue/niivue";
|
|
1395
|
+
import { loadDefaultFont as loadDefaultFont2 } from "@niivue/uikit";
|
|
1396
|
+
import { useEffect, useRef, useState } from "react";
|
|
1397
|
+
|
|
1398
|
+
// src/toolBridge.ts
|
|
1399
|
+
import { DRAG_MODE } from "@niivue/niivue";
|
|
1400
|
+
function ohifToolToAnnotationTool(tool) {
|
|
1401
|
+
switch (tool) {
|
|
1402
|
+
case "Length":
|
|
1403
|
+
return "measureLine";
|
|
1404
|
+
case "EllipticalROI":
|
|
1405
|
+
return "measureEllipse";
|
|
1406
|
+
case "RectangleROI":
|
|
1407
|
+
return "measureRect";
|
|
1408
|
+
case "CircleROI":
|
|
1409
|
+
return "measureCircle";
|
|
1410
|
+
case "PlanarFreehandROI":
|
|
1411
|
+
return "freehand";
|
|
1412
|
+
case "SplineROI":
|
|
1413
|
+
return "measureSpline";
|
|
1414
|
+
case "LivewireContour":
|
|
1415
|
+
return "measureLivewire";
|
|
1416
|
+
case "Bidirectional":
|
|
1417
|
+
return "measureBidirectional";
|
|
1418
|
+
case "ArrowAnnotate":
|
|
1419
|
+
return "arrow";
|
|
1420
|
+
default:
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
var UNSUPPORTED_MEASUREMENT_TOOLS = new Set;
|
|
1425
|
+
function ohifToolToDragMode(tool) {
|
|
1426
|
+
switch (tool) {
|
|
1427
|
+
case "WindowLevel":
|
|
1428
|
+
return DRAG_MODE.windowing;
|
|
1429
|
+
case "Pan":
|
|
1430
|
+
return DRAG_MODE.pan;
|
|
1431
|
+
case "Zoom":
|
|
1432
|
+
return DRAG_MODE.slicer3D;
|
|
1433
|
+
case "Angle":
|
|
1434
|
+
case "CobbAngle":
|
|
1435
|
+
return DRAG_MODE.angle;
|
|
1436
|
+
case "RectangleROI":
|
|
1437
|
+
case "EllipticalROI":
|
|
1438
|
+
case "CircleROI":
|
|
1439
|
+
return DRAG_MODE.roiSelection;
|
|
1440
|
+
default:
|
|
1441
|
+
return DRAG_MODE.crosshair;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/volumeAnnotationOverlay.ts
|
|
1446
|
+
import {
|
|
1447
|
+
buildAnnotationGeometry,
|
|
1448
|
+
UIKitLineOverlay,
|
|
1449
|
+
UIKitTextOverlay
|
|
1450
|
+
} from "@niivue/uikit";
|
|
1451
|
+
var LABEL_CSS_PX = 14;
|
|
1452
|
+
|
|
1453
|
+
class VolumeAnnotationOverlay {
|
|
1454
|
+
getShapes;
|
|
1455
|
+
lines = new UIKitLineOverlay;
|
|
1456
|
+
labels;
|
|
1457
|
+
lastSig = "";
|
|
1458
|
+
constructor(font, getShapes) {
|
|
1459
|
+
this.getShapes = getShapes;
|
|
1460
|
+
this.labels = new UIKitTextOverlay(font);
|
|
1461
|
+
}
|
|
1462
|
+
drawOverlay(frame) {
|
|
1463
|
+
const dpr = frame.dpr;
|
|
1464
|
+
const shapes = this.getShapes();
|
|
1465
|
+
let sig = `${dpr}`;
|
|
1466
|
+
for (const s of shapes) {
|
|
1467
|
+
sig += `|${s.id};`;
|
|
1468
|
+
for (const p of s.outer)
|
|
1469
|
+
sig += `${p.x | 0},${p.y | 0};`;
|
|
1470
|
+
if (s.start)
|
|
1471
|
+
sig += `s${s.start.x | 0},${s.start.y | 0};`;
|
|
1472
|
+
if (s.end)
|
|
1473
|
+
sig += `e${s.end.x | 0},${s.end.y | 0};`;
|
|
1474
|
+
if (s.start2)
|
|
1475
|
+
sig += `S${s.start2.x | 0},${s.start2.y | 0};`;
|
|
1476
|
+
if (s.end2)
|
|
1477
|
+
sig += `E${s.end2.x | 0},${s.end2.y | 0};`;
|
|
1478
|
+
if (s.label)
|
|
1479
|
+
sig += `t${s.label.lines.join("\x1F")};`;
|
|
1480
|
+
}
|
|
1481
|
+
if (sig !== this.lastSig) {
|
|
1482
|
+
this.lastSig = sig;
|
|
1483
|
+
const geo = buildAnnotationGeometry(shapes, {
|
|
1484
|
+
dpr,
|
|
1485
|
+
labelCssPx: LABEL_CSS_PX
|
|
1486
|
+
});
|
|
1487
|
+
this.lines.setLines(geo.lines);
|
|
1488
|
+
this.labels.setItems(geo.text);
|
|
1489
|
+
}
|
|
1490
|
+
this.lines.drawOverlay(frame);
|
|
1491
|
+
this.labels.drawOverlay(frame);
|
|
1492
|
+
}
|
|
1493
|
+
destroy() {
|
|
1494
|
+
this.lines.destroy();
|
|
1495
|
+
this.labels.destroy();
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// src/wsiSlideView.ts
|
|
1500
|
+
import { SlideRenderer } from "@niivue/niivue";
|
|
1501
|
+
import { loadDefaultFont, UIKitRulerOverlay } from "@niivue/uikit";
|
|
1502
|
+
var RULER_COLOR = [1, 0.85, 0, 1];
|
|
1503
|
+
var RULER_LABEL_CSS_PX = 28;
|
|
1504
|
+
var RULER_THICKNESS_CSS_PX = 2;
|
|
1505
|
+
var RULER_TICK_CSS_PX = 6;
|
|
1506
|
+
function measure(a, b, spacing) {
|
|
1507
|
+
const dpx = b.x - a.x;
|
|
1508
|
+
const dpy = b.y - a.y;
|
|
1509
|
+
if (!spacing) {
|
|
1510
|
+
return {
|
|
1511
|
+
length: Math.hypot(dpx, dpy),
|
|
1512
|
+
units: "px",
|
|
1513
|
+
decimals: 0,
|
|
1514
|
+
ticks: false
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
const mm = Math.hypot(dpx * spacing[0], dpy * spacing[1]);
|
|
1518
|
+
return mm < 1 ? { length: mm * 1000, units: "um", decimals: 0, ticks: false } : { length: mm, units: "mm", decimals: 2, ticks: true };
|
|
1519
|
+
}
|
|
1520
|
+
function mountWsiSlideView(canvas, slide, options = {}) {
|
|
1521
|
+
const gl = canvas.getContext("webgl2", {
|
|
1522
|
+
alpha: false,
|
|
1523
|
+
antialias: false,
|
|
1524
|
+
preserveDrawingBuffer: true
|
|
1525
|
+
});
|
|
1526
|
+
if (!gl)
|
|
1527
|
+
throw new Error("WebGL2 is required for the whole-slide viewer");
|
|
1528
|
+
const renderer = new SlideRenderer;
|
|
1529
|
+
renderer.init(gl);
|
|
1530
|
+
let disposed = false;
|
|
1531
|
+
let userInteracted = false;
|
|
1532
|
+
let activeTool = "Pan";
|
|
1533
|
+
let lastFitWidth = 0;
|
|
1534
|
+
let lastFitHeight = 0;
|
|
1535
|
+
let ruler = null;
|
|
1536
|
+
let ruleA = null;
|
|
1537
|
+
let ruleB = null;
|
|
1538
|
+
const onMeasure = options.onMeasure;
|
|
1539
|
+
const screenOf = () => ({
|
|
1540
|
+
widthCss: canvas.clientWidth || 1,
|
|
1541
|
+
heightCss: canvas.clientHeight || 1,
|
|
1542
|
+
devicePixelRatio: window.devicePixelRatio || 1
|
|
1543
|
+
});
|
|
1544
|
+
const slideToDevice = (p, screen) => {
|
|
1545
|
+
const { xCss, yCss } = slide.slideToScreen(p.x, p.y, screen);
|
|
1546
|
+
const dpr = screen.devicePixelRatio ?? 1;
|
|
1547
|
+
return [xCss * dpr, yCss * dpr];
|
|
1548
|
+
};
|
|
1549
|
+
const clearMeasurement = () => {
|
|
1550
|
+
ruleA = null;
|
|
1551
|
+
ruleB = null;
|
|
1552
|
+
ruler?.clear();
|
|
1553
|
+
onMeasure?.(null);
|
|
1554
|
+
};
|
|
1555
|
+
const updateRuler = (screen) => {
|
|
1556
|
+
if (!ruler)
|
|
1557
|
+
return;
|
|
1558
|
+
const a = ruleA;
|
|
1559
|
+
const b = ruleB;
|
|
1560
|
+
if (!a || !b) {
|
|
1561
|
+
ruler.clear();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
const m = measure(a, b, slide.manifest.pixelSpacingMM);
|
|
1565
|
+
const dpr = screen.devicePixelRatio ?? 1;
|
|
1566
|
+
ruler.setRuler({
|
|
1567
|
+
a: slideToDevice(a, screen),
|
|
1568
|
+
b: slideToDevice(b, screen),
|
|
1569
|
+
length: m.length,
|
|
1570
|
+
units: m.units,
|
|
1571
|
+
decimals: m.decimals,
|
|
1572
|
+
sizePx: RULER_LABEL_CSS_PX * dpr,
|
|
1573
|
+
thickness: RULER_THICKNESS_CSS_PX * dpr,
|
|
1574
|
+
tickLength: RULER_TICK_CSS_PX * dpr,
|
|
1575
|
+
showTicks: m.ticks,
|
|
1576
|
+
showTickNumbers: m.ticks,
|
|
1577
|
+
lineColor: RULER_COLOR,
|
|
1578
|
+
textColor: RULER_COLOR
|
|
1579
|
+
});
|
|
1580
|
+
onMeasure?.(`${m.length.toFixed(m.decimals)} ${m.units}`);
|
|
1581
|
+
};
|
|
1582
|
+
const syncCanvasSize = (screen) => {
|
|
1583
|
+
const dpr = screen.devicePixelRatio ?? 1;
|
|
1584
|
+
const width = Math.max(1, Math.floor(screen.widthCss * dpr));
|
|
1585
|
+
const height = Math.max(1, Math.floor(screen.heightCss * dpr));
|
|
1586
|
+
if (canvas.width !== width)
|
|
1587
|
+
canvas.width = width;
|
|
1588
|
+
if (canvas.height !== height)
|
|
1589
|
+
canvas.height = height;
|
|
1590
|
+
};
|
|
1591
|
+
const render = () => {
|
|
1592
|
+
if (disposed)
|
|
1593
|
+
return;
|
|
1594
|
+
const screen = screenOf();
|
|
1595
|
+
syncCanvasSize(screen);
|
|
1596
|
+
if (!userInteracted && screen.widthCss > 1 && screen.heightCss > 1 && (screen.widthCss !== lastFitWidth || screen.heightCss !== lastFitHeight)) {
|
|
1597
|
+
lastFitWidth = screen.widthCss;
|
|
1598
|
+
lastFitHeight = screen.heightCss;
|
|
1599
|
+
slide.fitToScreen(screen);
|
|
1600
|
+
}
|
|
1601
|
+
slide.clampViewport(screen);
|
|
1602
|
+
updateRuler(screen);
|
|
1603
|
+
renderer.draw(gl, [slide], screen);
|
|
1604
|
+
};
|
|
1605
|
+
let scheduled = false;
|
|
1606
|
+
const flush = () => {
|
|
1607
|
+
if (!scheduled || disposed)
|
|
1608
|
+
return;
|
|
1609
|
+
scheduled = false;
|
|
1610
|
+
render();
|
|
1611
|
+
};
|
|
1612
|
+
const scheduleRender = () => {
|
|
1613
|
+
if (scheduled || disposed)
|
|
1614
|
+
return;
|
|
1615
|
+
scheduled = true;
|
|
1616
|
+
requestAnimationFrame(flush);
|
|
1617
|
+
setTimeout(flush, 100);
|
|
1618
|
+
};
|
|
1619
|
+
loadDefaultFont().then((font) => {
|
|
1620
|
+
if (disposed)
|
|
1621
|
+
return;
|
|
1622
|
+
ruler = new UIKitRulerOverlay(font);
|
|
1623
|
+
renderer.overlayDraw = (frame) => ruler?.drawOverlay(frame);
|
|
1624
|
+
scheduleRender();
|
|
1625
|
+
}).catch((err) => {
|
|
1626
|
+
console.error("[nv-ohif] UIKit ruler font failed to load", err);
|
|
1627
|
+
});
|
|
1628
|
+
const cssPos = (e) => {
|
|
1629
|
+
const rect = canvas.getBoundingClientRect();
|
|
1630
|
+
return [e.clientX - rect.left, e.clientY - rect.top];
|
|
1631
|
+
};
|
|
1632
|
+
const onWheel = (e) => {
|
|
1633
|
+
e.preventDefault();
|
|
1634
|
+
userInteracted = true;
|
|
1635
|
+
const rect = canvas.getBoundingClientRect();
|
|
1636
|
+
const factor = Math.exp(-e.deltaY * 0.001);
|
|
1637
|
+
slide.zoomBy(factor, e.clientX - rect.left, e.clientY - rect.top, screenOf());
|
|
1638
|
+
scheduleRender();
|
|
1639
|
+
};
|
|
1640
|
+
const slidePointAt = (e) => {
|
|
1641
|
+
const [cx, cy] = cssPos(e);
|
|
1642
|
+
return slide.screenToSlide(cx, cy, screenOf());
|
|
1643
|
+
};
|
|
1644
|
+
let dragging = false;
|
|
1645
|
+
let measuring = false;
|
|
1646
|
+
let lastX = 0;
|
|
1647
|
+
let lastY = 0;
|
|
1648
|
+
const onPointerDown = (e) => {
|
|
1649
|
+
canvas.setPointerCapture?.(e.pointerId);
|
|
1650
|
+
if (activeTool === "Length") {
|
|
1651
|
+
measuring = true;
|
|
1652
|
+
const s = slidePointAt(e);
|
|
1653
|
+
ruleA = { x: s.x, y: s.y };
|
|
1654
|
+
ruleB = { x: s.x, y: s.y };
|
|
1655
|
+
scheduleRender();
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
dragging = true;
|
|
1659
|
+
lastX = e.clientX;
|
|
1660
|
+
lastY = e.clientY;
|
|
1661
|
+
};
|
|
1662
|
+
const onPointerMove = (e) => {
|
|
1663
|
+
if (measuring) {
|
|
1664
|
+
ruleB = slidePointAt(e);
|
|
1665
|
+
scheduleRender();
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
if (!dragging)
|
|
1669
|
+
return;
|
|
1670
|
+
userInteracted = true;
|
|
1671
|
+
const dx = e.clientX - lastX;
|
|
1672
|
+
const dy = e.clientY - lastY;
|
|
1673
|
+
lastX = e.clientX;
|
|
1674
|
+
lastY = e.clientY;
|
|
1675
|
+
if (activeTool === "Zoom") {
|
|
1676
|
+
const rect = canvas.getBoundingClientRect();
|
|
1677
|
+
slide.zoomBy(Math.exp(-dy * 0.01), e.clientX - rect.left, e.clientY - rect.top, screenOf());
|
|
1678
|
+
} else {
|
|
1679
|
+
slide.panByScreenDelta(dx, dy, screenOf());
|
|
1680
|
+
}
|
|
1681
|
+
scheduleRender();
|
|
1682
|
+
};
|
|
1683
|
+
const onPointerUp = (e) => {
|
|
1684
|
+
canvas.releasePointerCapture?.(e.pointerId);
|
|
1685
|
+
if (measuring) {
|
|
1686
|
+
measuring = false;
|
|
1687
|
+
ruleB = slidePointAt(e);
|
|
1688
|
+
if (ruleA && ruleB && ruleA.x === ruleB.x && ruleA.y === ruleB.y) {
|
|
1689
|
+
clearMeasurement();
|
|
1690
|
+
}
|
|
1691
|
+
scheduleRender();
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
dragging = false;
|
|
1695
|
+
};
|
|
1696
|
+
canvas.addEventListener("pointerdown", onPointerDown);
|
|
1697
|
+
canvas.addEventListener("pointermove", onPointerMove);
|
|
1698
|
+
canvas.addEventListener("pointerup", onPointerUp);
|
|
1699
|
+
canvas.addEventListener("pointercancel", onPointerUp);
|
|
1700
|
+
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
1701
|
+
const onChange = () => scheduleRender();
|
|
1702
|
+
slide.addEventListener("change", onChange);
|
|
1703
|
+
const resizeObserver = new ResizeObserver(() => scheduleRender());
|
|
1704
|
+
resizeObserver.observe(canvas);
|
|
1705
|
+
scheduleRender();
|
|
1706
|
+
return {
|
|
1707
|
+
setTool(tool) {
|
|
1708
|
+
if (tool !== "Length")
|
|
1709
|
+
measuring = false;
|
|
1710
|
+
activeTool = tool;
|
|
1711
|
+
},
|
|
1712
|
+
resetView() {
|
|
1713
|
+
userInteracted = false;
|
|
1714
|
+
lastFitWidth = 0;
|
|
1715
|
+
lastFitHeight = 0;
|
|
1716
|
+
clearMeasurement();
|
|
1717
|
+
scheduleRender();
|
|
1718
|
+
},
|
|
1719
|
+
async saveBitmap(filename = "niivue-slide.png") {
|
|
1720
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
1721
|
+
if (!blob)
|
|
1722
|
+
return;
|
|
1723
|
+
const url = URL.createObjectURL(blob);
|
|
1724
|
+
const link = document.createElement("a");
|
|
1725
|
+
link.href = url;
|
|
1726
|
+
link.download = filename;
|
|
1727
|
+
link.click();
|
|
1728
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
1729
|
+
},
|
|
1730
|
+
dispose() {
|
|
1731
|
+
if (disposed)
|
|
1732
|
+
return;
|
|
1733
|
+
disposed = true;
|
|
1734
|
+
resizeObserver.disconnect();
|
|
1735
|
+
slide.removeEventListener("change", onChange);
|
|
1736
|
+
canvas.removeEventListener("pointerdown", onPointerDown);
|
|
1737
|
+
canvas.removeEventListener("pointermove", onPointerMove);
|
|
1738
|
+
canvas.removeEventListener("pointerup", onPointerUp);
|
|
1739
|
+
canvas.removeEventListener("pointercancel", onPointerUp);
|
|
1740
|
+
canvas.removeEventListener("wheel", onWheel);
|
|
1741
|
+
ruler?.destroy();
|
|
1742
|
+
slide.dispose();
|
|
1743
|
+
renderer.destroy();
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// src/wsiTileSource.ts
|
|
1749
|
+
var JPEG_TRANSFER_SYNTAXES = new Set([
|
|
1750
|
+
"1.2.840.10008.1.2.4.50",
|
|
1751
|
+
"1.2.840.10008.1.2.4.51"
|
|
1752
|
+
]);
|
|
1753
|
+
function num(value) {
|
|
1754
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1755
|
+
}
|
|
1756
|
+
function toNum(value) {
|
|
1757
|
+
if (typeof value === "number")
|
|
1758
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1759
|
+
if (typeof value === "string") {
|
|
1760
|
+
const n = Number.parseFloat(value);
|
|
1761
|
+
return Number.isFinite(n) ? n : undefined;
|
|
1762
|
+
}
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
function str(value) {
|
|
1766
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
1767
|
+
}
|
|
1768
|
+
function first(value) {
|
|
1769
|
+
return Array.isArray(value) ? value[0] : value;
|
|
1770
|
+
}
|
|
1771
|
+
function imageTypeString(value) {
|
|
1772
|
+
if (Array.isArray(value)) {
|
|
1773
|
+
const parts = value.filter((v) => typeof v === "string" && v.length > 0);
|
|
1774
|
+
return parts.length > 0 ? parts.join("\\") : undefined;
|
|
1775
|
+
}
|
|
1776
|
+
return str(value);
|
|
1777
|
+
}
|
|
1778
|
+
function deriveSpacingMM(inst, matrixColumns, matrixRows) {
|
|
1779
|
+
const spacingFrom = (group) => first(first(group)?.PixelMeasuresSequence)?.PixelSpacing;
|
|
1780
|
+
const pixelSpacing = spacingFrom(inst.SharedFunctionalGroupsSequence) ?? spacingFrom(inst.PerFrameFunctionalGroupsSequence) ?? inst.PixelSpacing;
|
|
1781
|
+
if (Array.isArray(pixelSpacing)) {
|
|
1782
|
+
const dy = toNum(pixelSpacing[0]);
|
|
1783
|
+
const dx = toNum(pixelSpacing[1]);
|
|
1784
|
+
if (dx && dy && dx > 0 && dy > 0)
|
|
1785
|
+
return [dx, dy];
|
|
1786
|
+
}
|
|
1787
|
+
const volW = toNum(inst.ImagedVolumeWidth);
|
|
1788
|
+
const volH = toNum(inst.ImagedVolumeHeight);
|
|
1789
|
+
if (volW && volH && matrixColumns > 0 && matrixRows > 0) {
|
|
1790
|
+
return [volW / matrixColumns, volH / matrixRows];
|
|
1791
|
+
}
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
function isVolumeLevel(inst) {
|
|
1795
|
+
const imageType = imageTypeString(inst.ImageType);
|
|
1796
|
+
if (imageType)
|
|
1797
|
+
return imageType.includes("VOLUME");
|
|
1798
|
+
const matCols = num(inst.TotalPixelMatrixColumns) ?? 0;
|
|
1799
|
+
const tileCols = num(inst.Columns) ?? 0;
|
|
1800
|
+
const matRows = num(inst.TotalPixelMatrixRows) ?? 0;
|
|
1801
|
+
const tileRows = num(inst.Rows) ?? 0;
|
|
1802
|
+
return matCols > tileCols || matRows > tileRows;
|
|
1803
|
+
}
|
|
1804
|
+
function wsiVolumeLevels(ds) {
|
|
1805
|
+
const instances2 = ds.instances ?? [];
|
|
1806
|
+
const imageIds = ds.imageIds ?? [];
|
|
1807
|
+
const positionalSafe = imageIds.length === instances2.length;
|
|
1808
|
+
const levels = [];
|
|
1809
|
+
instances2.forEach((inst, i) => {
|
|
1810
|
+
const instImageId = inst.imageId;
|
|
1811
|
+
const imageId = typeof instImageId === "string" ? instImageId : positionalSafe ? imageIds[i] : undefined;
|
|
1812
|
+
const matrixColumns = num(inst.TotalPixelMatrixColumns);
|
|
1813
|
+
const matrixRows = num(inst.TotalPixelMatrixRows);
|
|
1814
|
+
const tileColumns = num(inst.Columns);
|
|
1815
|
+
const tileRows = num(inst.Rows);
|
|
1816
|
+
if (!imageId || !matrixColumns || !matrixRows || !tileColumns || !tileRows || !isVolumeLevel(inst))
|
|
1817
|
+
return;
|
|
1818
|
+
const noScheme = imageId.replace(/^(wadors|dicomweb|wadouri):/i, "");
|
|
1819
|
+
const qIdx = noScheme.indexOf("?");
|
|
1820
|
+
const frameQuery = qIdx >= 0 ? noScheme.slice(qIdx) : "";
|
|
1821
|
+
const frameBaseUrl = (qIdx >= 0 ? noScheme.slice(0, qIdx) : noScheme).replace(/\/\d+$/, "");
|
|
1822
|
+
const transferSyntax = str(inst.TransferSyntaxUID) ?? str(inst.AvailableTransferSyntaxUID);
|
|
1823
|
+
levels.push({
|
|
1824
|
+
matrixColumns,
|
|
1825
|
+
matrixRows,
|
|
1826
|
+
tileColumns,
|
|
1827
|
+
tileRows,
|
|
1828
|
+
frameBaseUrl,
|
|
1829
|
+
frameQuery,
|
|
1830
|
+
isJpeg: transferSyntax ? JPEG_TRANSFER_SYNTAXES.has(transferSyntax) : true,
|
|
1831
|
+
spacingMM: deriveSpacingMM(inst, matrixColumns, matrixRows),
|
|
1832
|
+
tiledFull: (() => {
|
|
1833
|
+
let v = inst.DimensionOrganizationType;
|
|
1834
|
+
while (Array.isArray(v))
|
|
1835
|
+
v = v[0];
|
|
1836
|
+
const org = str(v);
|
|
1837
|
+
return org === undefined || org.toUpperCase() === "TILED_FULL";
|
|
1838
|
+
})()
|
|
1839
|
+
});
|
|
1840
|
+
});
|
|
1841
|
+
return levels.sort((a, b) => b.matrixColumns * b.matrixRows - a.matrixColumns * a.matrixRows);
|
|
1842
|
+
}
|
|
1843
|
+
function levelTiles(level) {
|
|
1844
|
+
const columns = Math.ceil(level.matrixColumns / level.tileColumns);
|
|
1845
|
+
const rows = Math.ceil(level.matrixRows / level.tileRows);
|
|
1846
|
+
const tiles = [];
|
|
1847
|
+
for (let row = 0;row < rows; row++) {
|
|
1848
|
+
for (let col = 0;col < columns; col++) {
|
|
1849
|
+
const pixelX = col * level.tileColumns;
|
|
1850
|
+
const pixelY = row * level.tileRows;
|
|
1851
|
+
tiles.push({
|
|
1852
|
+
x: col,
|
|
1853
|
+
y: row,
|
|
1854
|
+
width: Math.min(level.tileColumns, level.matrixColumns - pixelX),
|
|
1855
|
+
height: Math.min(level.tileRows, level.matrixRows - pixelY),
|
|
1856
|
+
frame: row * columns + col + 1
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return { columns, rows, tiles };
|
|
1861
|
+
}
|
|
1862
|
+
function buildWsiManifest(ds) {
|
|
1863
|
+
const volumeLevels = wsiVolumeLevels(ds);
|
|
1864
|
+
if (volumeLevels.length === 0)
|
|
1865
|
+
return null;
|
|
1866
|
+
const level0 = volumeLevels[0];
|
|
1867
|
+
if (!level0)
|
|
1868
|
+
return null;
|
|
1869
|
+
const levels = [];
|
|
1870
|
+
const levelBaseUrls = [];
|
|
1871
|
+
const levelQueries = [];
|
|
1872
|
+
let allJpeg = true;
|
|
1873
|
+
let tiledFull = true;
|
|
1874
|
+
volumeLevels.forEach((level, index) => {
|
|
1875
|
+
const { columns, rows, tiles } = levelTiles(level);
|
|
1876
|
+
levels.push({
|
|
1877
|
+
index,
|
|
1878
|
+
width: level.matrixColumns,
|
|
1879
|
+
height: level.matrixRows,
|
|
1880
|
+
downsample: level0.matrixColumns / level.matrixColumns,
|
|
1881
|
+
tileWidth: level.tileColumns,
|
|
1882
|
+
tileHeight: level.tileRows,
|
|
1883
|
+
columns,
|
|
1884
|
+
rows,
|
|
1885
|
+
codec: level.isJpeg ? "image/jpeg" : "image/jp2",
|
|
1886
|
+
tiles
|
|
1887
|
+
});
|
|
1888
|
+
levelBaseUrls[index] = level.frameBaseUrl;
|
|
1889
|
+
levelQueries[index] = level.frameQuery;
|
|
1890
|
+
if (!level.isJpeg)
|
|
1891
|
+
allJpeg = false;
|
|
1892
|
+
if (!level.tiledFull)
|
|
1893
|
+
tiledFull = false;
|
|
1894
|
+
});
|
|
1895
|
+
const manifest = {
|
|
1896
|
+
id: str(ds.displaySetInstanceUID) ?? "wsi",
|
|
1897
|
+
name: str(ds.SeriesDescription) ?? "Whole-slide image",
|
|
1898
|
+
format: "dicom-wsi-ohif",
|
|
1899
|
+
width: level0.matrixColumns,
|
|
1900
|
+
height: level0.matrixRows,
|
|
1901
|
+
displayYAxis: "up",
|
|
1902
|
+
tileSize: level0.tileColumns,
|
|
1903
|
+
dtype: "uint8",
|
|
1904
|
+
channels: "encoded-rgb",
|
|
1905
|
+
...level0.spacingMM ? { pixelSpacingMM: level0.spacingMM } : {},
|
|
1906
|
+
levels
|
|
1907
|
+
};
|
|
1908
|
+
return { manifest, levelBaseUrls, levelQueries, allJpeg, tiledFull };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
class DicomWsiTileSource {
|
|
1912
|
+
manifest;
|
|
1913
|
+
levelBaseUrls;
|
|
1914
|
+
levelQueries;
|
|
1915
|
+
headers;
|
|
1916
|
+
constructor(built, headers = {}) {
|
|
1917
|
+
this.manifest = built.manifest;
|
|
1918
|
+
this.levelBaseUrls = built.levelBaseUrls;
|
|
1919
|
+
this.levelQueries = built.levelQueries;
|
|
1920
|
+
this.headers = headers;
|
|
1921
|
+
}
|
|
1922
|
+
bind(_host) {}
|
|
1923
|
+
async fetchTileBytes(level, tile) {
|
|
1924
|
+
const base = this.levelBaseUrls[level.index];
|
|
1925
|
+
if (!base)
|
|
1926
|
+
throw new Error(`no frame URL for WSI level ${level.index}`);
|
|
1927
|
+
if (tile.frame === undefined)
|
|
1928
|
+
throw new Error("WSI tile has no frame number");
|
|
1929
|
+
const query = this.levelQueries[level.index] ?? "";
|
|
1930
|
+
const response = await fetch(`${base}/${tile.frame}${query}`, {
|
|
1931
|
+
headers: {
|
|
1932
|
+
Accept: 'multipart/related; type="image/jpeg"',
|
|
1933
|
+
...this.headers
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
if (!response.ok)
|
|
1937
|
+
throw new Error(`WSI tile HTTP ${response.status}`);
|
|
1938
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1939
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
1940
|
+
const parts = parseMultipartRelated(body, contentType);
|
|
1941
|
+
const jpeg = parts[0];
|
|
1942
|
+
if (!jpeg || jpeg.length === 0)
|
|
1943
|
+
throw new Error("empty WSI tile body");
|
|
1944
|
+
return jpeg;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// src/NiivueViewport.tsx
|
|
1949
|
+
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
1950
|
+
var DEFAULT_OPTIONS = {
|
|
1951
|
+
backgroundColor: [0, 0, 0, 1],
|
|
1952
|
+
backend: "webgl2",
|
|
1953
|
+
annotationMergesOverlaps: false
|
|
1954
|
+
};
|
|
1955
|
+
function NiivueViewport(props) {
|
|
1956
|
+
const { displaySets, viewportId, servicesManager, commandsManager } = props;
|
|
1957
|
+
const containerRef = useRef(null);
|
|
1958
|
+
const nvRef = useRef(null);
|
|
1959
|
+
const slideViewRef = useRef(null);
|
|
1960
|
+
const activeOhifToolRef = useRef(undefined);
|
|
1961
|
+
const [ready, setReady] = useState(false);
|
|
1962
|
+
const [status, setStatus] = useState({ kind: "idle" });
|
|
1963
|
+
const displaySetsRef = useRef(displaySets);
|
|
1964
|
+
displaySetsRef.current = displaySets;
|
|
1965
|
+
const servicesManagerRef = useRef(servicesManager);
|
|
1966
|
+
servicesManagerRef.current = servicesManager;
|
|
1967
|
+
const commandsManagerRef = useRef(commandsManager);
|
|
1968
|
+
commandsManagerRef.current = commandsManager;
|
|
1969
|
+
const displaySetsKey = displaySets.map((ds) => String(ds.displaySetInstanceUID ?? ds.SeriesInstanceUID ?? "")).join("|");
|
|
1970
|
+
useEffect(() => {
|
|
1971
|
+
const container = containerRef.current;
|
|
1972
|
+
if (!container)
|
|
1973
|
+
return;
|
|
1974
|
+
let disposed = false;
|
|
1975
|
+
let annotationOverlay = null;
|
|
1976
|
+
let unregisterAnnotation = null;
|
|
1977
|
+
const canvas = document.createElement("canvas");
|
|
1978
|
+
canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%";
|
|
1979
|
+
container.appendChild(canvas);
|
|
1980
|
+
const nv = new NiiVue(DEFAULT_OPTIONS);
|
|
1981
|
+
nvRef.current = nv;
|
|
1982
|
+
nv.attachToCanvas(canvas).then(() => {
|
|
1983
|
+
if (disposed)
|
|
1984
|
+
return;
|
|
1985
|
+
nv.sliceType = SLICE_TYPE2.MULTIPLANAR;
|
|
1986
|
+
loadDefaultFont2().then((font) => {
|
|
1987
|
+
if (disposed)
|
|
1988
|
+
return;
|
|
1989
|
+
annotationOverlay = new VolumeAnnotationOverlay(font, () => visibleAnnotationScreenShapes(viewportId, nv.annotationScreenShapes));
|
|
1990
|
+
unregisterAnnotation = nv.registerOverlayRenderer(annotationOverlay);
|
|
1991
|
+
nv.isAnnotationDrawn = false;
|
|
1992
|
+
}).catch((err) => console.error("[nv-ohif] annotation font failed to load", err));
|
|
1993
|
+
registerNiivue(viewportId, nv);
|
|
1994
|
+
updateNiivueViewport(viewportId, {
|
|
1995
|
+
setStatus: (message) => setStatus(message === null ? { kind: "idle" } : {
|
|
1996
|
+
kind: /failed/i.test(message) ? "error" : /\.\.\./.test(message) ? "loading" : "note",
|
|
1997
|
+
message
|
|
1998
|
+
})
|
|
1999
|
+
});
|
|
2000
|
+
refreshToolbar(servicesManagerRef.current, viewportId);
|
|
2001
|
+
setReady(true);
|
|
2002
|
+
});
|
|
2003
|
+
const ro = new ResizeObserver(() => nv.resize());
|
|
2004
|
+
ro.observe(container);
|
|
2005
|
+
const onPointerUp = () => {
|
|
2006
|
+
const wl = syncNiivueWindowLevelToOhif(viewportId, servicesManagerRef.current, commandsManagerRef.current);
|
|
2007
|
+
if (!wl)
|
|
2008
|
+
return;
|
|
2009
|
+
const message = `W: ${Math.round(wl.window)} L: ${Math.round(wl.level)}`;
|
|
2010
|
+
setStatus({ kind: "note", message });
|
|
2011
|
+
window.setTimeout(() => setStatus((s) => s.kind === "note" && s.message === message ? { kind: "idle" } : s), 2000);
|
|
2012
|
+
};
|
|
2013
|
+
canvas.addEventListener("pointerup", onPointerUp);
|
|
2014
|
+
const onAnnotationAdded = (e) => {
|
|
2015
|
+
const annotation = e.detail?.annotation;
|
|
2016
|
+
if (!annotation)
|
|
2017
|
+
return;
|
|
2018
|
+
if (applyDefaultAnnotationText(annotation, nv.annotations))
|
|
2019
|
+
nv.drawScene();
|
|
2020
|
+
const stored = nv.annotations.find((a) => a.id === annotation.id) ?? annotation;
|
|
2021
|
+
const added = reflectNiivueAnnotation(viewportId, servicesManagerRef.current, stored);
|
|
2022
|
+
const stats = stored.stats;
|
|
2023
|
+
if (!added || !stats)
|
|
2024
|
+
return;
|
|
2025
|
+
const message = stats.length !== undefined ? `Length: ${stats.length.toFixed(1)} mm` : `Area: ${stats.area.toFixed(1)} mm²`;
|
|
2026
|
+
setStatus({ kind: "note", message });
|
|
2027
|
+
window.setTimeout(() => setStatus((s) => s.kind === "note" && s.message === message ? { kind: "idle" } : s), 2000);
|
|
2028
|
+
};
|
|
2029
|
+
const onAnnotationRemoved = (e) => {
|
|
2030
|
+
const id = e.detail?.id;
|
|
2031
|
+
if (id)
|
|
2032
|
+
removeNiivueAnnotation(viewportId, servicesManagerRef.current, id);
|
|
2033
|
+
};
|
|
2034
|
+
const onAnnotationChanged = (e) => {
|
|
2035
|
+
const action = e.detail?.action;
|
|
2036
|
+
if (!action)
|
|
2037
|
+
return;
|
|
2038
|
+
reconcileNiivueAnnotations(viewportId, servicesManagerRef.current);
|
|
2039
|
+
};
|
|
2040
|
+
nv.addEventListener("annotationAdded", onAnnotationAdded);
|
|
2041
|
+
nv.addEventListener("annotationRemoved", onAnnotationRemoved);
|
|
2042
|
+
nv.addEventListener("annotationChanged", onAnnotationChanged);
|
|
2043
|
+
const unsubscribeLabelSync = subscribeOhifLabelSync(servicesManagerRef.current);
|
|
2044
|
+
return () => {
|
|
2045
|
+
disposed = true;
|
|
2046
|
+
setReady(false);
|
|
2047
|
+
clearNiivueAnnotations(viewportId, servicesManagerRef.current);
|
|
2048
|
+
unregisterNiivue(viewportId);
|
|
2049
|
+
refreshToolbar(servicesManagerRef.current, viewportId);
|
|
2050
|
+
canvas.removeEventListener("pointerup", onPointerUp);
|
|
2051
|
+
nv.removeEventListener("annotationAdded", onAnnotationAdded);
|
|
2052
|
+
nv.removeEventListener("annotationRemoved", onAnnotationRemoved);
|
|
2053
|
+
nv.removeEventListener("annotationChanged", onAnnotationChanged);
|
|
2054
|
+
unsubscribeLabelSync?.();
|
|
2055
|
+
unregisterAnnotation?.();
|
|
2056
|
+
annotationOverlay?.destroy();
|
|
2057
|
+
ro.disconnect();
|
|
2058
|
+
nv.destroy();
|
|
2059
|
+
canvas.width = 0;
|
|
2060
|
+
canvas.height = 0;
|
|
2061
|
+
canvas.remove();
|
|
2062
|
+
nvRef.current = null;
|
|
2063
|
+
};
|
|
2064
|
+
}, [viewportId]);
|
|
2065
|
+
useEffect(() => {
|
|
2066
|
+
const nv = nvRef.current;
|
|
2067
|
+
const displaySets2 = displaySetsRef.current;
|
|
2068
|
+
const servicesManager2 = servicesManagerRef.current;
|
|
2069
|
+
if (!nv || !ready)
|
|
2070
|
+
return;
|
|
2071
|
+
nv.clearAnnotations();
|
|
2072
|
+
updateNiivueViewport(viewportId, {
|
|
2073
|
+
displaySets: displaySets2,
|
|
2074
|
+
overlayUIDs: [],
|
|
2075
|
+
overlayLoading: false
|
|
2076
|
+
});
|
|
2077
|
+
refreshToolbar(servicesManager2, viewportId);
|
|
2078
|
+
const dropStaleVolumes = () => {
|
|
2079
|
+
if (nv.volumes.length > 0)
|
|
2080
|
+
nv.removeAllVolumes().catch(() => {});
|
|
2081
|
+
};
|
|
2082
|
+
if (displaySets2.length === 0) {
|
|
2083
|
+
dropStaleVolumes();
|
|
2084
|
+
setStatus({ kind: "idle" });
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
let cancelled = false;
|
|
2088
|
+
const abort = new AbortController;
|
|
2089
|
+
const seedWindowLevel = () => {
|
|
2090
|
+
if (cancelled)
|
|
2091
|
+
return;
|
|
2092
|
+
updateNiivueViewport(viewportId, { windowLevel: readBaseWindowLevel(nv) });
|
|
2093
|
+
reconcileNiivueAnnotations(viewportId, servicesManagerRef.current);
|
|
2094
|
+
refreshToolbar(servicesManagerRef.current, viewportId);
|
|
2095
|
+
};
|
|
2096
|
+
const directSpecs = displaySets2.map((ds2) => displaySetToNiivue(ds2)).filter((s) => s !== null);
|
|
2097
|
+
if (directSpecs.length > 0) {
|
|
2098
|
+
setStatus({ kind: "idle" });
|
|
2099
|
+
nv.loadVolumes(directSpecs).then(() => {
|
|
2100
|
+
if (cancelled) {
|
|
2101
|
+
nv.removeAllVolumes().catch(() => {});
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
seedWindowLevel();
|
|
2105
|
+
}, () => {
|
|
2106
|
+
if (!cancelled)
|
|
2107
|
+
setStatus({ kind: "error", message: "Failed to load volume." });
|
|
2108
|
+
});
|
|
2109
|
+
return () => {
|
|
2110
|
+
cancelled = true;
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
const ds = displaySets2[0];
|
|
2114
|
+
if (!ds) {
|
|
2115
|
+
dropStaleVolumes();
|
|
2116
|
+
setStatus({ kind: "idle" });
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
const kind = classifyDisplaySet(ds);
|
|
2120
|
+
if (kind === "wsi") {
|
|
2121
|
+
dropStaleVolumes();
|
|
2122
|
+
const built = buildWsiManifest(ds);
|
|
2123
|
+
if (!built) {
|
|
2124
|
+
setStatus({
|
|
2125
|
+
kind: "note",
|
|
2126
|
+
message: "This slide has no tiled (VOLUME) pyramid levels to display."
|
|
2127
|
+
});
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
if (!built.allJpeg) {
|
|
2131
|
+
setStatus({
|
|
2132
|
+
kind: "note",
|
|
2133
|
+
message: "This slide is JPEG 2000, which the viewer cannot decode yet (JPEG slides are supported)."
|
|
2134
|
+
});
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
if (!built.tiledFull) {
|
|
2138
|
+
setStatus({
|
|
2139
|
+
kind: "note",
|
|
2140
|
+
message: "This slide uses TILED_SPARSE frame organization, which the viewer cannot render yet (TILED_FULL slides are supported)."
|
|
2141
|
+
});
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
const container = containerRef.current;
|
|
2145
|
+
if (!container)
|
|
2146
|
+
return;
|
|
2147
|
+
const slideCanvas = document.createElement("canvas");
|
|
2148
|
+
slideCanvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none";
|
|
2149
|
+
container.appendChild(slideCanvas);
|
|
2150
|
+
const source = new DicomWsiTileSource(built, authHeaders(servicesManager2));
|
|
2151
|
+
const slide = NVSlide.fromSource(source);
|
|
2152
|
+
let view = null;
|
|
2153
|
+
try {
|
|
2154
|
+
view = mountWsiSlideView(slideCanvas, slide);
|
|
2155
|
+
slideViewRef.current = view;
|
|
2156
|
+
view.setTool(activeOhifToolRef.current);
|
|
2157
|
+
updateNiivueViewport(viewportId, { slideView: view });
|
|
2158
|
+
refreshToolbar(servicesManager2, viewportId);
|
|
2159
|
+
setStatus({ kind: "idle" });
|
|
2160
|
+
} catch (err) {
|
|
2161
|
+
slideCanvas.remove();
|
|
2162
|
+
console.error("[nv-ohif] WSI view failed", err);
|
|
2163
|
+
setStatus({
|
|
2164
|
+
kind: "error",
|
|
2165
|
+
message: "Failed to start the whole-slide viewer."
|
|
2166
|
+
});
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
return () => {
|
|
2170
|
+
cancelled = true;
|
|
2171
|
+
updateNiivueViewport(viewportId, { slideView: undefined });
|
|
2172
|
+
slideViewRef.current = null;
|
|
2173
|
+
view?.dispose();
|
|
2174
|
+
slideCanvas.width = 0;
|
|
2175
|
+
slideCanvas.height = 0;
|
|
2176
|
+
slideCanvas.remove();
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
if (kind === "unsupported") {
|
|
2180
|
+
dropStaleVolumes();
|
|
2181
|
+
setStatus({
|
|
2182
|
+
kind: "note",
|
|
2183
|
+
message: "This series isn't something NiiVue can load yet."
|
|
2184
|
+
});
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
setStatus({ kind: "loading", message: "Fetching DICOM series..." });
|
|
2188
|
+
convertDisplaySetToNifti(ds, {
|
|
2189
|
+
headers: authHeaders(servicesManager2),
|
|
2190
|
+
signal: abort.signal,
|
|
2191
|
+
onProgress: (phase, loaded, total) => {
|
|
2192
|
+
if (cancelled)
|
|
2193
|
+
return;
|
|
2194
|
+
setStatus({
|
|
2195
|
+
kind: "loading",
|
|
2196
|
+
message: phase === "fetching" ? `Fetching DICOM series... ${loaded}/${total}` : "Converting DICOM to NIfTI (dcm2niix)..."
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
}).then((niftiFile) => {
|
|
2200
|
+
if (cancelled)
|
|
2201
|
+
return;
|
|
2202
|
+
if (!niftiFile) {
|
|
2203
|
+
setStatus({
|
|
2204
|
+
kind: "error",
|
|
2205
|
+
message: "DICOM conversion produced no volume."
|
|
2206
|
+
});
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
setStatus({ kind: "idle" });
|
|
2210
|
+
return nv.loadVolumes([{ url: niftiFile, name: niftiFile.name }]).then(() => {
|
|
2211
|
+
if (cancelled) {
|
|
2212
|
+
nv.removeAllVolumes().catch(() => {});
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
seedWindowLevel();
|
|
2216
|
+
});
|
|
2217
|
+
}).catch((err) => {
|
|
2218
|
+
if (cancelled || abort.signal.aborted)
|
|
2219
|
+
return;
|
|
2220
|
+
console.error("[nv-ohif] DICOM load failed", err);
|
|
2221
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2222
|
+
setStatus({
|
|
2223
|
+
kind: "error",
|
|
2224
|
+
message: `DICOM load failed: ${message || "unknown error"}`
|
|
2225
|
+
});
|
|
2226
|
+
});
|
|
2227
|
+
return () => {
|
|
2228
|
+
cancelled = true;
|
|
2229
|
+
abort.abort();
|
|
2230
|
+
};
|
|
2231
|
+
}, [displaySetsKey, ready]);
|
|
2232
|
+
useEffect(() => {
|
|
2233
|
+
const nv = nvRef.current;
|
|
2234
|
+
if (!nv || !ready)
|
|
2235
|
+
return;
|
|
2236
|
+
const svc = ohifServices(servicesManager)?.toolGroupService;
|
|
2237
|
+
if (!svc?.getActivePrimaryMouseButtonTool)
|
|
2238
|
+
return;
|
|
2239
|
+
const apply = () => {
|
|
2240
|
+
const tool = svc.getActivePrimaryMouseButtonTool?.("default");
|
|
2241
|
+
activeOhifToolRef.current = tool;
|
|
2242
|
+
const annoTool = ohifToolToAnnotationTool(tool);
|
|
2243
|
+
if (annoTool) {
|
|
2244
|
+
nv.annotationTool = annoTool;
|
|
2245
|
+
nv.annotationIsEnabled = true;
|
|
2246
|
+
} else {
|
|
2247
|
+
nv.annotationIsEnabled = false;
|
|
2248
|
+
nv.primaryDragMode = ohifToolToDragMode(tool);
|
|
2249
|
+
if (tool && UNSUPPORTED_MEASUREMENT_TOOLS.has(tool)) {
|
|
2250
|
+
const message = `${tool} is not supported in NiiVue`;
|
|
2251
|
+
setStatus({ kind: "note", message });
|
|
2252
|
+
window.setTimeout(() => setStatus((s) => s.kind === "note" && s.message === message ? { kind: "idle" } : s), 2500);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
slideViewRef.current?.setTool(tool);
|
|
2256
|
+
};
|
|
2257
|
+
apply();
|
|
2258
|
+
const event = svc.EVENTS?.PRIMARY_TOOL_ACTIVATED;
|
|
2259
|
+
const sub = event ? svc.subscribe?.(event, apply) : undefined;
|
|
2260
|
+
return () => sub?.unsubscribe?.();
|
|
2261
|
+
}, [ready, servicesManager]);
|
|
2262
|
+
const overlay = status.kind === "idle" ? null : status.message;
|
|
2263
|
+
return /* @__PURE__ */ jsxDEV("div", {
|
|
2264
|
+
"data-viewport-id": viewportId,
|
|
2265
|
+
style: { position: "relative", width: "100%", height: "100%" },
|
|
2266
|
+
children: [
|
|
2267
|
+
/* @__PURE__ */ jsxDEV("div", {
|
|
2268
|
+
ref: containerRef,
|
|
2269
|
+
style: { position: "absolute", inset: 0 }
|
|
2270
|
+
}, undefined, false, undefined, this),
|
|
2271
|
+
overlay ? /* @__PURE__ */ jsxDEV("div", {
|
|
2272
|
+
style: {
|
|
2273
|
+
position: "absolute",
|
|
2274
|
+
inset: 0,
|
|
2275
|
+
display: "flex",
|
|
2276
|
+
alignItems: "center",
|
|
2277
|
+
justifyContent: "center",
|
|
2278
|
+
color: status.kind === "error" ? "#e88" : "#9aa",
|
|
2279
|
+
font: "14px sans-serif",
|
|
2280
|
+
textAlign: "center",
|
|
2281
|
+
padding: "1rem",
|
|
2282
|
+
pointerEvents: "none"
|
|
2283
|
+
},
|
|
2284
|
+
children: overlay
|
|
2285
|
+
}, undefined, false, undefined, this) : null
|
|
2286
|
+
]
|
|
2287
|
+
}, undefined, true, undefined, this);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/getNiivueViewportModule.tsx
|
|
2291
|
+
var NIIVUE_VIEWPORT_NAME = "niivue";
|
|
2292
|
+
function getNiivueViewportModule() {
|
|
2293
|
+
return [{ name: NIIVUE_VIEWPORT_NAME, component: NiivueViewport }];
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// src/wsiThumbnail.ts
|
|
2297
|
+
function frameCount(inst) {
|
|
2298
|
+
const n = Number(inst.NumberOfFrames);
|
|
2299
|
+
return Number.isFinite(n) && n > 0 ? n : 1;
|
|
2300
|
+
}
|
|
2301
|
+
function flavorRank(inst) {
|
|
2302
|
+
const it = imageTypeString(inst.ImageType) ?? "";
|
|
2303
|
+
if (it.includes("OVERVIEW"))
|
|
2304
|
+
return 0;
|
|
2305
|
+
if (it.includes("THUMBNAIL"))
|
|
2306
|
+
return 1;
|
|
2307
|
+
if (it.includes("LABEL"))
|
|
2308
|
+
return 3;
|
|
2309
|
+
return 2;
|
|
2310
|
+
}
|
|
2311
|
+
function pickThumbnailInstance(instances2) {
|
|
2312
|
+
const eligible = instances2.filter((inst) => typeof inst.imageId === "string");
|
|
2313
|
+
if (eligible.length === 0)
|
|
2314
|
+
return;
|
|
2315
|
+
return eligible.reduce((best, inst) => {
|
|
2316
|
+
const r = flavorRank(inst);
|
|
2317
|
+
const rBest = flavorRank(best);
|
|
2318
|
+
if (r !== rBest)
|
|
2319
|
+
return r < rBest ? inst : best;
|
|
2320
|
+
return frameCount(inst) < frameCount(best) ? inst : best;
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
function frameEncoding(bytes) {
|
|
2324
|
+
if (bytes[0] === 255 && bytes[1] === 216)
|
|
2325
|
+
return "jpeg";
|
|
2326
|
+
if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78)
|
|
2327
|
+
return "png";
|
|
2328
|
+
return "raw";
|
|
2329
|
+
}
|
|
2330
|
+
var MAX_THUMBNAIL_FETCH_BYTES = 32 * 1024 * 1024;
|
|
2331
|
+
var MAX_THUMBNAIL_DECODE_PIXELS = 16 * 1024 * 1024;
|
|
2332
|
+
function rawImageInfo(inst, byteLength) {
|
|
2333
|
+
const width = Number(inst.Columns);
|
|
2334
|
+
const height = Number(inst.Rows);
|
|
2335
|
+
const channels = Number(inst.SamplesPerPixel);
|
|
2336
|
+
if (!Number.isFinite(width) || !Number.isFinite(height))
|
|
2337
|
+
return null;
|
|
2338
|
+
if (width * height > MAX_THUMBNAIL_DECODE_PIXELS)
|
|
2339
|
+
return null;
|
|
2340
|
+
if (channels !== 1 && channels !== 3)
|
|
2341
|
+
return null;
|
|
2342
|
+
const photometric = typeof inst.PhotometricInterpretation === "string" ? inst.PhotometricInterpretation : "";
|
|
2343
|
+
if (channels === 3 && photometric && !photometric.startsWith("RGB"))
|
|
2344
|
+
return null;
|
|
2345
|
+
if (width * height * channels !== byteLength)
|
|
2346
|
+
return null;
|
|
2347
|
+
return { width, height, channels };
|
|
2348
|
+
}
|
|
2349
|
+
var MAX_THUMBNAIL_EDGE = 256;
|
|
2350
|
+
function rawFrameToDataUrl(raw, info) {
|
|
2351
|
+
if (typeof document === "undefined")
|
|
2352
|
+
return null;
|
|
2353
|
+
const { width, height, channels } = info;
|
|
2354
|
+
const full = document.createElement("canvas");
|
|
2355
|
+
full.width = width;
|
|
2356
|
+
full.height = height;
|
|
2357
|
+
const fctx = full.getContext("2d");
|
|
2358
|
+
if (!fctx)
|
|
2359
|
+
return null;
|
|
2360
|
+
const image = fctx.createImageData(width, height);
|
|
2361
|
+
const out = image.data;
|
|
2362
|
+
if (channels === 3) {
|
|
2363
|
+
for (let px = 0, s = 0, d = 0;px < width * height; px++) {
|
|
2364
|
+
out[d++] = raw[s++] ?? 0;
|
|
2365
|
+
out[d++] = raw[s++] ?? 0;
|
|
2366
|
+
out[d++] = raw[s++] ?? 0;
|
|
2367
|
+
out[d++] = 255;
|
|
2368
|
+
}
|
|
2369
|
+
} else {
|
|
2370
|
+
for (let px = 0, d = 0;px < width * height; px++) {
|
|
2371
|
+
const v = raw[px] ?? 0;
|
|
2372
|
+
out[d++] = v;
|
|
2373
|
+
out[d++] = v;
|
|
2374
|
+
out[d++] = v;
|
|
2375
|
+
out[d++] = 255;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
fctx.putImageData(image, 0, 0);
|
|
2379
|
+
const scale = Math.min(1, MAX_THUMBNAIL_EDGE / Math.max(width, height));
|
|
2380
|
+
if (scale === 1)
|
|
2381
|
+
return full.toDataURL("image/png");
|
|
2382
|
+
const small = document.createElement("canvas");
|
|
2383
|
+
small.width = Math.max(1, Math.round(width * scale));
|
|
2384
|
+
small.height = Math.max(1, Math.round(height * scale));
|
|
2385
|
+
const sctx = small.getContext("2d");
|
|
2386
|
+
if (!sctx)
|
|
2387
|
+
return full.toDataURL("image/png");
|
|
2388
|
+
sctx.drawImage(full, 0, 0, small.width, small.height);
|
|
2389
|
+
return small.toDataURL("image/jpeg", 0.85);
|
|
2390
|
+
}
|
|
2391
|
+
function bytesToDataUrl(bytes, mime) {
|
|
2392
|
+
let binary = "";
|
|
2393
|
+
const chunk = 32768;
|
|
2394
|
+
for (let i = 0;i < bytes.length; i += chunk) {
|
|
2395
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
2396
|
+
}
|
|
2397
|
+
return `data:${mime};base64,${btoa(binary)}`;
|
|
2398
|
+
}
|
|
2399
|
+
async function encodedFrameToThumbnailUrl(frame, mime) {
|
|
2400
|
+
if (typeof document === "undefined")
|
|
2401
|
+
return null;
|
|
2402
|
+
if (typeof createImageBitmap !== "function")
|
|
2403
|
+
return null;
|
|
2404
|
+
let bitmap = null;
|
|
2405
|
+
try {
|
|
2406
|
+
bitmap = await createImageBitmap(new Blob([new Uint8Array(frame)], { type: mime }));
|
|
2407
|
+
const scale = Math.min(1, MAX_THUMBNAIL_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
2408
|
+
const canvas = document.createElement("canvas");
|
|
2409
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
2410
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
2411
|
+
const ctx = canvas.getContext("2d");
|
|
2412
|
+
if (!ctx)
|
|
2413
|
+
return null;
|
|
2414
|
+
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
2415
|
+
return canvas.toDataURL("image/jpeg", 0.85);
|
|
2416
|
+
} catch {
|
|
2417
|
+
return null;
|
|
2418
|
+
} finally {
|
|
2419
|
+
bitmap?.close();
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
async function fetchWsiThumbnailObjectUrl(instances2, headers = {}, signal) {
|
|
2423
|
+
const inst = pickThumbnailInstance(instances2);
|
|
2424
|
+
if (!inst)
|
|
2425
|
+
return null;
|
|
2426
|
+
const imageId = inst.imageId;
|
|
2427
|
+
if (typeof imageId !== "string")
|
|
2428
|
+
return null;
|
|
2429
|
+
const url = imageId.replace(/^(wadors|dicomweb|wadouri):/i, "");
|
|
2430
|
+
if (!/^https?:\/\//i.test(url))
|
|
2431
|
+
return null;
|
|
2432
|
+
try {
|
|
2433
|
+
const response = await fetch(url, {
|
|
2434
|
+
headers: { Accept: 'multipart/related; type="image/jpeg"', ...headers },
|
|
2435
|
+
signal
|
|
2436
|
+
});
|
|
2437
|
+
if (!response.ok)
|
|
2438
|
+
return null;
|
|
2439
|
+
const advertised = Number(response.headers.get("content-length"));
|
|
2440
|
+
if (Number.isFinite(advertised) && advertised > MAX_THUMBNAIL_FETCH_BYTES) {
|
|
2441
|
+
return null;
|
|
2442
|
+
}
|
|
2443
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
2444
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
2445
|
+
if (body.byteLength > MAX_THUMBNAIL_FETCH_BYTES)
|
|
2446
|
+
return null;
|
|
2447
|
+
const frame = parseMultipartRelated(body, contentType)[0];
|
|
2448
|
+
if (!frame || frame.length === 0)
|
|
2449
|
+
return null;
|
|
2450
|
+
const encoding = frameEncoding(frame);
|
|
2451
|
+
if (encoding !== "raw") {
|
|
2452
|
+
const mime = `image/${encoding}`;
|
|
2453
|
+
const scaled = await encodedFrameToThumbnailUrl(frame, mime);
|
|
2454
|
+
if (scaled)
|
|
2455
|
+
return scaled;
|
|
2456
|
+
return bytesToDataUrl(frame, mime);
|
|
2457
|
+
}
|
|
2458
|
+
const info = rawImageInfo(inst, frame.length);
|
|
2459
|
+
if (!info)
|
|
2460
|
+
return null;
|
|
2461
|
+
return rawFrameToDataUrl(frame, info);
|
|
2462
|
+
} catch {
|
|
2463
|
+
return null;
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// src/sopClassHandler.ts
|
|
2468
|
+
var NIIVUE_SOP_CLASS_HANDLER_NAME = "niivue-wsi";
|
|
2469
|
+
var NIIVUE_SOP_CLASS_HANDLER_ID = "@niivue/nv-ohif.sopClassHandlerModule.niivue-wsi";
|
|
2470
|
+
var VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.77.1.6";
|
|
2471
|
+
function getNiivueSopClassHandlerModule() {
|
|
2472
|
+
return [
|
|
2473
|
+
{
|
|
2474
|
+
name: NIIVUE_SOP_CLASS_HANDLER_NAME,
|
|
2475
|
+
sopClassUids: [VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE],
|
|
2476
|
+
getDisplaySetsFromSeries: (instances2) => {
|
|
2477
|
+
if (!instances2 || instances2.length === 0)
|
|
2478
|
+
return [];
|
|
2479
|
+
const first2 = instances2[0];
|
|
2480
|
+
const seriesUID = String(first2.SeriesInstanceUID ?? "");
|
|
2481
|
+
const imageIds = instances2.map((inst) => inst.imageId).filter((id) => typeof id === "string");
|
|
2482
|
+
const description = String(first2.SeriesDescription ?? "Whole-slide image");
|
|
2483
|
+
const displaySet = {
|
|
2484
|
+
displaySetInstanceUID: `${seriesUID}-niivue-wsi`,
|
|
2485
|
+
SOPClassHandlerId: NIIVUE_SOP_CLASS_HANDLER_ID,
|
|
2486
|
+
Modality: "SM",
|
|
2487
|
+
SeriesInstanceUID: seriesUID,
|
|
2488
|
+
StudyInstanceUID: String(first2.StudyInstanceUID ?? ""),
|
|
2489
|
+
SeriesDescription: description,
|
|
2490
|
+
SeriesNumber: first2.SeriesNumber,
|
|
2491
|
+
SOPClassUID: first2.SOPClassUID,
|
|
2492
|
+
numImageFrames: 0,
|
|
2493
|
+
numInstances: instances2.length,
|
|
2494
|
+
instances: instances2,
|
|
2495
|
+
imageIds,
|
|
2496
|
+
getThumbnailSrc: (options) => fetchWsiThumbnailObjectUrl(instances2, authHeaders(undefined), options?.signal),
|
|
2497
|
+
label: description
|
|
2498
|
+
};
|
|
2499
|
+
return [displaySet];
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
];
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/toolbar.ts
|
|
2506
|
+
var NIIVUE_VIEWS_SECTION = "NiivueViews";
|
|
2507
|
+
var NIIVUE_CLIP_SECTION = "NiivueClip";
|
|
2508
|
+
var NIIVUE_WL_SECTION = "NiivueWindowLevel";
|
|
2509
|
+
var NIIVUE_COLORMAP_SECTION = "NiivueColormap";
|
|
2510
|
+
var NIIVUE_RESET_BUTTON = "NiivueReset";
|
|
2511
|
+
var NIIVUE_OVERLAY_BUTTON = "NiivueOverlay";
|
|
2512
|
+
var NIIVUE_COLORBAR_BUTTON = "NiivueColorbar";
|
|
2513
|
+
var NIIVUE_INTERPOLATION_BUTTON = "NiivueInterpolation";
|
|
2514
|
+
var NIIVUE_RULER_BUTTON = "NiivueRuler";
|
|
2515
|
+
var NIIVUE_CAPTURE_BUTTON = "NiivueCapture";
|
|
2516
|
+
var NIIVUE_CROSSHAIR_BUTTON = "NiivueCrosshair";
|
|
2517
|
+
var SLICE_TYPE_EVALUATOR = "evaluate.niivue.sliceType";
|
|
2518
|
+
var CLIP_PLANE_EVALUATOR = "evaluate.niivue.clipPlane";
|
|
2519
|
+
var OVERLAY_EVALUATOR = "evaluate.niivue.overlay";
|
|
2520
|
+
var WL_PRESET_EVALUATOR = "evaluate.niivue.windowLevelPreset";
|
|
2521
|
+
var COLORMAP_EVALUATOR = "evaluate.niivue.colormap";
|
|
2522
|
+
var COLORBAR_EVALUATOR = "evaluate.niivue.colorbar";
|
|
2523
|
+
var INTERPOLATION_EVALUATOR = "evaluate.niivue.interpolation";
|
|
2524
|
+
var CROSSHAIR_EVALUATOR = "evaluate.niivue.crosshair";
|
|
2525
|
+
var MEASUREMENT_EVALUATOR = "evaluate.niivue.measurement";
|
|
2526
|
+
var NIIVUE_EVALUATOR = "evaluate.niivue";
|
|
2527
|
+
function capitalize(s) {
|
|
2528
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2529
|
+
}
|
|
2530
|
+
var colormapButtonId = (name) => `NiivueCmap${capitalize(name)}`;
|
|
2531
|
+
function sliceTypeButton(id, sliceType, icon, label) {
|
|
2532
|
+
return {
|
|
2533
|
+
id,
|
|
2534
|
+
uiType: "ohif.toolButton",
|
|
2535
|
+
props: {
|
|
2536
|
+
icon,
|
|
2537
|
+
label,
|
|
2538
|
+
tooltip: `${label} (NiiVue)`,
|
|
2539
|
+
commands: {
|
|
2540
|
+
commandName: "niivueSetSliceType",
|
|
2541
|
+
commandOptions: { sliceType }
|
|
2542
|
+
},
|
|
2543
|
+
evaluate: SLICE_TYPE_EVALUATOR
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
function clipPlaneButton(id, plane, icon, label) {
|
|
2548
|
+
return {
|
|
2549
|
+
id,
|
|
2550
|
+
uiType: "ohif.toolButton",
|
|
2551
|
+
props: {
|
|
2552
|
+
icon,
|
|
2553
|
+
label,
|
|
2554
|
+
tooltip: `${label} (NiiVue clip plane)`,
|
|
2555
|
+
commands: {
|
|
2556
|
+
commandName: "niivueSetClipPlane",
|
|
2557
|
+
commandOptions: { plane }
|
|
2558
|
+
},
|
|
2559
|
+
evaluate: CLIP_PLANE_EVALUATOR
|
|
2560
|
+
}
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function wlPresetButton(id, modality, presetId, presetIndex, label) {
|
|
2564
|
+
return {
|
|
2565
|
+
id,
|
|
2566
|
+
uiType: "ohif.toolButton",
|
|
2567
|
+
props: {
|
|
2568
|
+
icon: "tool-window-level",
|
|
2569
|
+
label,
|
|
2570
|
+
tooltip: `${label} window (${modality}, NiiVue)`,
|
|
2571
|
+
commands: {
|
|
2572
|
+
commandName: "niivueSetWindowLevelPreset",
|
|
2573
|
+
commandOptions: { modality, presetId, presetIndex }
|
|
2574
|
+
},
|
|
2575
|
+
evaluate: WL_PRESET_EVALUATOR
|
|
2576
|
+
}
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
function colormapButton(name, label) {
|
|
2580
|
+
return {
|
|
2581
|
+
id: colormapButtonId(name),
|
|
2582
|
+
uiType: "ohif.toolButton",
|
|
2583
|
+
props: {
|
|
2584
|
+
icon: "icon-color-lut",
|
|
2585
|
+
label,
|
|
2586
|
+
tooltip: `${label} colormap (NiiVue)`,
|
|
2587
|
+
commands: {
|
|
2588
|
+
commandName: "niivueSetColormap",
|
|
2589
|
+
commandOptions: { colormap: name }
|
|
2590
|
+
},
|
|
2591
|
+
evaluate: COLORMAP_EVALUATOR
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
var NIIVUE_TOOLBAR_BUTTONS = [
|
|
2596
|
+
{
|
|
2597
|
+
id: NIIVUE_VIEWS_SECTION,
|
|
2598
|
+
uiType: "ohif.toolButtonList",
|
|
2599
|
+
props: { buttonSection: true }
|
|
2600
|
+
},
|
|
2601
|
+
sliceTypeButton("NiivueMultiplanar", "multiplanar", "icon-mpr", "Multiplanar"),
|
|
2602
|
+
sliceTypeButton("NiivueAxial", "axial", "OrientationSwitchA", "Axial"),
|
|
2603
|
+
sliceTypeButton("NiivueCoronal", "coronal", "OrientationSwitchC", "Coronal"),
|
|
2604
|
+
sliceTypeButton("NiivueSagittal", "sagittal", "OrientationSwitchS", "Sagittal"),
|
|
2605
|
+
sliceTypeButton("NiivueRender", "render", "tool-3d-rotate", "3D Render"),
|
|
2606
|
+
{
|
|
2607
|
+
id: NIIVUE_CLIP_SECTION,
|
|
2608
|
+
uiType: "ohif.toolButtonList",
|
|
2609
|
+
props: { buttonSection: true }
|
|
2610
|
+
},
|
|
2611
|
+
clipPlaneButton("NiivueClipNone", "none", "icon-clear", "No Clip"),
|
|
2612
|
+
clipPlaneButton("NiivueClipRight", "right", "OrientationSwitchS", "Clip Right"),
|
|
2613
|
+
clipPlaneButton("NiivueClipLeft", "left", "OrientationSwitchS", "Clip Left"),
|
|
2614
|
+
clipPlaneButton("NiivueClipAnterior", "anterior", "OrientationSwitchC", "Clip Anterior"),
|
|
2615
|
+
clipPlaneButton("NiivueClipPosterior", "posterior", "OrientationSwitchC", "Clip Posterior"),
|
|
2616
|
+
clipPlaneButton("NiivueClipSuperior", "superior", "OrientationSwitchA", "Clip Superior"),
|
|
2617
|
+
clipPlaneButton("NiivueClipInferior", "inferior", "OrientationSwitchA", "Clip Inferior"),
|
|
2618
|
+
{
|
|
2619
|
+
id: NIIVUE_WL_SECTION,
|
|
2620
|
+
uiType: "ohif.toolButtonList",
|
|
2621
|
+
props: { buttonSection: true }
|
|
2622
|
+
},
|
|
2623
|
+
{
|
|
2624
|
+
id: "NiivueWLAuto",
|
|
2625
|
+
uiType: "ohif.toolButton",
|
|
2626
|
+
props: {
|
|
2627
|
+
icon: "tool-window-level",
|
|
2628
|
+
label: "Auto Window",
|
|
2629
|
+
tooltip: "Auto window/level (robust 2-98%, NiiVue)",
|
|
2630
|
+
commands: "niivueAutoWindowLevel",
|
|
2631
|
+
evaluate: NIIVUE_EVALUATOR
|
|
2632
|
+
}
|
|
2633
|
+
},
|
|
2634
|
+
wlPresetButton("NiivueWLCtSoft", "CT", "ct-soft-tissue", 0, "Soft Tissue"),
|
|
2635
|
+
wlPresetButton("NiivueWLCtLung", "CT", "ct-lung", 1, "Lung"),
|
|
2636
|
+
wlPresetButton("NiivueWLCtLiver", "CT", "ct-liver", 2, "Liver"),
|
|
2637
|
+
wlPresetButton("NiivueWLCtBone", "CT", "ct-bone", 3, "Bone"),
|
|
2638
|
+
wlPresetButton("NiivueWLCtBrain", "CT", "ct-brain", 4, "Brain"),
|
|
2639
|
+
wlPresetButton("NiivueWLPtDefault", "PT", "pt-default", 0, "PET Default"),
|
|
2640
|
+
wlPresetButton("NiivueWLPtSuv5", "PT", "pt-suv-5", 1, "SUV 5"),
|
|
2641
|
+
wlPresetButton("NiivueWLPtSuv10", "PT", "pt-suv-10", 2, "SUV 10"),
|
|
2642
|
+
{
|
|
2643
|
+
id: NIIVUE_COLORMAP_SECTION,
|
|
2644
|
+
uiType: "ohif.toolButtonList",
|
|
2645
|
+
props: { buttonSection: true }
|
|
2646
|
+
},
|
|
2647
|
+
...NIIVUE_COLORMAPS.map((c) => colormapButton(c.name, c.label)),
|
|
2648
|
+
{
|
|
2649
|
+
id: NIIVUE_COLORBAR_BUTTON,
|
|
2650
|
+
uiType: "ohif.toolButton",
|
|
2651
|
+
props: {
|
|
2652
|
+
icon: "icon-color-lut",
|
|
2653
|
+
label: "Colorbar",
|
|
2654
|
+
tooltip: "Toggle the colormap legend (NiiVue)",
|
|
2655
|
+
commands: "niivueToggleColorbar",
|
|
2656
|
+
evaluate: COLORBAR_EVALUATOR
|
|
2657
|
+
}
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
id: NIIVUE_INTERPOLATION_BUTTON,
|
|
2661
|
+
uiType: "ohif.toolButton",
|
|
2662
|
+
props: {
|
|
2663
|
+
icon: "icon-tool-interpolation",
|
|
2664
|
+
label: "Smoothing",
|
|
2665
|
+
tooltip: "Toggle nearest-neighbor vs smooth interpolation (NiiVue)",
|
|
2666
|
+
commands: "niivueToggleInterpolation",
|
|
2667
|
+
evaluate: INTERPOLATION_EVALUATOR
|
|
2668
|
+
}
|
|
2669
|
+
},
|
|
2670
|
+
{
|
|
2671
|
+
id: NIIVUE_RULER_BUTTON,
|
|
2672
|
+
uiType: "ohif.toolButton",
|
|
2673
|
+
props: {
|
|
2674
|
+
icon: "tool-length",
|
|
2675
|
+
label: "Ruler",
|
|
2676
|
+
tooltip: "Ruler: drag to measure; length appears in Measurements (NiiVue)",
|
|
2677
|
+
commands: "niivueSetMeasurementMode",
|
|
2678
|
+
evaluate: MEASUREMENT_EVALUATOR
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2681
|
+
{
|
|
2682
|
+
id: NIIVUE_OVERLAY_BUTTON,
|
|
2683
|
+
uiType: "ohif.toolButton",
|
|
2684
|
+
props: {
|
|
2685
|
+
icon: "toggle-dicom-overlay",
|
|
2686
|
+
label: "Overlay",
|
|
2687
|
+
tooltip: "Toggle a colormapped overlay of the next series in this study (NiiVue)",
|
|
2688
|
+
commands: "niivueToggleOverlay",
|
|
2689
|
+
evaluate: OVERLAY_EVALUATOR
|
|
2690
|
+
}
|
|
2691
|
+
},
|
|
2692
|
+
{
|
|
2693
|
+
id: NIIVUE_CROSSHAIR_BUTTON,
|
|
2694
|
+
uiType: "ohif.toolButton",
|
|
2695
|
+
props: {
|
|
2696
|
+
icon: "tool-crosshair",
|
|
2697
|
+
label: "Crosshairs",
|
|
2698
|
+
tooltip: "Toggle the crosshair on/off (NiiVue)",
|
|
2699
|
+
commands: "niivueToggleCrosshair",
|
|
2700
|
+
evaluate: CROSSHAIR_EVALUATOR
|
|
2701
|
+
}
|
|
2702
|
+
},
|
|
2703
|
+
{
|
|
2704
|
+
id: NIIVUE_CAPTURE_BUTTON,
|
|
2705
|
+
uiType: "ohif.toolButton",
|
|
2706
|
+
props: {
|
|
2707
|
+
icon: "tool-capture",
|
|
2708
|
+
label: "Capture",
|
|
2709
|
+
tooltip: "Download the NiiVue viewport as PNG",
|
|
2710
|
+
commands: "niivueSaveBitmap",
|
|
2711
|
+
evaluate: NIIVUE_EVALUATOR
|
|
2712
|
+
}
|
|
2713
|
+
},
|
|
2714
|
+
{
|
|
2715
|
+
id: NIIVUE_RESET_BUTTON,
|
|
2716
|
+
uiType: "ohif.toolButton",
|
|
2717
|
+
props: {
|
|
2718
|
+
icon: "tool-reset",
|
|
2719
|
+
label: "Reset View",
|
|
2720
|
+
tooltip: "Reset View (NiiVue)",
|
|
2721
|
+
commands: "niivueResetView",
|
|
2722
|
+
evaluate: NIIVUE_EVALUATOR
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
];
|
|
2726
|
+
var NIIVUE_TOOLBAR_SECTIONS = {
|
|
2727
|
+
[NIIVUE_VIEWS_SECTION]: [
|
|
2728
|
+
"NiivueMultiplanar",
|
|
2729
|
+
"NiivueAxial",
|
|
2730
|
+
"NiivueCoronal",
|
|
2731
|
+
"NiivueSagittal",
|
|
2732
|
+
"NiivueRender"
|
|
2733
|
+
],
|
|
2734
|
+
[NIIVUE_CLIP_SECTION]: [
|
|
2735
|
+
"NiivueClipNone",
|
|
2736
|
+
"NiivueClipRight",
|
|
2737
|
+
"NiivueClipLeft",
|
|
2738
|
+
"NiivueClipAnterior",
|
|
2739
|
+
"NiivueClipPosterior",
|
|
2740
|
+
"NiivueClipSuperior",
|
|
2741
|
+
"NiivueClipInferior"
|
|
2742
|
+
],
|
|
2743
|
+
[NIIVUE_WL_SECTION]: [
|
|
2744
|
+
"NiivueWLAuto",
|
|
2745
|
+
"NiivueWLCtSoft",
|
|
2746
|
+
"NiivueWLCtLung",
|
|
2747
|
+
"NiivueWLCtLiver",
|
|
2748
|
+
"NiivueWLCtBone",
|
|
2749
|
+
"NiivueWLCtBrain",
|
|
2750
|
+
"NiivueWLPtDefault",
|
|
2751
|
+
"NiivueWLPtSuv5",
|
|
2752
|
+
"NiivueWLPtSuv10"
|
|
2753
|
+
],
|
|
2754
|
+
[NIIVUE_COLORMAP_SECTION]: NIIVUE_COLORMAPS.map((c) => colormapButtonId(c.name))
|
|
2755
|
+
};
|
|
2756
|
+
var niivueToolbarCustomization = {
|
|
2757
|
+
"niivue.toolbarButtons": NIIVUE_TOOLBAR_BUTTONS,
|
|
2758
|
+
"niivue.toolbarSections": NIIVUE_TOOLBAR_SECTIONS
|
|
2759
|
+
};
|
|
2760
|
+
function buttonCommandOption(button, option) {
|
|
2761
|
+
const commands = button?.props?.commands;
|
|
2762
|
+
const options = commands && typeof commands === "object" && !Array.isArray(commands) ? commands.commandOptions : undefined;
|
|
2763
|
+
const value = options?.[option];
|
|
2764
|
+
return typeof value === "string" ? value : undefined;
|
|
2765
|
+
}
|
|
2766
|
+
var DISABLED = {
|
|
2767
|
+
disabled: true,
|
|
2768
|
+
disabledText: "Available on NiiVue viewports"
|
|
2769
|
+
};
|
|
2770
|
+
var VOLUME_DISABLED = {
|
|
2771
|
+
disabled: true,
|
|
2772
|
+
disabledText: "Available for NiiVue volume viewports"
|
|
2773
|
+
};
|
|
2774
|
+
function volumeDisabled(viewportId) {
|
|
2775
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2776
|
+
return !nv || !Array.isArray(nv.volumes) || nv.volumes.length === 0;
|
|
2777
|
+
}
|
|
2778
|
+
function getNiivueToolbarModule() {
|
|
2779
|
+
return [
|
|
2780
|
+
{
|
|
2781
|
+
name: NIIVUE_EVALUATOR,
|
|
2782
|
+
evaluate: ({ viewportId }) => getNiivueForViewport(viewportId) ? { disabled: false } : DISABLED
|
|
2783
|
+
},
|
|
2784
|
+
{
|
|
2785
|
+
name: SLICE_TYPE_EVALUATOR,
|
|
2786
|
+
evaluate: ({ viewportId, button }) => {
|
|
2787
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2788
|
+
if (!nv)
|
|
2789
|
+
return DISABLED;
|
|
2790
|
+
if (volumeDisabled(viewportId))
|
|
2791
|
+
return VOLUME_DISABLED;
|
|
2792
|
+
const name = buttonCommandOption(button, "sliceType");
|
|
2793
|
+
const target = name === undefined ? undefined : NIIVUE_SLICE_TYPES[name];
|
|
2794
|
+
return {
|
|
2795
|
+
disabled: false,
|
|
2796
|
+
isActive: target !== undefined && nv.sliceType === target
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
},
|
|
2800
|
+
{
|
|
2801
|
+
name: CLIP_PLANE_EVALUATOR,
|
|
2802
|
+
evaluate: ({ viewportId, button }) => {
|
|
2803
|
+
const entry = getNiivueEntryForViewport(viewportId);
|
|
2804
|
+
if (!entry)
|
|
2805
|
+
return DISABLED;
|
|
2806
|
+
if (volumeDisabled(viewportId))
|
|
2807
|
+
return VOLUME_DISABLED;
|
|
2808
|
+
const plane = buttonCommandOption(button, "plane");
|
|
2809
|
+
return {
|
|
2810
|
+
disabled: false,
|
|
2811
|
+
isActive: plane !== undefined && plane !== "none" && entry.clipPlane === plane
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
},
|
|
2815
|
+
{
|
|
2816
|
+
name: OVERLAY_EVALUATOR,
|
|
2817
|
+
evaluate: ({ viewportId }) => {
|
|
2818
|
+
const entry = getNiivueEntryForViewport(viewportId);
|
|
2819
|
+
if (!entry)
|
|
2820
|
+
return DISABLED;
|
|
2821
|
+
if (volumeDisabled(viewportId))
|
|
2822
|
+
return VOLUME_DISABLED;
|
|
2823
|
+
return { disabled: false, isActive: entry.overlayUIDs.length > 0 };
|
|
2824
|
+
}
|
|
2825
|
+
},
|
|
2826
|
+
{
|
|
2827
|
+
name: WL_PRESET_EVALUATOR,
|
|
2828
|
+
evaluate: ({ viewportId, button }) => {
|
|
2829
|
+
const entry = getNiivueEntryForViewport(viewportId);
|
|
2830
|
+
if (!entry)
|
|
2831
|
+
return DISABLED;
|
|
2832
|
+
if (volumeDisabled(viewportId))
|
|
2833
|
+
return VOLUME_DISABLED;
|
|
2834
|
+
const modality = buttonCommandOption(button, "modality");
|
|
2835
|
+
if (modality !== undefined && modality !== baseModality(entry)) {
|
|
2836
|
+
return {
|
|
2837
|
+
disabled: true,
|
|
2838
|
+
disabledText: `Applies to ${modality} series`
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
return { disabled: false };
|
|
2842
|
+
}
|
|
2843
|
+
},
|
|
2844
|
+
{
|
|
2845
|
+
name: COLORMAP_EVALUATOR,
|
|
2846
|
+
evaluate: ({ viewportId, button }) => {
|
|
2847
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2848
|
+
if (!nv)
|
|
2849
|
+
return DISABLED;
|
|
2850
|
+
if (volumeDisabled(viewportId))
|
|
2851
|
+
return VOLUME_DISABLED;
|
|
2852
|
+
const name = buttonCommandOption(button, "colormap");
|
|
2853
|
+
const current = nv.volumes[0]?.colormap;
|
|
2854
|
+
return {
|
|
2855
|
+
disabled: false,
|
|
2856
|
+
isActive: name !== undefined && typeof current === "string" && current.toLowerCase() === name.toLowerCase()
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
},
|
|
2860
|
+
{
|
|
2861
|
+
name: COLORBAR_EVALUATOR,
|
|
2862
|
+
evaluate: ({ viewportId }) => {
|
|
2863
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2864
|
+
if (!nv)
|
|
2865
|
+
return DISABLED;
|
|
2866
|
+
if (volumeDisabled(viewportId))
|
|
2867
|
+
return VOLUME_DISABLED;
|
|
2868
|
+
return { disabled: false, isActive: nv.isColorbarVisible === true };
|
|
2869
|
+
}
|
|
2870
|
+
},
|
|
2871
|
+
{
|
|
2872
|
+
name: INTERPOLATION_EVALUATOR,
|
|
2873
|
+
evaluate: ({ viewportId }) => {
|
|
2874
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2875
|
+
if (!nv)
|
|
2876
|
+
return DISABLED;
|
|
2877
|
+
if (volumeDisabled(viewportId))
|
|
2878
|
+
return VOLUME_DISABLED;
|
|
2879
|
+
return {
|
|
2880
|
+
disabled: false,
|
|
2881
|
+
isActive: nv.volumeIsNearestInterpolation === true
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
},
|
|
2885
|
+
{
|
|
2886
|
+
name: CROSSHAIR_EVALUATOR,
|
|
2887
|
+
evaluate: ({ viewportId }) => {
|
|
2888
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2889
|
+
if (!nv)
|
|
2890
|
+
return DISABLED;
|
|
2891
|
+
if (volumeDisabled(viewportId))
|
|
2892
|
+
return VOLUME_DISABLED;
|
|
2893
|
+
return { disabled: false, isActive: nv.crosshairWidth > 0 };
|
|
2894
|
+
}
|
|
2895
|
+
},
|
|
2896
|
+
{
|
|
2897
|
+
name: MEASUREMENT_EVALUATOR,
|
|
2898
|
+
evaluate: ({ viewportId }) => {
|
|
2899
|
+
const nv = getNiivueForViewport(viewportId);
|
|
2900
|
+
if (!nv)
|
|
2901
|
+
return DISABLED;
|
|
2902
|
+
if (volumeDisabled(viewportId))
|
|
2903
|
+
return VOLUME_DISABLED;
|
|
2904
|
+
return {
|
|
2905
|
+
disabled: false,
|
|
2906
|
+
isActive: nv.annotationIsEnabled === true && nv.annotationTool === "measureLine"
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
];
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
// src/index.ts
|
|
2914
|
+
var NIIVUE_OHIF_EXTENSION_ID = "@niivue/nv-ohif";
|
|
2915
|
+
var niivueOhifExtension = {
|
|
2916
|
+
id: NIIVUE_OHIF_EXTENSION_ID,
|
|
2917
|
+
version: "0.0.0",
|
|
2918
|
+
getViewportModule: () => getNiivueViewportModule(),
|
|
2919
|
+
getSopClassHandlerModule: () => getNiivueSopClassHandlerModule(),
|
|
2920
|
+
getCommandsModule: (params) => getNiivueCommandsModule(params),
|
|
2921
|
+
getToolbarModule: () => getNiivueToolbarModule(),
|
|
2922
|
+
getCustomizationModule: () => [
|
|
2923
|
+
{ name: "default", value: niivueToolbarCustomization }
|
|
2924
|
+
]
|
|
2925
|
+
};
|
|
2926
|
+
var src_default = niivueOhifExtension;
|
|
2927
|
+
export {
|
|
2928
|
+
niivueToolbarCustomization,
|
|
2929
|
+
getNiivueViewportModule,
|
|
2930
|
+
getNiivueToolbarModule,
|
|
2931
|
+
getNiivueSopClassHandlerModule,
|
|
2932
|
+
getNiivueCommandsModule,
|
|
2933
|
+
displaySetToNiivue,
|
|
2934
|
+
src_default as default,
|
|
2935
|
+
NiivueViewport,
|
|
2936
|
+
NIIVUE_WL_SECTION,
|
|
2937
|
+
NIIVUE_VIEWS_SECTION,
|
|
2938
|
+
NIIVUE_VIEWPORT_NAME,
|
|
2939
|
+
NIIVUE_TOOLBAR_SECTIONS,
|
|
2940
|
+
NIIVUE_TOOLBAR_BUTTONS,
|
|
2941
|
+
NIIVUE_SOP_CLASS_HANDLER_NAME,
|
|
2942
|
+
NIIVUE_SLICE_TYPES,
|
|
2943
|
+
NIIVUE_RESET_BUTTON,
|
|
2944
|
+
NIIVUE_OVERLAY_BUTTON,
|
|
2945
|
+
NIIVUE_OHIF_EXTENSION_ID,
|
|
2946
|
+
NIIVUE_INTERPOLATION_BUTTON,
|
|
2947
|
+
NIIVUE_COLORMAP_SECTION,
|
|
2948
|
+
NIIVUE_COLORMAPS,
|
|
2949
|
+
NIIVUE_COLORBAR_BUTTON,
|
|
2950
|
+
NIIVUE_CLIP_SECTION,
|
|
2951
|
+
NIIVUE_CLIP_PLANES
|
|
2952
|
+
};
|