@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.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- 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
|
+
|