nuxt-cornerstone 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +933 -0
  3. package/dist/module.d.mts +22 -0
  4. package/dist/module.json +12 -0
  5. package/dist/module.mjs +168 -0
  6. package/dist/runtime/annotation-json.d.ts +76 -0
  7. package/dist/runtime/annotation-json.js +176 -0
  8. package/dist/runtime/components/CornerstoneViewport.d.vue.ts +57 -0
  9. package/dist/runtime/components/CornerstoneViewport.vue +227 -0
  10. package/dist/runtime/components/CornerstoneViewport.vue.d.ts +57 -0
  11. package/dist/runtime/composables/useAnnotationReport.d.ts +28 -0
  12. package/dist/runtime/composables/useAnnotationReport.js +66 -0
  13. package/dist/runtime/composables/useCinePlayer.d.ts +52 -0
  14. package/dist/runtime/composables/useCinePlayer.js +95 -0
  15. package/dist/runtime/composables/useCornerstone.d.ts +17 -0
  16. package/dist/runtime/composables/useCornerstone.js +30 -0
  17. package/dist/runtime/composables/useCornerstoneI18n.d.ts +24 -0
  18. package/dist/runtime/composables/useCornerstoneI18n.js +32 -0
  19. package/dist/runtime/composables/useCornerstoneTools.d.ts +21 -0
  20. package/dist/runtime/composables/useCornerstoneTools.js +105 -0
  21. package/dist/runtime/composables/useDicomAnnotations.d.ts +76 -0
  22. package/dist/runtime/composables/useDicomAnnotations.js +220 -0
  23. package/dist/runtime/composables/useDicomFiles.d.ts +87 -0
  24. package/dist/runtime/composables/useDicomFiles.js +234 -0
  25. package/dist/runtime/composables/useDicomGuard.d.ts +24 -0
  26. package/dist/runtime/composables/useDicomGuard.js +30 -0
  27. package/dist/runtime/composables/useDicomStudy.d.ts +122 -0
  28. package/dist/runtime/composables/useDicomStudy.js +222 -0
  29. package/dist/runtime/composables/useImagePrefetch.d.ts +78 -0
  30. package/dist/runtime/composables/useImagePrefetch.js +119 -0
  31. package/dist/runtime/composables/useMeasurements.d.ts +51 -0
  32. package/dist/runtime/composables/useMeasurements.js +138 -0
  33. package/dist/runtime/composables/useRenderingEngine.d.ts +6 -0
  34. package/dist/runtime/composables/useRenderingEngine.js +46 -0
  35. package/dist/runtime/composables/useStackCine.d.ts +50 -0
  36. package/dist/runtime/composables/useStackCine.js +62 -0
  37. package/dist/runtime/composables/useViewerShortcuts.d.ts +94 -0
  38. package/dist/runtime/composables/useViewerShortcuts.js +115 -0
  39. package/dist/runtime/cornerstone.d.ts +21 -0
  40. package/dist/runtime/cornerstone.js +100 -0
  41. package/dist/runtime/dicom-instances.d.ts +32 -0
  42. package/dist/runtime/dicom-instances.js +15 -0
  43. package/dist/runtime/dicom-zip.d.ts +95 -0
  44. package/dist/runtime/dicom-zip.js +149 -0
  45. package/dist/runtime/i18n/detect.d.ts +37 -0
  46. package/dist/runtime/i18n/detect.js +37 -0
  47. package/dist/runtime/i18n/index.d.ts +61 -0
  48. package/dist/runtime/i18n/index.js +145 -0
  49. package/dist/runtime/i18n/messages.d.ts +122 -0
  50. package/dist/runtime/i18n/messages.js +147 -0
  51. package/dist/runtime/plugin.client.d.ts +13 -0
  52. package/dist/runtime/plugin.client.js +31 -0
  53. package/dist/runtime/plugin.i18n.d.ts +12 -0
  54. package/dist/runtime/plugin.i18n.js +15 -0
  55. package/dist/runtime/types.d.ts +150 -0
  56. package/dist/runtime/types.js +10 -0
  57. package/dist/types.d.mts +29 -0
  58. package/package.json +76 -0
