@omniradiology/omnirad 0.1.3

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 (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
@@ -0,0 +1,645 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useImperativeHandle, forwardRef, useState, useCallback } from 'react';
4
+ import * as dicomParser from 'dicom-parser';
5
+
6
+ export interface DicomViewerHandle {
7
+ captureFrame: () => Promise<string | null>;
8
+ captureMultipleFrames: (maxSlices?: number) => Promise<string[]>;
9
+ getTotalFrames: () => number;
10
+ }
11
+
12
+ interface DicomViewerProps {
13
+ file: File;
14
+ className?: string;
15
+ onReady?: () => void;
16
+ }
17
+
18
+ interface FrameData {
19
+ values: Float32Array;
20
+ }
21
+
22
+ interface PixelInfo {
23
+ rows: number;
24
+ columns: number;
25
+ minPixel: number;
26
+ maxPixel: number;
27
+ defaultWC: number;
28
+ defaultWW: number;
29
+ isMonochrome1: boolean;
30
+ frames: FrameData[];
31
+ isRGB: boolean;
32
+ rgbFrames?: Uint8Array[];
33
+ }
34
+
35
+ // Helper: render a specific frame to an offscreen canvas and return JPEG base64
36
+ function renderFrameToCanvas(
37
+ pi: PixelInfo, frameIdx: number, wc: number, ww: number
38
+ ): string | null {
39
+ const offscreen = document.createElement('canvas');
40
+ offscreen.width = pi.columns;
41
+ offscreen.height = pi.rows;
42
+ const ctx = offscreen.getContext('2d');
43
+ if (!ctx) return null;
44
+
45
+ const imageData = ctx.createImageData(pi.columns, pi.rows);
46
+ const data = imageData.data;
47
+
48
+ if (pi.isRGB && pi.rgbFrames) {
49
+ const rgb = pi.rgbFrames[frameIdx];
50
+ for (let i = 0; i < pi.rows * pi.columns; i++) {
51
+ data[i * 4] = rgb[i * 3];
52
+ data[i * 4 + 1] = rgb[i * 3 + 1];
53
+ data[i * 4 + 2] = rgb[i * 3 + 2];
54
+ data[i * 4 + 3] = 255;
55
+ }
56
+ } else {
57
+ const frame = pi.frames[frameIdx];
58
+ if (!frame) return null;
59
+ const vals = frame.values;
60
+ const lower = wc - 0.5 - (ww - 1) / 2;
61
+ const upper = wc - 0.5 + (ww - 1) / 2;
62
+ const range = upper - lower;
63
+
64
+ for (let i = 0; i < vals.length; i++) {
65
+ let lum: number;
66
+ if (vals[i] <= lower) lum = 0;
67
+ else if (vals[i] >= upper) lum = 255;
68
+ else lum = ((vals[i] - lower) / range) * 255;
69
+ if (pi.isMonochrome1) lum = 255 - lum;
70
+ data[i * 4] = lum;
71
+ data[i * 4 + 1] = lum;
72
+ data[i * 4 + 2] = lum;
73
+ data[i * 4 + 3] = 255;
74
+ }
75
+ }
76
+
77
+ ctx.putImageData(imageData, 0, 0);
78
+ return offscreen.toDataURL('image/jpeg', 0.85);
79
+ }
80
+
81
+ export const DicomViewer = forwardRef<DicomViewerHandle, DicomViewerProps>(({ file, className, onReady }, ref) => {
82
+ const canvasRef = useRef<HTMLCanvasElement>(null);
83
+ const pixelInfoRef = useRef<PixelInfo | null>(null);
84
+ const [error, setError] = useState<string | null>(null);
85
+ const [loading, setLoading] = useState(true);
86
+
87
+ // View state
88
+ const windowCenterRef = useRef(0);
89
+ const windowWidthRef = useRef(0);
90
+ const [windowCenter, setWindowCenter] = useState(0);
91
+ const [windowWidth, setWindowWidth] = useState(0);
92
+ const [zoom, setZoom] = useState(1);
93
+ const [panX, setPanX] = useState(0);
94
+ const [panY, setPanY] = useState(0);
95
+ const [currentFrame, setCurrentFrame] = useState(0);
96
+ const [totalFrames, setTotalFrames] = useState(1);
97
+ const isDraggingRef = useRef(false);
98
+ const dragButtonRef = useRef(0);
99
+ const lastPosRef = useRef({ x: 0, y: 0 });
100
+
101
+ // Keep refs in sync with state for imperative access
102
+ useEffect(() => { windowCenterRef.current = windowCenter; }, [windowCenter]);
103
+ useEffect(() => { windowWidthRef.current = windowWidth; }, [windowWidth]);
104
+
105
+ useImperativeHandle(ref, () => ({
106
+ captureFrame: async () => {
107
+ if (canvasRef.current) {
108
+ return canvasRef.current.toDataURL('image/jpeg', 0.92);
109
+ }
110
+ return null;
111
+ },
112
+
113
+ // Capture up to maxSlices evenly-spaced frames as JPEG base64 strings
114
+ captureMultipleFrames: async (maxSlices: number = 5): Promise<string[]> => {
115
+ const pi = pixelInfoRef.current;
116
+ if (!pi) return [];
117
+
118
+ const total = pi.isRGB ? (pi.rgbFrames?.length || 0) : pi.frames.length;
119
+ if (total <= 1) {
120
+ // Single frame — just capture the current canvas
121
+ if (canvasRef.current) return [canvasRef.current.toDataURL('image/jpeg', 0.85)];
122
+ return [];
123
+ }
124
+
125
+ // Pick evenly-spaced frame indices
126
+ const count = Math.min(maxSlices, total);
127
+ const indices: number[] = [];
128
+ for (let i = 0; i < count; i++) {
129
+ indices.push(Math.round((i / (count - 1)) * (total - 1)));
130
+ }
131
+
132
+ const results: string[] = [];
133
+ const wc = windowCenterRef.current;
134
+ const ww = windowWidthRef.current;
135
+
136
+ for (const idx of indices) {
137
+ const jpeg = renderFrameToCanvas(pi, idx, wc, ww);
138
+ if (jpeg) results.push(jpeg);
139
+ }
140
+ return results;
141
+ },
142
+
143
+ getTotalFrames: () => {
144
+ const pi = pixelInfoRef.current;
145
+ if (!pi) return 1;
146
+ return pi.isRGB ? (pi.rgbFrames?.length || 1) : pi.frames.length;
147
+ }
148
+ }));
149
+
150
+ // Parse DICOM pixel data
151
+ useEffect(() => {
152
+ let cancelled = false;
153
+ setLoading(true);
154
+ setError(null);
155
+ setCurrentFrame(0);
156
+
157
+ (async () => {
158
+ try {
159
+ const arrayBuffer = await file.arrayBuffer();
160
+ const byteArray = new Uint8Array(arrayBuffer);
161
+ const dataSet = dicomParser.parseDicom(byteArray);
162
+
163
+ const rows = dataSet.uint16('x00280010');
164
+ const columns = dataSet.uint16('x00280011');
165
+ const bitsAllocated = dataSet.uint16('x00280100') || 16;
166
+ const bitsStored = dataSet.uint16('x00280101') || bitsAllocated;
167
+ const pixelRepresentation = dataSet.uint16('x00280103') || 0;
168
+ const samplesPerPixel = dataSet.uint16('x00280002') || 1;
169
+ const photometricInterpretation = dataSet.string('x00280004')?.trim() || '';
170
+
171
+ // Rescale Slope & Intercept (critical for CT Hounsfield units & MR display)
172
+ const rescaleSlope = dataSet.string('x00281053') ? parseFloat(dataSet.string('x00281053')!) : 1;
173
+ const rescaleIntercept = dataSet.string('x00281052') ? parseFloat(dataSet.string('x00281052')!) : 0;
174
+
175
+ // Window Center / Width (may have multiple values, take first)
176
+ let wcStr = dataSet.string('x00281050');
177
+ let wwStr = dataSet.string('x00281051');
178
+ let wc = wcStr ? parseFloat(wcStr.split('\\')[0]) : undefined;
179
+ let ww = wwStr ? parseFloat(wwStr.split('\\')[0]) : undefined;
180
+
181
+ // Number of frames
182
+ const numFramesStr = dataSet.string('x00280008');
183
+ const numFrames = numFramesStr ? parseInt(numFramesStr.trim(), 10) : 1;
184
+
185
+ const isMonochrome1 = photometricInterpretation === 'MONOCHROME1';
186
+
187
+ if (!rows || !columns) {
188
+ if (cancelled) return;
189
+ setError("Missing image dimensions in DICOM header.");
190
+ setLoading(false);
191
+ return;
192
+ }
193
+
194
+ const pixelDataElement = dataSet.elements.x7fe00010;
195
+ if (!pixelDataElement) {
196
+ if (cancelled) return;
197
+ setError("No pixel data found. This may be a compressed DICOM format not yet supported.");
198
+ setLoading(false);
199
+ return;
200
+ }
201
+
202
+ // Check for encapsulated (compressed) pixel data
203
+ if (pixelDataElement.encapsulatedPixelData) {
204
+ if (cancelled) return;
205
+ setError("Compressed DICOM transfer syntax (JPEG/JPEG2000) is not yet supported in this viewer. Use uncompressed DICOM files.");
206
+ setLoading(false);
207
+ return;
208
+ }
209
+
210
+ const pixelBytes = byteArray.slice(
211
+ pixelDataElement.dataOffset,
212
+ pixelDataElement.dataOffset + pixelDataElement.length
213
+ );
214
+
215
+ // Determine if RGB
216
+ const isRGB = samplesPerPixel === 3 ||
217
+ photometricInterpretation === 'RGB' ||
218
+ photometricInterpretation === 'YBR_FULL' ||
219
+ photometricInterpretation === 'YBR_FULL_422';
220
+
221
+ const framePixelCount = rows * columns;
222
+ const frameSizeBytes = isRGB
223
+ ? framePixelCount * 3 * (bitsAllocated / 8)
224
+ : framePixelCount * (bitsAllocated / 8);
225
+
226
+ // Determine actual frame count from data
227
+ const actualFrameCount = Math.max(1, Math.min(numFrames, Math.floor(pixelBytes.length / frameSizeBytes)));
228
+
229
+ if (isRGB && bitsAllocated === 8) {
230
+ // Handle RGB frames
231
+ const rgbFrames: Uint8Array[] = [];
232
+ for (let f = 0; f < actualFrameCount; f++) {
233
+ const offset = f * frameSizeBytes;
234
+ rgbFrames.push(pixelBytes.slice(offset, offset + frameSizeBytes));
235
+ }
236
+
237
+ if (cancelled) return;
238
+
239
+ pixelInfoRef.current = {
240
+ rows, columns,
241
+ minPixel: 0, maxPixel: 255,
242
+ defaultWC: 128, defaultWW: 256,
243
+ isMonochrome1: false,
244
+ frames: [],
245
+ isRGB: true,
246
+ rgbFrames
247
+ };
248
+ setTotalFrames(actualFrameCount);
249
+ setWindowCenter(128);
250
+ setWindowWidth(256);
251
+ setLoading(false);
252
+ if (onReady) onReady();
253
+ return;
254
+ }
255
+
256
+ // ── Grayscale processing ──
257
+ let globalMin = Infinity;
258
+ let globalMax = -Infinity;
259
+ const frames: FrameData[] = [];
260
+
261
+ for (let f = 0; f < actualFrameCount; f++) {
262
+ const pixelValues = new Float32Array(framePixelCount);
263
+ const frameByteOffset = f * frameSizeBytes;
264
+
265
+ if (bitsAllocated === 16) {
266
+ const dataView = new DataView(pixelBytes.buffer, pixelBytes.byteOffset + frameByteOffset, frameSizeBytes);
267
+ for (let i = 0; i < framePixelCount; i++) {
268
+ let raw: number;
269
+ if (pixelRepresentation === 1) {
270
+ raw = dataView.getInt16(i * 2, true);
271
+ } else {
272
+ raw = dataView.getUint16(i * 2, true);
273
+ }
274
+ // Apply rescale slope/intercept
275
+ const val = raw * rescaleSlope + rescaleIntercept;
276
+ pixelValues[i] = val;
277
+ if (val < globalMin) globalMin = val;
278
+ if (val > globalMax) globalMax = val;
279
+ }
280
+ } else if (bitsAllocated === 8) {
281
+ for (let i = 0; i < framePixelCount; i++) {
282
+ const raw = pixelBytes[frameByteOffset + i];
283
+ const val = raw * rescaleSlope + rescaleIntercept;
284
+ pixelValues[i] = val;
285
+ if (val < globalMin) globalMin = val;
286
+ if (val > globalMax) globalMax = val;
287
+ }
288
+ } else if (bitsAllocated === 32) {
289
+ const dataView = new DataView(pixelBytes.buffer, pixelBytes.byteOffset + frameByteOffset, frameSizeBytes);
290
+ for (let i = 0; i < framePixelCount; i++) {
291
+ let raw: number;
292
+ if (pixelRepresentation === 1) {
293
+ raw = dataView.getInt32(i * 4, true);
294
+ } else {
295
+ raw = dataView.getUint32(i * 4, true);
296
+ }
297
+ const val = raw * rescaleSlope + rescaleIntercept;
298
+ pixelValues[i] = val;
299
+ if (val < globalMin) globalMin = val;
300
+ if (val > globalMax) globalMax = val;
301
+ }
302
+ } else {
303
+ if (cancelled) return;
304
+ setError(`Unsupported bit depth: ${bitsAllocated}`);
305
+ setLoading(false);
306
+ return;
307
+ }
308
+
309
+ frames.push({ values: pixelValues });
310
+ }
311
+
312
+ // Compute default window if not provided
313
+ if (wc === undefined || ww === undefined || isNaN(wc) || isNaN(ww)) {
314
+ wc = (globalMax + globalMin) / 2;
315
+ ww = globalMax - globalMin;
316
+ if (ww === 0) ww = 1;
317
+ }
318
+
319
+ if (cancelled) return;
320
+
321
+ pixelInfoRef.current = {
322
+ rows, columns,
323
+ minPixel: globalMin, maxPixel: globalMax,
324
+ defaultWC: wc, defaultWW: ww,
325
+ isMonochrome1,
326
+ frames,
327
+ isRGB: false
328
+ };
329
+ setTotalFrames(actualFrameCount);
330
+ setWindowCenter(wc);
331
+ setWindowWidth(ww);
332
+ setLoading(false);
333
+ if (onReady) onReady();
334
+
335
+ } catch (e: any) {
336
+ if (cancelled) return;
337
+ console.error("DICOM parse error:", e);
338
+ setError(e?.message || "Failed to parse DICOM file.");
339
+ setLoading(false);
340
+ }
341
+ })();
342
+
343
+ return () => { cancelled = true; };
344
+ }, [file]);
345
+
346
+ // Render image to canvas
347
+ const renderImage = useCallback(() => {
348
+ const pi = pixelInfoRef.current;
349
+ const canvas = canvasRef.current;
350
+ if (!pi || !canvas) return;
351
+
352
+ canvas.width = pi.columns;
353
+ canvas.height = pi.rows;
354
+ const ctx = canvas.getContext('2d');
355
+ if (!ctx) return;
356
+
357
+ const imageData = ctx.createImageData(pi.columns, pi.rows);
358
+ const data = imageData.data;
359
+ const frameIdx = Math.min(currentFrame, pi.frames.length - 1, (pi.rgbFrames?.length ?? 1) - 1);
360
+
361
+ if (pi.isRGB && pi.rgbFrames) {
362
+ // RGB rendering
363
+ const rgb = pi.rgbFrames[Math.max(0, frameIdx)];
364
+ for (let i = 0; i < pi.rows * pi.columns; i++) {
365
+ data[i * 4] = rgb[i * 3];
366
+ data[i * 4 + 1] = rgb[i * 3 + 1];
367
+ data[i * 4 + 2] = rgb[i * 3 + 2];
368
+ data[i * 4 + 3] = 255;
369
+ }
370
+ } else {
371
+ // Grayscale rendering with W/L
372
+ const frame = pi.frames[Math.max(0, Math.min(frameIdx, pi.frames.length - 1))];
373
+ if (!frame) return;
374
+ const vals = frame.values;
375
+
376
+ const lower = windowCenter - 0.5 - (windowWidth - 1) / 2;
377
+ const upper = windowCenter - 0.5 + (windowWidth - 1) / 2;
378
+ const range = upper - lower;
379
+
380
+ for (let i = 0; i < vals.length; i++) {
381
+ const val = vals[i];
382
+ let lum: number;
383
+ if (val <= lower) lum = 0;
384
+ else if (val >= upper) lum = 255;
385
+ else lum = ((val - lower) / range) * 255;
386
+
387
+ // Invert for MONOCHROME1
388
+ if (pi.isMonochrome1) lum = 255 - lum;
389
+
390
+ const offset = i * 4;
391
+ data[offset] = lum;
392
+ data[offset + 1] = lum;
393
+ data[offset + 2] = lum;
394
+ data[offset + 3] = 255;
395
+ }
396
+ }
397
+
398
+ ctx.putImageData(imageData, 0, 0);
399
+ }, [windowCenter, windowWidth, currentFrame]);
400
+
401
+ useEffect(() => {
402
+ renderImage();
403
+ }, [renderImage]);
404
+
405
+ // Mouse interactions
406
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
407
+ e.preventDefault();
408
+ isDraggingRef.current = true;
409
+ dragButtonRef.current = e.button;
410
+ lastPosRef.current = { x: e.clientX, y: e.clientY };
411
+ }, []);
412
+
413
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
414
+ if (!isDraggingRef.current) return;
415
+ const dx = e.clientX - lastPosRef.current.x;
416
+ const dy = e.clientY - lastPosRef.current.y;
417
+ lastPosRef.current = { x: e.clientX, y: e.clientY };
418
+
419
+ if (dragButtonRef.current === 0) {
420
+ // Left drag: Window/Level
421
+ setWindowWidth(prev => Math.max(1, prev + dx * 2));
422
+ setWindowCenter(prev => prev + dy * 2);
423
+ } else if (dragButtonRef.current === 2) {
424
+ // Right drag: Pan
425
+ setPanX(prev => prev + dx);
426
+ setPanY(prev => prev + dy);
427
+ }
428
+ }, []);
429
+
430
+ const handleMouseUp = useCallback(() => {
431
+ isDraggingRef.current = false;
432
+ }, []);
433
+
434
+ const handleWheel = useCallback((e: React.WheelEvent) => {
435
+ e.preventDefault();
436
+ // If multi-frame, scroll through slices; otherwise zoom
437
+ if (totalFrames > 1) {
438
+ setCurrentFrame(prev => {
439
+ const next = e.deltaY > 0 ? prev + 1 : prev - 1;
440
+ return Math.max(0, Math.min(totalFrames - 1, next));
441
+ });
442
+ } else {
443
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
444
+ setZoom(prev => Math.max(0.1, Math.min(10, prev * delta)));
445
+ }
446
+ }, [totalFrames]);
447
+
448
+ const handleReset = useCallback(() => {
449
+ const pi = pixelInfoRef.current;
450
+ if (pi) {
451
+ setWindowCenter(pi.defaultWC);
452
+ setWindowWidth(pi.defaultWW);
453
+ }
454
+ setZoom(1);
455
+ setPanX(0);
456
+ setPanY(0);
457
+ }, []);
458
+
459
+ const goToFrame = useCallback((delta: number) => {
460
+ setCurrentFrame(prev => Math.max(0, Math.min(totalFrames - 1, prev + delta)));
461
+ }, [totalFrames]);
462
+
463
+ // ── Cine loop (play/pause) ──
464
+ const [isPlaying, setIsPlaying] = useState(false);
465
+ const [fps, setFps] = useState(8);
466
+ const playIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
467
+
468
+ useEffect(() => {
469
+ if (isPlaying && totalFrames > 1) {
470
+ playIntervalRef.current = setInterval(() => {
471
+ setCurrentFrame(prev => {
472
+ const next = prev + 1;
473
+ return next >= totalFrames ? 0 : next; // loop back to start
474
+ });
475
+ }, 1000 / fps);
476
+ } else {
477
+ if (playIntervalRef.current) {
478
+ clearInterval(playIntervalRef.current);
479
+ playIntervalRef.current = null;
480
+ }
481
+ }
482
+ return () => {
483
+ if (playIntervalRef.current) {
484
+ clearInterval(playIntervalRef.current);
485
+ playIntervalRef.current = null;
486
+ }
487
+ };
488
+ }, [isPlaying, fps, totalFrames]);
489
+
490
+ const togglePlay = useCallback(() => {
491
+ setIsPlaying(prev => !prev);
492
+ }, []);
493
+
494
+ if (error) {
495
+ return (
496
+ <div className={`flex items-center justify-center bg-black/90 rounded-lg p-6 ${className}`}>
497
+ <p className="text-red-400 text-sm text-center">{error}</p>
498
+ </div>
499
+ );
500
+ }
501
+
502
+ if (loading) {
503
+ return (
504
+ <div className={`flex flex-col items-center justify-center bg-[#0a0b0e] rounded-lg ${className}`}>
505
+ <div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-3" />
506
+ <p className="text-white/40 text-xs">Decoding pixel data...</p>
507
+ </div>
508
+ );
509
+ }
510
+
511
+ return (
512
+ <div className={`relative bg-black rounded-lg overflow-hidden select-none flex flex-col ${className}`}
513
+ onContextMenu={e => e.preventDefault()}
514
+ >
515
+ {/* Canvas viewport */}
516
+ <div className="flex-1 flex items-center justify-center overflow-hidden"
517
+ onMouseDown={handleMouseDown}
518
+ onMouseMove={handleMouseMove}
519
+ onMouseUp={handleMouseUp}
520
+ onMouseLeave={handleMouseUp}
521
+ onWheel={handleWheel}
522
+ >
523
+ <canvas
524
+ ref={canvasRef}
525
+ style={{
526
+ transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
527
+ imageRendering: 'pixelated',
528
+ maxWidth: '100%',
529
+ maxHeight: '100%',
530
+ objectFit: 'contain',
531
+ cursor: isDraggingRef.current ? 'grabbing' : 'crosshair',
532
+ }}
533
+ />
534
+ </div>
535
+
536
+ {/* ── Top-right controls ── */}
537
+ <div className="absolute top-2 right-2 flex items-center gap-1.5">
538
+ {/* Zoom controls (always visible for multi-frame since scroll = slice nav) */}
539
+ {totalFrames > 1 && (
540
+ <>
541
+ <button onClick={() => setZoom(prev => Math.min(10, prev * 1.2))}
542
+ className="bg-black/70 hover:bg-black/90 text-white/60 hover:text-white text-[11px] w-7 h-7 rounded border border-white/10 transition-colors flex items-center justify-center font-bold"
543
+ title="Zoom In">+</button>
544
+ <button onClick={() => setZoom(prev => Math.max(0.1, prev * 0.8))}
545
+ className="bg-black/70 hover:bg-black/90 text-white/60 hover:text-white text-[11px] w-7 h-7 rounded border border-white/10 transition-colors flex items-center justify-center font-bold"
546
+ title="Zoom Out">−</button>
547
+ </>
548
+ )}
549
+ <button
550
+ onClick={handleReset}
551
+ className="bg-black/70 hover:bg-black/90 text-white/60 hover:text-white text-[10px] px-2 py-1 rounded border border-white/10 transition-colors"
552
+ >
553
+ Reset
554
+ </button>
555
+ </div>
556
+
557
+ {/* ── Top-left: W/L + Zoom info ── */}
558
+ <div className="absolute top-2 left-2 pointer-events-none">
559
+ <div className="text-[10px] text-white/50 font-mono leading-relaxed bg-black/40 rounded px-1.5 py-0.5">
560
+ <div>W: {Math.round(windowWidth)} L: {Math.round(windowCenter)}</div>
561
+ <div>Zoom: {(zoom * 100).toFixed(0)}%</div>
562
+ </div>
563
+ </div>
564
+
565
+ {/* ── Bottom panel: Slice navigation (OHIF-style) ── */}
566
+ {totalFrames > 1 && (
567
+ <div className="relative bg-[#0d1117] border-t border-white/10 px-3 py-2 flex flex-col gap-1.5">
568
+ {/* Scrub bar */}
569
+ <div className="flex items-center gap-2">
570
+ {/* Play/Pause */}
571
+ <button
572
+ onClick={togglePlay}
573
+ className={`w-7 h-7 flex items-center justify-center rounded transition-all text-sm
574
+ ${isPlaying
575
+ ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
576
+ : 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'
577
+ }`}
578
+ title={isPlaying ? 'Pause' : 'Play cine loop'}
579
+ >
580
+ {isPlaying ? '⏸' : '▶'}
581
+ </button>
582
+
583
+ {/* Slice slider */}
584
+ <input
585
+ type="range"
586
+ min={0}
587
+ max={totalFrames - 1}
588
+ value={currentFrame}
589
+ onChange={e => setCurrentFrame(parseInt(e.target.value, 10))}
590
+ className="flex-1 h-1.5 appearance-none bg-white/10 rounded-full cursor-pointer
591
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5
592
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:shadow-lg
593
+ [&::-webkit-slider-thumb]:shadow-blue-500/40 [&::-webkit-slider-thumb]:cursor-grab
594
+ [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5
595
+ [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:border-0
596
+ [&::-moz-range-thumb]:cursor-grab"
597
+ style={{
598
+ background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(currentFrame / (totalFrames - 1)) * 100}%, rgba(255,255,255,0.1) ${(currentFrame / (totalFrames - 1)) * 100}%, rgba(255,255,255,0.1) 100%)`
599
+ }}
600
+ />
601
+
602
+ {/* Slice counter */}
603
+ <span className="text-[11px] text-white/70 font-mono tabular-nums min-w-[5rem] text-right select-none">
604
+ {currentFrame + 1} / {totalFrames}
605
+ </span>
606
+ </div>
607
+
608
+ {/* FPS control (only visible when playing) */}
609
+ {isPlaying && (
610
+ <div className="flex items-center gap-2 pl-9">
611
+ <span className="text-[9px] text-white/40 uppercase tracking-wider">Speed</span>
612
+ <input
613
+ type="range"
614
+ min={1}
615
+ max={30}
616
+ value={fps}
617
+ onChange={e => setFps(parseInt(e.target.value, 10))}
618
+ className="w-20 h-1 appearance-none bg-white/10 rounded-full cursor-pointer
619
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5
620
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white/50
621
+ [&::-moz-range-thumb]:w-2.5 [&::-moz-range-thumb]:h-2.5
622
+ [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white/50 [&::-moz-range-thumb]:border-0"
623
+ />
624
+ <span className="text-[9px] text-white/40 font-mono">{fps} fps</span>
625
+ </div>
626
+ )}
627
+ </div>
628
+ )}
629
+
630
+ {/* ── Single-frame bottom info bar ── */}
631
+ {totalFrames <= 1 && (
632
+ <div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-3 py-1.5 bg-gradient-to-t from-black/80 to-transparent pointer-events-none">
633
+ <span className="text-[10px] text-white/50 font-mono">
634
+ W: {Math.round(windowWidth)} L: {Math.round(windowCenter)} | Zoom: {(zoom * 100).toFixed(0)}%
635
+ </span>
636
+ </div>
637
+ )}
638
+ </div>
639
+ );
640
+ });
641
+
642
+ DicomViewer.displayName = 'DicomViewer';
643
+
644
+ export default DicomViewer;
645
+