@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,562 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ useImperativeHandle,
8
+ forwardRef,
9
+ useCallback,
10
+ } from "react";
11
+ import type { CopilotViewerRef, AnnotationAction, SegmentationAction } from "@/types/copilot-viewer";
12
+ import { formatAILabel, AI_ANNOTATION_DEFAULTS } from "@/lib/copilot/action-types";
13
+ import { MonitorDot } from "lucide-react";
14
+
15
+ interface CopilotCornerstoneViewerProps {
16
+ images: string[]; // base64 image data URLs
17
+ currentSlice?: number;
18
+ onSliceChange?: (slice: number) => void;
19
+ }
20
+
21
+ // ── Internal annotation storage (canvas-rendered AI overlays) ────────────────
22
+ interface StoredAnnotation {
23
+ id: string;
24
+ type: AnnotationAction["action"];
25
+ slice: number;
26
+ label: string;
27
+ color: string;
28
+ confidence?: number;
29
+ // geometry
30
+ x?: number;
31
+ y?: number;
32
+ width?: number;
33
+ height?: number;
34
+ center_x?: number;
35
+ center_y?: number;
36
+ radius?: number;
37
+ start_x?: number;
38
+ start_y?: number;
39
+ end_x?: number;
40
+ end_y?: number;
41
+ }
42
+
43
+ interface StoredSegmentation {
44
+ id: string;
45
+ slice: number;
46
+ label: string;
47
+ color: string;
48
+ opacity: number;
49
+ bbox?: [number, number, number, number];
50
+ contour_points?: [number, number][];
51
+ }
52
+
53
+ const CopilotCornerstoneViewer = forwardRef<CopilotViewerRef, CopilotCornerstoneViewerProps>(
54
+ function CopilotCornerstoneViewer({ images, currentSlice = 0, onSliceChange }, ref) {
55
+ const canvasRef = useRef<HTMLCanvasElement>(null);
56
+ const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
57
+ const containerRef = useRef<HTMLDivElement>(null);
58
+
59
+ const [sliceIndex, setSliceIndex] = useState(currentSlice);
60
+ const [annotations, setAnnotations] = useState<StoredAnnotation[]>([]);
61
+ const [segmentations, setSegmentations] = useState<StoredSegmentation[]>([]);
62
+ const [loadedImage, setLoadedImage] = useState<HTMLImageElement | null>(null);
63
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
64
+
65
+ // ── Viewport zoom/pan state ──────────────────────────────────────────
66
+ const [viewportZoom, setViewportZoom] = useState(1);
67
+ const [viewportPanX, setViewportPanX] = useState(0);
68
+ const [viewportPanY, setViewportPanY] = useState(0);
69
+ const isZoomed = viewportZoom > 1.05;
70
+
71
+ // Sync external slice changes
72
+ useEffect(() => {
73
+ if (currentSlice >= 0 && currentSlice < images.length) {
74
+ setSliceIndex(currentSlice);
75
+ }
76
+ }, [currentSlice, images.length]);
77
+
78
+ // Observe container size
79
+ useEffect(() => {
80
+ const container = containerRef.current;
81
+ if (!container) return;
82
+
83
+ const observer = new ResizeObserver((entries) => {
84
+ for (const entry of entries) {
85
+ setContainerSize({
86
+ width: entry.contentRect.width,
87
+ height: entry.contentRect.height,
88
+ });
89
+ }
90
+ });
91
+ observer.observe(container);
92
+ return () => observer.disconnect();
93
+ }, []);
94
+
95
+ // Load the current image
96
+ useEffect(() => {
97
+ if (!images[sliceIndex]) {
98
+ setLoadedImage(null);
99
+ return;
100
+ }
101
+
102
+ const img = new Image();
103
+ img.onload = () => setLoadedImage(img);
104
+ img.onerror = () => setLoadedImage(null);
105
+ img.src = images[sliceIndex];
106
+ }, [images, sliceIndex]);
107
+
108
+ // Render the medical image on the main canvas
109
+ useEffect(() => {
110
+ const canvas = canvasRef.current;
111
+ if (!canvas || !loadedImage || containerSize.width === 0) return;
112
+
113
+ canvas.width = containerSize.width;
114
+ canvas.height = containerSize.height;
115
+
116
+ const ctx = canvas.getContext("2d");
117
+ if (!ctx) return;
118
+
119
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
120
+ ctx.fillStyle = "#000";
121
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
122
+
123
+ // Fit image to canvas maintaining aspect ratio
124
+ const imgW = loadedImage.naturalWidth;
125
+ const imgH = loadedImage.naturalHeight;
126
+ const scale = Math.min(canvas.width / imgW, canvas.height / imgH);
127
+ const drawW = imgW * scale;
128
+ const drawH = imgH * scale;
129
+ const offsetX = (canvas.width - drawW) / 2;
130
+ const offsetY = (canvas.height - drawH) / 2;
131
+
132
+ // Apply slight brightness/contrast for medical imaging feel
133
+ ctx.filter = "brightness(1.1) contrast(1.05)";
134
+ ctx.drawImage(loadedImage, offsetX, offsetY, drawW, drawH);
135
+ ctx.filter = "none";
136
+ }, [loadedImage, containerSize]);
137
+
138
+ // ── Coordinate mapping helper ────────────────────────────────────────
139
+ const mapCoords = useCallback(
140
+ (x: number, y: number): { x: number; y: number } | null => {
141
+ if (!loadedImage || containerSize.width === 0) return null;
142
+ const imgW = loadedImage.naturalWidth;
143
+ const imgH = loadedImage.naturalHeight;
144
+ const scale = Math.min(containerSize.width / imgW, containerSize.height / imgH);
145
+ const offsetX = (containerSize.width - imgW * scale) / 2;
146
+ const offsetY = (containerSize.height - imgH * scale) / 2;
147
+ return { x: x * scale + offsetX, y: y * scale + offsetY };
148
+ },
149
+ [loadedImage, containerSize]
150
+ );
151
+
152
+ const mapSize = useCallback(
153
+ (size: number): number => {
154
+ if (!loadedImage || containerSize.width === 0) return size;
155
+ const imgW = loadedImage.naturalWidth;
156
+ const imgH = loadedImage.naturalHeight;
157
+ const scale = Math.min(containerSize.width / imgW, containerSize.height / imgH);
158
+ return size * scale;
159
+ },
160
+ [loadedImage, containerSize]
161
+ );
162
+
163
+ // Render AI overlay annotations and segmentations
164
+ useEffect(() => {
165
+ const overlay = overlayCanvasRef.current;
166
+ if (!overlay || containerSize.width === 0) return;
167
+
168
+ overlay.width = containerSize.width;
169
+ overlay.height = containerSize.height;
170
+
171
+ const ctx = overlay.getContext("2d");
172
+ if (!ctx) return;
173
+
174
+ ctx.clearRect(0, 0, overlay.width, overlay.height);
175
+
176
+ // Draw segmentations for current slice
177
+ const sliceSegs = segmentations.filter((s) => s.slice === sliceIndex);
178
+ for (const seg of sliceSegs) {
179
+ const hasContours = seg.contour_points && seg.contour_points.length > 1;
180
+
181
+ if (seg.bbox && !hasContours) {
182
+ const tl = mapCoords(seg.bbox[0], seg.bbox[1]);
183
+ const br = mapCoords(seg.bbox[2], seg.bbox[3]);
184
+ if (tl && br) {
185
+ // Semi-transparent fill
186
+ ctx.fillStyle = hexToRgba(seg.color || AI_ANNOTATION_DEFAULTS.segmentationColor, seg.opacity || AI_ANNOTATION_DEFAULTS.segmentationOpacity);
187
+ ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
188
+ // Contour border
189
+ ctx.strokeStyle = seg.color || AI_ANNOTATION_DEFAULTS.contourColor;
190
+ ctx.lineWidth = AI_ANNOTATION_DEFAULTS.contourWidth;
191
+ ctx.setLineDash([6, 3]);
192
+ ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
193
+ ctx.setLineDash([]);
194
+ }
195
+ }
196
+
197
+ if (seg.contour_points && seg.contour_points.length > 1) {
198
+ const mapped = seg.contour_points.map(([px, py]) => mapCoords(px, py)).filter(Boolean) as { x: number; y: number }[];
199
+ if (mapped.length > 1) {
200
+ ctx.beginPath();
201
+ ctx.moveTo(mapped[0].x, mapped[0].y);
202
+ for (let i = 1; i < mapped.length; i++) {
203
+ ctx.lineTo(mapped[i].x, mapped[i].y);
204
+ }
205
+ ctx.closePath();
206
+ ctx.fillStyle = hexToRgba(seg.color || AI_ANNOTATION_DEFAULTS.segmentationColor, seg.opacity || AI_ANNOTATION_DEFAULTS.segmentationOpacity);
207
+ ctx.fill();
208
+ ctx.strokeStyle = seg.color || AI_ANNOTATION_DEFAULTS.contourColor;
209
+ ctx.lineWidth = AI_ANNOTATION_DEFAULTS.contourWidth;
210
+ ctx.stroke();
211
+ }
212
+ }
213
+
214
+ // Segmentation label
215
+ if (seg.label && seg.bbox) {
216
+ const labelPos = mapCoords(seg.bbox[0], seg.bbox[1]);
217
+ if (labelPos) {
218
+ drawLabel(ctx, seg.label, labelPos.x, labelPos.y - 8);
219
+ }
220
+ }
221
+ }
222
+
223
+ // Draw annotations for current slice
224
+ const sliceAnns = annotations.filter((a) => a.slice === sliceIndex);
225
+ for (const ann of sliceAnns) {
226
+ const color = ann.color || AI_ANNOTATION_DEFAULTS.color;
227
+ ctx.strokeStyle = color;
228
+ ctx.lineWidth = 2;
229
+
230
+ switch (ann.type) {
231
+ case "create_bounding_box_annotation":
232
+ case "create_rectangle_annotation": {
233
+ if (ann.x !== undefined && ann.y !== undefined && ann.width && ann.height) {
234
+ const tl = mapCoords(ann.x, ann.y);
235
+ const w = mapSize(ann.width);
236
+ const h = mapSize(ann.height);
237
+ if (tl) {
238
+ ctx.strokeRect(tl.x, tl.y, w, h);
239
+ drawLabel(ctx, formatAILabel(ann.label, ann.confidence), tl.x, tl.y - 8);
240
+ }
241
+ }
242
+ break;
243
+ }
244
+ case "create_circle_annotation": {
245
+ if (ann.center_x !== undefined && ann.center_y !== undefined && ann.radius) {
246
+ const center = mapCoords(ann.center_x, ann.center_y);
247
+ const r = mapSize(ann.radius);
248
+ if (center) {
249
+ ctx.beginPath();
250
+ ctx.arc(center.x, center.y, r, 0, Math.PI * 2);
251
+ ctx.stroke();
252
+ drawLabel(ctx, formatAILabel(ann.label, ann.confidence), center.x - r, center.y - r - 8);
253
+ }
254
+ }
255
+ break;
256
+ }
257
+ case "create_arrow_annotation": {
258
+ if (ann.start_x !== undefined && ann.start_y !== undefined && ann.end_x !== undefined && ann.end_y !== undefined) {
259
+ const start = mapCoords(ann.start_x, ann.start_y);
260
+ const end = mapCoords(ann.end_x, ann.end_y);
261
+ if (start && end) {
262
+ drawArrow(ctx, start.x, start.y, end.x, end.y, color);
263
+ drawLabel(ctx, formatAILabel(ann.label, ann.confidence), end.x + 8, end.y - 8);
264
+ }
265
+ }
266
+ break;
267
+ }
268
+ case "create_probe_annotation": {
269
+ if (ann.x !== undefined && ann.y !== undefined) {
270
+ const pos = mapCoords(ann.x, ann.y);
271
+ if (pos) {
272
+ ctx.beginPath();
273
+ ctx.arc(pos.x, pos.y, 4, 0, Math.PI * 2);
274
+ ctx.fillStyle = color;
275
+ ctx.fill();
276
+ drawLabel(ctx, formatAILabel(ann.label, ann.confidence), pos.x + 8, pos.y - 4);
277
+ }
278
+ }
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ }, [annotations, segmentations, sliceIndex, containerSize, mapCoords, mapSize, loadedImage]);
284
+
285
+ // ── Imperative API exposed via ref ───────────────────────────────────
286
+ useImperativeHandle(ref, () => ({
287
+ jumpToSlice(slice: number) {
288
+ const idx = Math.max(0, Math.min(slice, images.length - 1));
289
+ setSliceIndex(idx);
290
+ onSliceChange?.(idx);
291
+ },
292
+
293
+ addAnnotation(action: AnnotationAction) {
294
+ const ann: StoredAnnotation = {
295
+ id: action.annotation_id || `ai_ann_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
296
+ type: action.action,
297
+ slice: action.slice ?? sliceIndex,
298
+ label: action.label || "Finding",
299
+ color: action.color || AI_ANNOTATION_DEFAULTS.color,
300
+ confidence: action.confidence,
301
+ x: action.x,
302
+ y: action.y,
303
+ width: action.width,
304
+ height: action.height,
305
+ center_x: action.center_x,
306
+ center_y: action.center_y,
307
+ radius: action.radius,
308
+ start_x: action.start_x,
309
+ start_y: action.start_y,
310
+ end_x: action.end_x,
311
+ end_y: action.end_y,
312
+ };
313
+ setAnnotations((prev) => {
314
+ // Prevent duplicates when replaying actions
315
+ const existingIdx = prev.findIndex(a => a.id === ann.id);
316
+ if (existingIdx >= 0) {
317
+ const copy = [...prev];
318
+ copy[existingIdx] = ann;
319
+ return copy;
320
+ }
321
+ return [...prev, ann];
322
+ });
323
+ },
324
+
325
+ addSegmentation(action: SegmentationAction) {
326
+ if (action.action === "clear_ai_segmentations") {
327
+ setSegmentations([]);
328
+ return;
329
+ }
330
+ const seg: StoredSegmentation = {
331
+ id: action.segmentation_id || `ai_seg_${Date.now()}`,
332
+ slice: action.slice ?? sliceIndex,
333
+ label: action.label || "Segmentation",
334
+ color: action.color || AI_ANNOTATION_DEFAULTS.segmentationColor,
335
+ opacity: action.opacity ?? AI_ANNOTATION_DEFAULTS.segmentationOpacity,
336
+ bbox: action.bbox,
337
+ contour_points: action.contour_points,
338
+ };
339
+ setSegmentations((prev) => {
340
+ // Prevent duplicates when replaying actions
341
+ const existingIdx = prev.findIndex(s => s.id === seg.id);
342
+ if (existingIdx >= 0) {
343
+ const copy = [...prev];
344
+ copy[existingIdx] = seg;
345
+ return copy;
346
+ }
347
+ return [...prev, seg];
348
+ });
349
+ },
350
+
351
+ clearAIFindings() {
352
+ setAnnotations([]);
353
+ setSegmentations([]);
354
+ },
355
+
356
+ zoomToRegion(x: number, y: number, w: number, h: number) {
357
+ if (!loadedImage || containerSize.width === 0) return;
358
+
359
+ const imgW = loadedImage.naturalWidth;
360
+ const imgH = loadedImage.naturalHeight;
361
+ const baseScale = Math.min(containerSize.width / imgW, containerSize.height / imgH);
362
+ const offsetX = (containerSize.width - imgW * baseScale) / 2;
363
+ const offsetY = (containerSize.height - imgH * baseScale) / 2;
364
+
365
+ // Center of the region in image pixel coords
366
+ const regionCenterX = x + w / 2;
367
+ const regionCenterY = y + h / 2;
368
+
369
+ // Map to canvas coords
370
+ const canvasCX = regionCenterX * baseScale + offsetX;
371
+ const canvasCY = regionCenterY * baseScale + offsetY;
372
+
373
+ // Calculate zoom to fit the region with padding
374
+ const regionScreenW = w * baseScale;
375
+ const regionScreenH = h * baseScale;
376
+ const zoomX = containerSize.width / (regionScreenW * 1.6);
377
+ const zoomY = containerSize.height / (regionScreenH * 1.6);
378
+ const zoom = Math.min(Math.max(Math.min(zoomX, zoomY), 1.5), 5);
379
+
380
+ // Pan so the region center maps to container center
381
+ const panX = containerSize.width / 2 - canvasCX * zoom;
382
+ const panY = containerSize.height / 2 - canvasCY * zoom;
383
+
384
+ setViewportZoom(zoom);
385
+ setViewportPanX(panX);
386
+ setViewportPanY(panY);
387
+ },
388
+
389
+ resetViewport() {
390
+ setViewportZoom(1);
391
+ setViewportPanX(0);
392
+ setViewportPanY(0);
393
+ },
394
+
395
+ getCurrentSlice() {
396
+ return sliceIndex;
397
+ },
398
+
399
+ getTotalSlices() {
400
+ return images.length;
401
+ },
402
+
403
+ getCurrentImageBase64() {
404
+ return images[sliceIndex] || null;
405
+ },
406
+ }), [sliceIndex, images, images.length, onSliceChange, loadedImage, containerSize]);
407
+
408
+ // ── Empty State ──────────────────────────────────────────────────────
409
+ if (images.length === 0) {
410
+ return (
411
+ <div className="flex flex-col items-center justify-center h-full text-text-muted gap-4 p-8">
412
+ <MonitorDot size={48} className="opacity-30" />
413
+ <div className="text-center">
414
+ <p className="font-medium text-text-secondary">No DICOM Images</p>
415
+ <p className="text-sm mt-1">Ask the copilot to show a scan or select a report with images.</p>
416
+ </div>
417
+ </div>
418
+ );
419
+ }
420
+
421
+ const findingsCount = annotations.filter((a) => a.slice === sliceIndex).length +
422
+ segmentations.filter((s) => s.slice === sliceIndex).length;
423
+
424
+ return (
425
+ <div className="flex flex-col h-full bg-black/90">
426
+ {/* Image Canvas Area */}
427
+ <div ref={containerRef} className="flex-1 relative overflow-hidden">
428
+ <canvas
429
+ ref={canvasRef}
430
+ className="absolute inset-0 w-full h-full"
431
+ style={{
432
+ transform: `translate(${viewportPanX}px, ${viewportPanY}px) scale(${viewportZoom})`,
433
+ transformOrigin: '0 0',
434
+ transition: 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
435
+ }}
436
+ />
437
+ <canvas
438
+ ref={overlayCanvasRef}
439
+ className="absolute inset-0 w-full h-full pointer-events-none"
440
+ style={{
441
+ transform: `translate(${viewportPanX}px, ${viewportPanY}px) scale(${viewportZoom})`,
442
+ transformOrigin: '0 0',
443
+ transition: 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
444
+ }}
445
+ />
446
+
447
+ {/* Reset Zoom button */}
448
+ {isZoomed && (
449
+ <button
450
+ onClick={() => { setViewportZoom(1); setViewportPanX(0); setViewportPanY(0); }}
451
+ className="absolute top-4 left-4 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-black/70 text-white hover:bg-black/90 backdrop-blur-sm border border-white/10 transition-all cursor-pointer"
452
+ title="Reset zoom to fit"
453
+ >
454
+ 🔍 Reset Zoom
455
+ </button>
456
+ )}
457
+
458
+ {/* Slice indicator */}
459
+ {images.length > 1 && (
460
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-1.5 rounded-full backdrop-blur-sm">
461
+ Slice {sliceIndex + 1} / {images.length}
462
+ </div>
463
+ )}
464
+
465
+ {/* AI Findings badge */}
466
+ {findingsCount > 0 && (
467
+ <div className="absolute top-4 right-4 flex items-center gap-2 bg-red-500/90 text-white text-xs px-3 py-1.5 rounded-full backdrop-blur-sm font-semibold shadow-lg">
468
+ <span className="w-2 h-2 rounded-full bg-white animate-pulse" />
469
+ {findingsCount} AI Finding{findingsCount > 1 ? "s" : ""}
470
+ </div>
471
+ )}
472
+ </div>
473
+
474
+ {/* Slice Navigation */}
475
+ {images.length > 1 && (
476
+ <div className="px-6 py-3 bg-bg-surface border-t border-border-primary flex items-center gap-4">
477
+ <span className="text-xs text-text-muted font-medium">SLICE</span>
478
+ <input
479
+ type="range"
480
+ min={0}
481
+ max={images.length - 1}
482
+ value={sliceIndex}
483
+ onChange={(e) => {
484
+ const idx = parseInt(e.target.value);
485
+ setSliceIndex(idx);
486
+ onSliceChange?.(idx);
487
+ }}
488
+ className="flex-1 h-1.5 bg-border-primary rounded-full appearance-none cursor-pointer accent-primary"
489
+ />
490
+ <span className="text-xs text-text-secondary font-mono w-12 text-right">
491
+ {sliceIndex + 1}/{images.length}
492
+ </span>
493
+ </div>
494
+ )}
495
+ </div>
496
+ );
497
+ }
498
+ );
499
+
500
+ // ── Canvas Drawing Helpers ───────────────────────────────────────────────────
501
+
502
+ function drawLabel(ctx: CanvasRenderingContext2D, text: string, x: number, y: number) {
503
+ ctx.font = "bold 11px Inter, system-ui, sans-serif";
504
+ const metrics = ctx.measureText(text);
505
+ const padding = 4;
506
+ const bgW = metrics.width + padding * 2;
507
+ const bgH = 16;
508
+
509
+ // Dark translucent background
510
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
511
+ const radius = 4;
512
+ ctx.beginPath();
513
+ ctx.roundRect(x, y - bgH, bgW, bgH, radius);
514
+ ctx.fill();
515
+
516
+ // White text
517
+ ctx.fillStyle = "#ffffff";
518
+ ctx.textBaseline = "bottom";
519
+ ctx.fillText(text, x + padding, y - 2);
520
+ }
521
+
522
+ function drawArrow(
523
+ ctx: CanvasRenderingContext2D,
524
+ fromX: number,
525
+ fromY: number,
526
+ toX: number,
527
+ toY: number,
528
+ color: string
529
+ ) {
530
+ const headLen = 10;
531
+ const angle = Math.atan2(toY - fromY, toX - fromX);
532
+
533
+ ctx.strokeStyle = color;
534
+ ctx.lineWidth = 2;
535
+ ctx.beginPath();
536
+ ctx.moveTo(fromX, fromY);
537
+ ctx.lineTo(toX, toY);
538
+ ctx.stroke();
539
+
540
+ ctx.fillStyle = color;
541
+ ctx.beginPath();
542
+ ctx.moveTo(toX, toY);
543
+ ctx.lineTo(
544
+ toX - headLen * Math.cos(angle - Math.PI / 6),
545
+ toY - headLen * Math.sin(angle - Math.PI / 6)
546
+ );
547
+ ctx.lineTo(
548
+ toX - headLen * Math.cos(angle + Math.PI / 6),
549
+ toY - headLen * Math.sin(angle + Math.PI / 6)
550
+ );
551
+ ctx.closePath();
552
+ ctx.fill();
553
+ }
554
+
555
+ function hexToRgba(hex: string, alpha: number): string {
556
+ const r = parseInt(hex.slice(1, 3), 16);
557
+ const g = parseInt(hex.slice(3, 5), 16);
558
+ const b = parseInt(hex.slice(5, 7), 16);
559
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
560
+ }
561
+
562
+ export default CopilotCornerstoneViewer;