@@ -0,0 +1,234 @@
1
+ import { ensureCornerstone } from "../cornerstone.js";
2
+ import { clearInstanceIndex, imageIdForSopInstanceUid, registerInstance } from "../dicom-instances.js";
3
+ import { t } from "../i18n/index.js";
4
+ import { unzipDicom } from "../dicom-zip.js";
5
+ const TAG = {
6
+ sopInstanceUid: "x00080018",
7
+ modality: "x00080060",
8
+ seriesDescription: "x0008103e",
9
+ seriesInstanceUid: "x0020000e",
10
+ seriesNumber: "x00200011",
11
+ instanceNumber: "x00200013"
12
+ };
13
+ const UNTIL_TAG = TAG.instanceNumber;
14
+ const HEADER_PROBE_BYTES = 256 * 1024;
15
+ const INDEX_CHUNK = 32;
16
+ const collator = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
17
+ export function useDicomFiles() {
18
+ async function addFiles(input, options = {}) {
19
+ const { sort = "instanceNumber" } = options;
20
+ const files = Array.from(input);
21
+ if (files.length === 0) return [];
22
+ const { dicomImageLoader } = await ensureCornerstone();
23
+ const indexed = files.map((file) => ({
24
+ name: file.name,
25
+ imageId: dicomImageLoader.wadouri.fileManager.add(file),
26
+ header: null
27
+ }));
28
+ if (sort === false) return indexed.map((entry) => entry.imageId);
29
+ if (sort === "instanceNumber") {
30
+ await Promise.all(
31
+ files.map(async (file, i) => {
32
+ indexed[i].header = await readFileHeader(file);
33
+ })
34
+ );
35
+ indexInstances(indexed);
36
+ }
37
+ sortImages(indexed, sort);
38
+ return indexed.map((entry) => entry.imageId);
39
+ }
40
+ async function addZip(input, options = {}) {
41
+ const { sort = "instanceNumber", groupBy = "series", maxBytes, onProgress } = options;
42
+ onProgress?.({ phase: "reading", done: 0, total: 0 });
43
+ const data = await toBytes(input);
44
+ onProgress?.({ phase: "extracting", done: 0, total: 0 });
45
+ const { entries, skipped } = await unzipDicom(data, { maxBytes });
46
+ if (entries.length === 0) {
47
+ return { series: [], imageIds: [], skipped };
48
+ }
49
+ const { dicomImageLoader } = await ensureCornerstone();
50
+ const indexed = entries.map((entry) => ({
51
+ name: entry.path,
52
+ imageId: dicomImageLoader.wadouri.fileManager.add(toFile(entry)),
53
+ header: null
54
+ }));
55
+ if (groupBy === "series" || sort === "instanceNumber") {
56
+ await indexHeaders(entries, indexed, onProgress);
57
+ indexInstances(indexed);
58
+ }
59
+ const groups = groupBy === "series" ? groupBySeries(indexed) : /* @__PURE__ */ new Map([["", indexed]]);
60
+ const series = [];
61
+ for (const [key, images] of groups) {
62
+ if (sort !== false) sortImages(images, sort);
63
+ series.push(toSeries(key, images));
64
+ }
65
+ series.sort(compareSeries);
66
+ return {
67
+ series,
68
+ imageIds: series.flatMap((entry) => entry.imageIds),
69
+ skipped
70
+ };
71
+ }
72
+ function toImageId(url) {
73
+ return url.startsWith("wadouri:") ? url : `wadouri:${url}`;
74
+ }
75
+ async function indexUrls(urls, options = {}) {
76
+ const { concurrency = 6 } = options;
77
+ const failed = [];
78
+ let indexed = 0;
79
+ let next = 0;
80
+ async function worker() {
81
+ while (next < urls.length) {
82
+ const url = urls[next++];
83
+ const uid = await readUrlHeader(url);
84
+ if (uid) {
85
+ registerInstance(uid, toImageId(url));
86
+ indexed += 1;
87
+ } else failed.push(url);
88
+ }
89
+ }
90
+ await Promise.all(
91
+ Array.from({ length: Math.min(concurrency, urls.length) }, () => worker())
92
+ );
93
+ return { indexed, failed };
94
+ }
95
+ async function purge() {
96
+ const { dicomImageLoader } = await ensureCornerstone();
97
+ dicomImageLoader.wadouri.fileManager.purge();
98
+ clearInstanceIndex();
99
+ }
100
+ return { addFiles, addZip, toImageId, indexUrls, purge, imageIdForSopInstanceUid };
101
+ }
102
+ function indexInstances(images) {
103
+ for (const image of images) {
104
+ const uid = image.header?.sopInstanceUid;
105
+ if (uid) registerInstance(uid, image.imageId);
106
+ }
107
+ }
108
+ function toFile(entry) {
109
+ return new File([entry.bytes], entry.path, { type: "application/dicom" });
110
+ }
111
+ async function toBytes(input) {
112
+ if (input instanceof Uint8Array) return input;
113
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
114
+ return new Uint8Array(await input.arrayBuffer());
115
+ }
116
+ async function indexHeaders(entries, indexed, onProgress) {
117
+ const total = entries.length;
118
+ const dicomParser = await loadDicomParser();
119
+ onProgress?.({ phase: "indexing", done: 0, total });
120
+ for (let i = 0; i < total; i++) {
121
+ indexed[i].header = parseHeader(dicomParser, entries[i].bytes);
122
+ if ((i + 1) % INDEX_CHUNK === 0 || i + 1 === total) {
123
+ onProgress?.({ phase: "indexing", done: i + 1, total });
124
+ await yieldToEventLoop();
125
+ }
126
+ }
127
+ }
128
+ function yieldToEventLoop() {
129
+ return new Promise((resolve) => setTimeout(resolve, 0));
130
+ }
131
+ function sortImages(images, mode) {
132
+ if (mode === "name") {
133
+ images.sort((a, b) => collator.compare(a.name, b.name));
134
+ return;
135
+ }
136
+ images.sort((a, b) => {
137
+ const left = a.header?.instanceNumber ?? null;
138
+ const right = b.header?.instanceNumber ?? null;
139
+ if (left !== null && right !== null && left !== right) return left - right;
140
+ if (left !== null && right === null) return -1;
141
+ if (left === null && right !== null) return 1;
142
+ return collator.compare(a.name, b.name);
143
+ });
144
+ }
145
+ function groupBySeries(images) {
146
+ const groups = /* @__PURE__ */ new Map();
147
+ for (const image of images) {
148
+ const key = image.header?.seriesInstanceUid ?? directoryOf(image.name);
149
+ const group = groups.get(key);
150
+ if (group) group.push(image);
151
+ else groups.set(key, [image]);
152
+ }
153
+ return groups;
154
+ }
155
+ function directoryOf(path) {
156
+ const index = path.lastIndexOf("/");
157
+ return index === -1 ? "" : path.slice(0, index);
158
+ }
159
+ function toSeries(key, images) {
160
+ const header = images.find((image) => image.header !== null)?.header ?? null;
161
+ const count = images.length;
162
+ const seriesNumber = header?.seriesNumber ?? null;
163
+ const description = header?.seriesDescription ?? null;
164
+ const modality = header?.modality ?? null;
165
+ const name = description ?? (key && !header?.seriesInstanceUid ? key : null) ?? (seriesNumber !== null ? t("series.numbered", { number: seriesNumber }) : t("series.unnamed"));
166
+ const details = [modality, t("series.images", { count })].filter(Boolean).join(t("list.separator"));
167
+ return {
168
+ seriesInstanceUid: header?.seriesInstanceUid ?? key,
169
+ seriesNumber,
170
+ description,
171
+ modality,
172
+ label: t("series.label", { name, details }),
173
+ imageIds: images.map((image) => image.imageId)
174
+ };
175
+ }
176
+ function compareSeries(a, b) {
177
+ if (a.seriesNumber !== null && b.seriesNumber !== null && a.seriesNumber !== b.seriesNumber) {
178
+ return a.seriesNumber - b.seriesNumber;
179
+ }
180
+ if (a.seriesNumber !== null && b.seriesNumber === null) return -1;
181
+ if (a.seriesNumber === null && b.seriesNumber !== null) return 1;
182
+ return collator.compare(a.label, b.label);
183
+ }
184
+ async function readFileHeader(file) {
185
+ const dicomParser = await loadDicomParser();
186
+ const head = await file.slice(0, HEADER_PROBE_BYTES).arrayBuffer();
187
+ const probed = parseHeader(dicomParser, new Uint8Array(head));
188
+ if (probed !== null) return probed;
189
+ if (file.size <= HEADER_PROBE_BYTES) return null;
190
+ return parseHeader(dicomParser, new Uint8Array(await file.arrayBuffer()));
191
+ }
192
+ async function readUrlHeader(url) {
193
+ try {
194
+ const response = await fetch(url, {
195
+ headers: { Range: `bytes=0-${HEADER_PROBE_BYTES - 1}` }
196
+ });
197
+ if (!response.ok) return null;
198
+ const dicomParser = await loadDicomParser();
199
+ const bytes = new Uint8Array(await response.arrayBuffer());
200
+ return parseHeader(dicomParser, bytes)?.sopInstanceUid ?? null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ function parseHeader(dicomParser, bytes) {
206
+ try {
207
+ const dataSet = dicomParser.parseDicom(bytes, { untilTag: UNTIL_TAG });
208
+ return {
209
+ instanceNumber: intOrNull(dataSet.intString(TAG.instanceNumber)),
210
+ sopInstanceUid: textOrNull(dataSet.string(TAG.sopInstanceUid)),
211
+ seriesInstanceUid: textOrNull(dataSet.string(TAG.seriesInstanceUid)),
212
+ seriesNumber: intOrNull(dataSet.intString(TAG.seriesNumber)),
213
+ seriesDescription: textOrNull(dataSet.string(TAG.seriesDescription)),
214
+ modality: textOrNull(dataSet.string(TAG.modality))
215
+ };
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+ function intOrNull(value) {
221
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
222
+ }
223
+ function textOrNull(value) {
224
+ const trimmed = value?.trim();
225
+ return trimmed ? trimmed : null;
226
+ }
227
+ let dicomParserPromise = null;
228
+ function loadDicomParser() {
229
+ dicomParserPromise ??= import("dicom-parser").then((mod) => {
230
+ const candidate = mod;
231
+ return candidate.default ?? mod;
232
+ });
233
+ return dicomParserPromise;
234
+ }
@@ -0,0 +1,24 @@
1
+ import type { SkipReason } from '../dicom-zip.js';
2
+ export interface RejectedFile {
3
+ name: string;
4
+ reason: SkipReason;
5
+ }
6
+ export interface GuardResult {
7
+ accepted: File[];
8
+ rejected: RejectedFile[];
9
+ }
10
+ /**
11
+ * Decide which of a picked or dropped selection are worth handing to
12
+ * `addFiles()`.
13
+ *
14
+ * `addFiles()` registers whatever it is given, so a file that is not DICOM
15
+ * becomes an imageId that fails later, at render, with an error pointing at
16
+ * the viewport rather than at the file. Answering the question up front means
17
+ * the failure names the file instead.
18
+ *
19
+ * Every file has to prove itself. A name is not evidence in either direction:
20
+ * plenty of DICOM files are called `IM000001`, and a PNG renamed to `.dcm` is
21
+ * still a PNG. The decision is made on content, by {@link isDicomContent},
22
+ * which is the same test the archive reader applies.
23
+ */
24
+ export declare function guardDicomFiles(files: File[]): Promise<GuardResult>;
@@ -0,0 +1,30 @@
1
+ import {
2
+ CONTENT_PROBE_BYTES,
3
+ isDicomContent,
4
+ nonDicomNameReason
5
+ } from "../dicom-zip.js";
6
+ export async function guardDicomFiles(files) {
7
+ const accepted = [];
8
+ const rejected = [];
9
+ for (const file of files) {
10
+ const nameReason = nonDicomNameReason(file.name);
11
+ if (nameReason) {
12
+ rejected.push({ name: file.name, reason: nameReason });
13
+ continue;
14
+ }
15
+ if (file.size === 0) {
16
+ rejected.push({ name: file.name, reason: "empty" });
17
+ continue;
18
+ }
19
+ if (!await isDicom(file)) {
20
+ rejected.push({ name: file.name, reason: "not-dicom" });
21
+ continue;
22
+ }
23
+ accepted.push(file);
24
+ }
25
+ return { accepted, rejected };
26
+ }
27
+ async function isDicom(file) {
28
+ const head = await file.slice(0, CONTENT_PROBE_BYTES).arrayBuffer();
29
+ return isDicomContent(new Uint8Array(head));
30
+ }
@@ -0,0 +1,122 @@
1
+ import type { DicomSeries, ZipProgress } from './useDicomFiles.js';
2
+ /**
3
+ * What produced the current stack. Kept as data rather than as a finished
4
+ * string, so a caption re-renders in the new language when the locale changes
5
+ * instead of freezing whatever was picked at load time.
6
+ */
7
+ export type SourceInfo = {
8
+ kind: 'urls';
9
+ count: number;
10
+ } | {
11
+ kind: 'files';
12
+ count: number;
13
+ skipped: number;
14
+ } | {
15
+ kind: 'zip';
16
+ file: string;
17
+ images: number;
18
+ series: number;
19
+ skipped: number;
20
+ };
21
+ /**
22
+ * A problem to show. Either a key to translate, or a message that already came
23
+ * out of a thrown error — those are translated at the moment they are thrown,
24
+ * so switching locale afterwards does not rewrite them.
25
+ */
26
+ export type Problem = {
27
+ key: string;
28
+ params?: Record<string, string | number>;
29
+ } | {
30
+ message: string;
31
+ };
32
+ export interface OpenUrlsOptions {
33
+ /**
34
+ * Read each image's headers after the stack is on screen. Default: `true`.
35
+ *
36
+ * A `wadouri:` imageId is built without reading the file, so nothing would
37
+ * know these slices' SOPInstanceUIDs and an annotation report could never be
38
+ * matched to them. Indexing afterwards keeps the images showing straight
39
+ * away and does not block them.
40
+ */
41
+ index?: boolean;
42
+ /**
43
+ * A caption for {@link useDicomStudy.sourceLabel} in place of the built-in
44
+ * count. Pass a getter rather than a string, so it re-runs on a locale
45
+ * switch like every other label here.
46
+ */
47
+ label?: () => string;
48
+ }
49
+ /**
50
+ * Everything an application needs to know about the stack on screen: where it
51
+ * came from, how loading it is going, and which series of an archive is
52
+ * showing.
53
+ *
54
+ * This is the orchestration layer over {@link useDicomFiles} — the part every
55
+ * viewer ends up writing, and the part that has nothing to do with how the
56
+ * viewer looks. It produces state and already-translated captions; the
57
+ * application supplies the buttons, the drop target and the series list.
58
+ *
59
+ * State is created per call rather than held at module scope. Module-scoped
60
+ * state is shared by every in-flight request during SSR, which is the same
61
+ * trap the README describes for the locale. Call this once, high up, and pass
62
+ * what each component needs down as props.
63
+ */
64
+ export declare function useDicomStudy(): {
65
+ ready: import("vue").ComputedRef<boolean>;
66
+ imageIds: import("vue").Ref<string[], string[]>;
67
+ imageIndex: import("vue").Ref<number, number>;
68
+ maxIndex: import("vue").ComputedRef<number>;
69
+ source: import("vue").Ref<{
70
+ kind: "urls";
71
+ count: number;
72
+ } | {
73
+ kind: "files";
74
+ count: number;
75
+ skipped: number;
76
+ } | {
77
+ kind: "zip";
78
+ file: string;
79
+ images: number;
80
+ series: number;
81
+ skipped: number;
82
+ } | null, SourceInfo | {
83
+ kind: "urls";
84
+ count: number;
85
+ } | {
86
+ kind: "files";
87
+ count: number;
88
+ skipped: number;
89
+ } | {
90
+ kind: "zip";
91
+ file: string;
92
+ images: number;
93
+ series: number;
94
+ skipped: number;
95
+ } | null>;
96
+ busy: import("vue").Ref<boolean, boolean>;
97
+ problemText: import("vue").ComputedRef<string | null>;
98
+ series: import("vue").ShallowRef<DicomSeries[], DicomSeries[]>;
99
+ activeSeriesUid: import("vue").Ref<string | null, string | null>;
100
+ progress: import("vue").Ref<{
101
+ phase: "reading" | "extracting" | "indexing";
102
+ done: number;
103
+ total: number;
104
+ } | null, ZipProgress | {
105
+ phase: "reading" | "extracting" | "indexing";
106
+ done: number;
107
+ total: number;
108
+ } | null>;
109
+ progressLabel: import("vue").ComputedRef<string>;
110
+ progressValue: import("vue").ComputedRef<number | null>;
111
+ sourceLabel: import("vue").ComputedRef<string>;
112
+ rejectedText: import("vue").ComputedRef<string | null>;
113
+ openUrls: (urls: string[], options?: OpenUrlsOptions) => void;
114
+ openFiles: (files: File[] | FileList | null) => Promise<void>;
115
+ openZip: (file: File) => Promise<void>;
116
+ openAny: (files: File[]) => void;
117
+ selectSeries: (uid: string | null) => void;
118
+ step: (delta: number) => void;
119
+ stepSeries: (delta: number) => void;
120
+ clear: () => Promise<void>;
121
+ setProblem: (value: Problem | null) => void;
122
+ };
@@ -0,0 +1,222 @@
1
+ import { computed, ref, shallowRef } from "vue";
2
+ import { t } from "../i18n/index.js";
3
+ import { useCornerstone } from "./useCornerstone.js";
4
+ import { useDicomFiles } from "./useDicomFiles.js";
5
+ import { guardDicomFiles } from "./useDicomGuard.js";
6
+ const SKIP_REASON_KEY = {
7
+ "metadata": "study.skip.metadata",
8
+ "not-dicom-extension": "study.skip.notDicomExtension",
9
+ "not-dicom": "study.skip.notDicom",
10
+ "empty": "study.skip.empty"
11
+ };
12
+ const MAX_NAMED_REJECTS = 3;
13
+ export function useDicomStudy() {
14
+ const { ready, error: initError } = useCornerstone();
15
+ const { addFiles, addZip, toImageId, indexUrls, purge } = useDicomFiles();
16
+ const imageIds = ref([]);
17
+ const imageIndex = ref(0);
18
+ const source = ref(null);
19
+ const busy = ref(false);
20
+ const problem = ref(null);
21
+ const sourceLabelOverride = shallowRef(null);
22
+ const series = shallowRef([]);
23
+ const activeSeriesUid = ref(null);
24
+ const progress = ref(null);
25
+ const rejected = ref([]);
26
+ const maxIndex = computed(() => Math.max(0, imageIds.value.length - 1));
27
+ const problemText = computed(() => {
28
+ if (initError.value) return initError.value.message;
29
+ const value = problem.value;
30
+ if (!value) return null;
31
+ return "key" in value ? t(value.key, value.params) : value.message;
32
+ });
33
+ function withSkipped(summary, skipped) {
34
+ if (!skipped) return summary;
35
+ return summary + t("list.separator") + t("study.count.skipped", { count: skipped });
36
+ }
37
+ const sourceLabel = computed(() => {
38
+ const value = source.value;
39
+ if (!value) return "";
40
+ if (value.kind === "urls") {
41
+ return sourceLabelOverride.value?.() ?? t("study.source.urls", { count: value.count });
42
+ }
43
+ if (value.kind === "files") {
44
+ return withSkipped(t("study.source.files", { count: value.count }), value.skipped);
45
+ }
46
+ return withSkipped(
47
+ t("study.source.zip", {
48
+ file: value.file,
49
+ images: t("series.images", { count: value.images }),
50
+ series: t("study.count.series", { count: value.series })
51
+ }),
52
+ value.skipped
53
+ );
54
+ });
55
+ const rejectedText = computed(() => {
56
+ const list = rejected.value;
57
+ if (!list.length) return null;
58
+ const named = list.slice(0, MAX_NAMED_REJECTS).map((entry) => t("study.skipped.entry", {
59
+ name: entry.name,
60
+ reason: t(SKIP_REASON_KEY[entry.reason])
61
+ })).join(t("list.separator"));
62
+ const count = t("study.count.skipped", { count: list.length });
63
+ const rest = list.length - Math.min(list.length, MAX_NAMED_REJECTS);
64
+ return rest > 0 ? t("study.skipped.more", { count, named, rest }) : t("study.skipped.summary", { count, named });
65
+ });
66
+ const progressLabel = computed(() => {
67
+ const value = progress.value;
68
+ if (!value) return "";
69
+ if (value.phase === "reading") return t("study.progress.reading");
70
+ if (value.phase === "extracting") return t("study.progress.extracting");
71
+ return t("study.progress.indexing", { done: value.done, total: value.total });
72
+ });
73
+ const progressValue = computed(() => {
74
+ const value = progress.value;
75
+ if (!value || value.phase !== "indexing" || value.total === 0) return null;
76
+ return Math.round(value.done / value.total * 100);
77
+ });
78
+ function openUrls(urls, options = {}) {
79
+ if (!urls.length) {
80
+ problem.value = { key: "study.error.noUrls" };
81
+ return;
82
+ }
83
+ problem.value = null;
84
+ rejected.value = [];
85
+ resetSeries();
86
+ imageIndex.value = 0;
87
+ imageIds.value = urls.map(toImageId);
88
+ source.value = { kind: "urls", count: urls.length };
89
+ sourceLabelOverride.value = options.label ?? null;
90
+ if (options.index !== false) indexUrls(urls);
91
+ }
92
+ async function openFiles(files) {
93
+ if (!files) return;
94
+ const list = Array.from(files);
95
+ if (!list.length) return;
96
+ busy.value = true;
97
+ problem.value = null;
98
+ rejected.value = [];
99
+ sourceLabelOverride.value = null;
100
+ try {
101
+ const guard = await guardDicomFiles(list);
102
+ rejected.value = guard.rejected;
103
+ if (!guard.accepted.length) {
104
+ problem.value = { key: "study.error.noDicomFiles", params: { count: list.length } };
105
+ return;
106
+ }
107
+ const ids = await addFiles(guard.accepted);
108
+ resetSeries();
109
+ imageIndex.value = 0;
110
+ imageIds.value = ids;
111
+ source.value = { kind: "files", count: ids.length, skipped: guard.rejected.length };
112
+ } catch (caught) {
113
+ problem.value = { message: caught instanceof Error ? caught.message : String(caught) };
114
+ } finally {
115
+ busy.value = false;
116
+ }
117
+ }
118
+ async function openZip(file) {
119
+ busy.value = true;
120
+ problem.value = null;
121
+ progress.value = null;
122
+ sourceLabelOverride.value = null;
123
+ rejected.value = [];
124
+ try {
125
+ const result = await addZip(file, {
126
+ onProgress: (value) => progress.value = value
127
+ });
128
+ if (!result.series.length) {
129
+ resetSeries();
130
+ imageIds.value = [];
131
+ source.value = null;
132
+ problem.value = { key: "study.error.noDicomInZip", params: { file: file.name } };
133
+ return;
134
+ }
135
+ series.value = result.series;
136
+ selectSeries(result.series[0].seriesInstanceUid);
137
+ source.value = {
138
+ kind: "zip",
139
+ file: file.name,
140
+ images: result.imageIds.length,
141
+ series: result.series.length,
142
+ skipped: result.skipped.length
143
+ };
144
+ } catch (caught) {
145
+ problem.value = { message: caught instanceof Error ? caught.message : String(caught) };
146
+ } finally {
147
+ busy.value = false;
148
+ progress.value = null;
149
+ }
150
+ }
151
+ function selectSeries(uid) {
152
+ const chosen = series.value.find((entry) => entry.seriesInstanceUid === uid);
153
+ if (!chosen) return;
154
+ activeSeriesUid.value = chosen.seriesInstanceUid;
155
+ imageIndex.value = 0;
156
+ imageIds.value = chosen.imageIds;
157
+ }
158
+ function resetSeries() {
159
+ series.value = [];
160
+ activeSeriesUid.value = null;
161
+ }
162
+ function isZip(file) {
163
+ return /\.zip$/i.test(file.name) || /zip/.test(file.type);
164
+ }
165
+ function openAny(files) {
166
+ if (!files.length) return;
167
+ const archive = files.find(isZip);
168
+ if (archive) openZip(archive);
169
+ else openFiles(files);
170
+ }
171
+ function step(delta) {
172
+ if (!imageIds.value.length) return;
173
+ imageIndex.value = Math.min(maxIndex.value, Math.max(0, imageIndex.value + delta));
174
+ }
175
+ function stepSeries(delta) {
176
+ if (series.value.length < 2) return;
177
+ const current = series.value.findIndex(
178
+ (entry) => entry.seriesInstanceUid === activeSeriesUid.value
179
+ );
180
+ const next = Math.min(series.value.length - 1, Math.max(0, current + delta));
181
+ if (next === current) return;
182
+ selectSeries(series.value[next].seriesInstanceUid);
183
+ }
184
+ async function clear() {
185
+ imageIds.value = [];
186
+ imageIndex.value = 0;
187
+ source.value = null;
188
+ sourceLabelOverride.value = null;
189
+ rejected.value = [];
190
+ problem.value = null;
191
+ resetSeries();
192
+ await purge();
193
+ }
194
+ function setProblem(value) {
195
+ problem.value = value;
196
+ }
197
+ return {
198
+ ready,
199
+ imageIds,
200
+ imageIndex,
201
+ maxIndex,
202
+ source,
203
+ busy,
204
+ problemText,
205
+ series,
206
+ activeSeriesUid,
207
+ progress,
208
+ progressLabel,
209
+ progressValue,
210
+ sourceLabel,
211
+ rejectedText,
212
+ openUrls,
213
+ openFiles,
214
+ openZip,
215
+ openAny,
216
+ selectSeries,
217
+ step,
218
+ stepSeries,
219
+ clear,
220
+ setProblem
221
+ };
222
+ }