update the codebase poc ver1
@@ -0,0 +1,83 @@
|
||||
# Lumina MSK — Front-end Mockup
|
||||
|
||||
Vietnamese-language MSK clinical workspace prototype (Sprint 1–2, FR-25).
|
||||
|
||||
## Screens
|
||||
|
||||
| Route | Screen | Use cases |
|
||||
|-------|--------|-----------|
|
||||
| `/` | Danh sách công việc (Patient Worklist) | UC-48376 |
|
||||
| `/workspace/:patientId` | Không gian chẩn đoán (Clinical Workspace) | UC-47988, UC-25776, FR-02 partial (session replay) |
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd CODEBASE/frontend/implementation
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173
|
||||
|
||||
Backend proxy (ML analyze): FastAPI on `:8001` — see repo root / backend docs.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Session recording (PoC)
|
||||
|
||||
Implemented in the **Xem lại phiên chẩn đoán** sidebar layer:
|
||||
|
||||
| Feature | Modes |
|
||||
|---------|--------|
|
||||
| **Một khung** / **Nhiều khung** | `RecordingModeSelector.tsx` |
|
||||
| Multi-frame UIX vector log + WAL | `uixSessionRecorder.ts`, `workers/uixSessionRecorder.worker.ts` |
|
||||
| Chronological replay (frame switch + overlay draw) | `uixSessionReplayEngine.ts`, `useDiagnosticSessionReplay.ts` |
|
||||
| **Hủy ghi** / **Phiên mới** / **Lưu phiên** | `SessionLifecycleToolbar.tsx` |
|
||||
|
||||
Full spec: [`../spec/session_recording_frame_switch_spec.md`](../spec/session_recording_frame_switch_spec.md)
|
||||
Coverage map: [`../spec/uix_prototype_coverage.md`](../spec/uix_prototype_coverage.md)
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
implementation/
|
||||
├── public/assets/ # SVG ultrasound overlays + Stitch reference thumbnails
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── layout/ # TopBar, BottomBar, AppShell
|
||||
│ │ ├── shells/ # PatientListShell, WorkspaceShell (65/35)
|
||||
│ │ ├── organisms/ # DiagnosticCanvas, ReviewDiagnosticSessionPanel, ReplaySandboxOverlay
|
||||
│ │ └── molecules/ # SessionLifecycleToolbar, RecordingModeSelector, PatientCard, …
|
||||
│ ├── hooks/ # useDiagnosticSessionReplay, useImageCanvasTools, …
|
||||
│ ├── lib/ # sessionLifecycle, uixSession*, segmentationApi, …
|
||||
│ ├── workers/ # uixSessionRecorder.worker.ts (IndexedDB WAL)
|
||||
│ ├── data/ # mockData, scanFrames, diagnosticSessionReplay
|
||||
│ ├── pages/ # Worklist + Clinical Workspace routes
|
||||
│ └── styles/global.css # Glassmorphism tokens from spec/DESIGN.md
|
||||
├── package.json
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## Design tokens
|
||||
|
||||
Colors, typography, and glassmorphism (`blur(12px)`, `#056B58` mint, teal secondary) follow `../spec/DESIGN.md`.
|
||||
|
||||
## Demo flows
|
||||
|
||||
### Happy path (grading)
|
||||
|
||||
1. Open worklist → **Mở phiên chẩn đoán**.
|
||||
2. Wait for segmentation / grade in Zone B.
|
||||
3. Toggle mask, pan/zoom, draw on canvas.
|
||||
4. **Ký & Hoàn tất** (local draft only).
|
||||
|
||||
### Multi-frame session replay
|
||||
|
||||
1. Sidebar → **Xem lại phiên chẩn đoán** → **Nhiều khung** → **Ghi**.
|
||||
2. Draw on frame 1 → switch frame → draw on frame 2 → **Kết thúc ghi** → **Lưu phiên**.
|
||||
3. Load saved session → **Phát liên tục** → scrub across frame switch and mid-stroke overlay.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lumina MSK — Không gian Lâm sàng</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@600;700&family=Work+Sans:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<radialGradient id="heat" cx="55%" cy="42%" r="35%">
|
||||
<stop offset="0%" stop-color="#ff4444" stop-opacity="0.8"/>
|
||||
<stop offset="50%" stop-color="#ffaa00" stop-opacity="0.4"/>
|
||||
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="heat2" cx="38%" cy="55%" r="25%">
|
||||
<stop offset="0%" stop-color="#ff6600" stop-opacity="0.5"/>
|
||||
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse cx="350" cy="210" rx="120" ry="80" fill="url(#heat)"/>
|
||||
<ellipse cx="250" cy="270" rx="70" ry="50" fill="url(#heat2)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,14 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Figma MSK_Workspace_UIX-Design node 244:2415 -->
|
||||
<path
|
||||
d="M5.25 12.75C3.75 11.25 3.25 9.25 3.75 7.25C4.5 5 6.75 3.5 9.5 3.75C12.25 4 14.25 5.75 14.75 8.25C15.25 10.75 14.25 13 12.5 14.25M11.75 13.5C12.15 13 12.4 12.4 12.5 11.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="6.75" cy="7.75" r="1.05" fill="currentColor" />
|
||||
<circle cx="9.25" cy="6.5" r="1.05" fill="currentColor" />
|
||||
<circle cx="11.75" cy="6.5" r="1.05" fill="currentColor" />
|
||||
<circle cx="14.25" cy="7.75" r="1.05" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 702 B |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||
<path
|
||||
d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z"
|
||||
fill="none"
|
||||
stroke="#056b58"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="8 4"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<ellipse
|
||||
cx="340"
|
||||
cy="230"
|
||||
rx="55"
|
||||
ry="35"
|
||||
fill="#76c8b1"
|
||||
opacity="0.35"
|
||||
stroke="#056b58"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<text x="310" y="235" fill="#ffffff" font-family="sans-serif" font-size="11" font-weight="600">M<EFBFBD>ng H</text>
|
||||
</svg>
|
||||
@@ -0,0 +1,35 @@
|
||||
# Stitch Project Assets
|
||||
|
||||
**Project:** MSK Collaborative Workspace
|
||||
**Project ID:** `17628337625105266704`
|
||||
|
||||
| Screen | Screen ID | Route mapping |
|
||||
|--------|-----------|---------------|
|
||||
| Danh sách công việc - Lumina MSK | `9d638112797c4b8d8a100d526bab43f2` | `/` (Patient Worklist) |
|
||||
| Chế độ Kiểm tra Lâm sàng - Lumina MSK (VN) | `fa207d6a4f684f32a3b99706275daad9` | `/workspace/:id` (Clinical Review) |
|
||||
| Không gian chẩn đoán - AI Review (VN) | `03c138643dd9427aaa59a6e16d089793` | Safety overlay state |
|
||||
| Chế độ siêu âm gốc - Metadata Tinh gọn (Refined Raw View) | `85ece6c4ed6f4c179042b59813237d44` | Segmentation legend bubble on `DiagnosticCanvas` |
|
||||
| Annotation Workspace with AI Hub | `3bdfcf6ba964447d88b98c8b9b0df9f1` | Floating `AnnotationRibbon` on `DiagnosticCanvas` |
|
||||
|
||||
## Fetching assets
|
||||
|
||||
Stitch API requires OAuth2 (`STITCH_ACCESS_TOKEN` + `GOOGLE_CLOUD_PROJECT`), not a generic API key.
|
||||
|
||||
```bash
|
||||
# Using @google/stitch-sdk (after OAuth setup)
|
||||
npm install @google/stitch-sdk
|
||||
STITCH_ACCESS_TOKEN=... GOOGLE_CLOUD_PROJECT=... node scripts/fetch-stitch.mjs
|
||||
```
|
||||
|
||||
Or use Stitch MCP `get_screen` with `projectId` and `screenId`, then download:
|
||||
- `htmlCode.downloadUrl` → save to `public/assets/stitch/<screen>/index.html`
|
||||
- `screenshot.downloadUrl` → save to `public/assets/stitch/<screen>/screenshot.png`
|
||||
|
||||
## Local placeholders
|
||||
|
||||
This mockup uses handcrafted SVG assets in `public/assets/` aligned with `spec/DESIGN.md` tokens, since Stitch OAuth was unavailable during build.
|
||||
|
||||
Reference thumbnails (design intent):
|
||||
- `worklist-reference.svg`
|
||||
- `clinical-review-reference.svg`
|
||||
- `ai-review-reference.svg`
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
|
||||
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)"/>
|
||||
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" opacity="0.45"/>
|
||||
<rect x="60" y="80" width="280" height="180" fill="rgba(255,255,255,0.85)" stroke="#ba1a1a" stroke-width="2" rx="12"/>
|
||||
<text x="80" y="110" fill="#ba1a1a" font-family="sans-serif" font-size="12" font-weight="700">Ch<EFBFBD> <11> Leo thang An to<74>n</text>
|
||||
<text x="80" y="140" fill="#3e4945" font-family="sans-serif" font-size="11"><10>i tho<68>i Socratic</text>
|
||||
<rect x="80" y="155" width="240" height="40" fill="#f0f5f4" rx="6" stroke="#bec9c4"/>
|
||||
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
|
||||
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)"/>
|
||||
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" stroke="rgba(5,107,88,0.18)"/>
|
||||
<rect x="268" y="52" width="116" height="232" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.22)" rx="12"/>
|
||||
<text x="280" y="80" fill="#23686c" font-family="sans-serif" font-size="10" font-weight="600"><10> XU<58>T AI</text>
|
||||
<circle cx="326" cy="110" r="20" fill="#e8a838"/>
|
||||
<text x="320" y="115" fill="#fff" font-family="sans-serif" font-size="16" font-weight="700">2</text>
|
||||
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 827 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
|
||||
<rect x="16" y="16" width="368" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="8"/>
|
||||
<text x="32" y="42" fill="#23686c" font-family="sans-serif" font-size="14" font-weight="700">Danh s<>ch c<>ng vi<76>c</text>
|
||||
<rect x="16" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||
<rect x="136" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||
<rect x="256" y="68" width="128" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||
<rect x="16" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||
<rect x="204" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
|
||||
<text x="16" y="292" fill="#6f7975" font-family="sans-serif" font-size="10">NFR <20> EMR</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img">
|
||||
<defs>
|
||||
<radialGradient id="us-bg" cx="50%" cy="45%" r="55%">
|
||||
<stop offset="0%" stop-color="#2a3533"/>
|
||||
<stop offset="100%" stop-color="#0a0f0e"/>
|
||||
</radialGradient>
|
||||
<filter id="noise">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" stitchTiles="stitch"/>
|
||||
<feColorMatrix type="saturate" values="0"/>
|
||||
<feBlend in="SourceGraphic" mode="multiply"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="640" height="480" fill="url(#us-bg)"/>
|
||||
<ellipse cx="320" cy="240" rx="220" ry="170" fill="#1a2422" opacity="0.6"/>
|
||||
<path d="M180 200 Q260 120 400 180 T520 280 Q420 360 280 340 T160 260 Z" fill="#3d4a47" opacity="0.5" filter="url(#noise)"/>
|
||||
<path d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z" fill="#5a6b66" opacity="0.35"/>
|
||||
<ellipse cx="340" cy="230" rx="45" ry="28" fill="#8a9a94" opacity="0.25"/>
|
||||
<text x="20" y="30" fill="#76c8b1" font-family="monospace" font-size="14">SI<EFBFBD>U <20>M <20> KH<4B>P G<>I</text>
|
||||
<text x="20" y="460" fill="#6f7975" font-family="monospace" font-size="12">12.5 MHz <20> Gain 62</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,17 @@
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const src = join(root, 'node_modules/@mediapipe/tasks-genai/wasm');
|
||||
const dest = join(root, 'public/mediapipe-wasm');
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.warn('[sync-mediapipe-wasm] Skip: run npm install first.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
cpSync(src, dest, { recursive: true });
|
||||
console.log('[sync-mediapipe-wasm] Copied WASM assets to public/mediapipe-wasm');
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { PatientStoreProvider } from './lib/patientStore';
|
||||
import PatientWorklistPage from './pages/PatientWorklistPage';
|
||||
import ClinicalWorkspacePage from './pages/ClinicalWorkspacePage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<PatientStoreProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<PatientWorklistPage />} />
|
||||
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</PatientStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { startTransition, useEffect, useState, type ReactNode } from 'react';
|
||||
import StreamingPlainText from './StreamingPlainText';
|
||||
|
||||
const ORDERED_RE = /^(\d+)\.\s+(.*)$/;
|
||||
const BULLET_RE = /^(\s*)[\*\-]\s+(.*)$/;
|
||||
|
||||
function parseInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let rest = text;
|
||||
let index = 0;
|
||||
|
||||
while (rest.length > 0) {
|
||||
const boldMatch = rest.match(/^\*\*(.+?)\*\*/);
|
||||
if (boldMatch) {
|
||||
nodes.push(
|
||||
<strong key={`${keyPrefix}-b-${index}`}>{boldMatch[1]}</strong>,
|
||||
);
|
||||
rest = rest.slice(boldMatch[0].length);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const italicMatch = rest.match(/^\*([^*]+?)\*/);
|
||||
if (italicMatch) {
|
||||
nodes.push(
|
||||
<em key={`${keyPrefix}-i-${index}`}>{italicMatch[1]}</em>,
|
||||
);
|
||||
rest = rest.slice(italicMatch[0].length);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codeMatch = rest.match(/^`([^`]+)`/);
|
||||
if (codeMatch) {
|
||||
nodes.push(
|
||||
<code key={`${keyPrefix}-c-${index}`} className="chat-md__code">
|
||||
{codeMatch[1]}
|
||||
</code>,
|
||||
);
|
||||
rest = rest.slice(codeMatch[0].length);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSpecial = rest.search(/\*\*|\*|`/);
|
||||
if (nextSpecial === -1) {
|
||||
nodes.push(rest);
|
||||
break;
|
||||
}
|
||||
if (nextSpecial > 0) {
|
||||
nodes.push(rest.slice(0, nextSpecial));
|
||||
rest = rest.slice(nextSpecial);
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push(rest[0]);
|
||||
rest = rest.slice(1);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
content: string;
|
||||
children: ListItem[];
|
||||
}
|
||||
|
||||
function parseListItems(lines: string[], start: number): { items: ListItem[]; next: number } {
|
||||
const items: ListItem[] = [];
|
||||
let index = start;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
break;
|
||||
}
|
||||
|
||||
const bullet = line.match(BULLET_RE);
|
||||
if (!bullet) {
|
||||
break;
|
||||
}
|
||||
|
||||
const indent = bullet[1].length;
|
||||
if (items.length > 0 && indent > 0) {
|
||||
const parent = items[items.length - 1];
|
||||
const nested = parseListItems(lines, index);
|
||||
parent.children = nested.items;
|
||||
index = nested.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
items.push({ content: bullet[2], children: [] });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return { items, next: index };
|
||||
}
|
||||
|
||||
function renderListItems(items: ListItem[], ordered: boolean, keyPrefix: string): ReactNode {
|
||||
const Tag = ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag
|
||||
key={keyPrefix}
|
||||
className={ordered ? 'chat-md__ol' : 'chat-md__ul'}
|
||||
>
|
||||
{items.map((item, itemIndex) => (
|
||||
<li key={`${keyPrefix}-${itemIndex}`}>
|
||||
{parseInline(item.content, `${keyPrefix}-${itemIndex}`)}
|
||||
{item.children.length > 0
|
||||
? renderListItems(item.children, false, `${keyPrefix}-${itemIndex}-sub`)
|
||||
: null}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlocks(text: string): ReactNode[] {
|
||||
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks: ReactNode[] = [];
|
||||
let index = 0;
|
||||
let blockKey = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordered = line.match(ORDERED_RE);
|
||||
if (ordered) {
|
||||
const items: ListItem[] = [];
|
||||
while (index < lines.length) {
|
||||
const current = lines[index];
|
||||
const match = current.match(ORDERED_RE);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
const item: ListItem = { content: match[2], children: [] };
|
||||
index += 1;
|
||||
const nested = parseListItems(lines, index);
|
||||
item.children = nested.items;
|
||||
index = nested.next;
|
||||
items.push(item);
|
||||
}
|
||||
blocks.push(renderListItems(items, true, `ol-${blockKey}`));
|
||||
blockKey += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = line.match(BULLET_RE);
|
||||
if (bullet && bullet[1].length === 0) {
|
||||
const { items, next } = parseListItems(lines, index);
|
||||
blocks.push(renderListItems(items, false, `ul-${blockKey}`));
|
||||
blockKey += 1;
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
const Tag = level === 1 ? 'h4' : level === 2 ? 'h5' : 'h6';
|
||||
blocks.push(
|
||||
<Tag key={`h-${blockKey}`} className="chat-md__heading">
|
||||
{parseInline(heading[2], `h-${blockKey}`)}
|
||||
</Tag>,
|
||||
);
|
||||
blockKey += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (index < lines.length && lines[index].trim()) {
|
||||
const current = lines[index];
|
||||
if (
|
||||
ORDERED_RE.test(current) ||
|
||||
BULLET_RE.test(current) ||
|
||||
/^#{1,3}\s/.test(current)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paragraphLines.push(current);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p key={`p-${blockKey}`} className="chat-md__p">
|
||||
{parseInline(paragraphLines.join(' '), `p-${blockKey}`)}
|
||||
</p>,
|
||||
);
|
||||
blockKey += 1;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function scheduleIdle(work: () => void): () => void {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
const id = requestIdleCallback(work, { timeout: 150 });
|
||||
return () => cancelIdleCallback(id);
|
||||
}
|
||||
const id = window.setTimeout(work, 0);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
|
||||
interface ChatMarkdownProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
/** Plain text while tokens stream in; formatted markdown once complete. */
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatMarkdown({ text, className, streaming = false }: ChatMarkdownProps) {
|
||||
const [formattedBlocks, setFormattedBlocks] = useState<ReactNode[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) {
|
||||
setFormattedBlocks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const cancelIdle = scheduleIdle(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const blocks = renderBlocks(text);
|
||||
startTransition(() => {
|
||||
if (!cancelled) {
|
||||
setFormattedBlocks(blocks);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [streaming, text]);
|
||||
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showPlain = streaming || formattedBlocks === null;
|
||||
|
||||
if (showPlain) {
|
||||
return (
|
||||
<div className={className ? `chat-md chat-md--plain ${className}` : 'chat-md chat-md--plain'}>
|
||||
{streaming ? (
|
||||
<StreamingPlainText text={text} className="chat-md__plain" />
|
||||
) : (
|
||||
<p className="chat-md__plain">{text}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className ? `chat-md ${className}` : 'chat-md'}>
|
||||
{formattedBlocks}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const CHAT_PHRASES = [
|
||||
'Đang suy nghĩ',
|
||||
'Đang phân tích',
|
||||
'Đang cân nhắc',
|
||||
'Đang tổng hợp',
|
||||
] as const;
|
||||
|
||||
const AGENT_PHRASES = [
|
||||
'Đang suy nghĩ',
|
||||
'Đang lập kế hoạch',
|
||||
'Đang chuẩn bị',
|
||||
'Đang xử lý',
|
||||
] as const;
|
||||
|
||||
export type ClinicalChatPonderingVariant = 'chat' | 'agent';
|
||||
|
||||
interface ClinicalChatPonderingTextProps {
|
||||
variant?: ClinicalChatPonderingVariant;
|
||||
}
|
||||
|
||||
export default function ClinicalChatPonderingText({
|
||||
variant = 'chat',
|
||||
}: ClinicalChatPonderingTextProps) {
|
||||
const phrases = variant === 'agent' ? AGENT_PHRASES : CHAT_PHRASES;
|
||||
const [index, setIndex] = useState(0);
|
||||
const [flickerKey, setFlickerKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const phraseTimer = window.setInterval(() => {
|
||||
setIndex((current) => (current + 1) % phrases.length);
|
||||
setFlickerKey((current) => current + 1);
|
||||
}, 2_400);
|
||||
return () => window.clearInterval(phraseTimer);
|
||||
}, [phrases.length]);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={flickerKey}
|
||||
className="clinical-chat__pondering"
|
||||
aria-live="polite"
|
||||
aria-label={`${phrases[index]}, vui lòng đợi`}
|
||||
>
|
||||
<span className="clinical-chat__pondering-text">{phrases[index]}</span>
|
||||
<span className="clinical-chat__pondering-dots" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { memo, useLayoutEffect, useRef } from 'react';
|
||||
import { registerClinicalStreamTarget } from '../../lib/llm/clinicalChatStreamRegistry';
|
||||
|
||||
interface StreamingPlainTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
/** When set, DOM is updated imperatively during Ollama token stream. */
|
||||
streamTargetKey?: string;
|
||||
}
|
||||
|
||||
/** Updates streaming text via DOM textContent to avoid React child reconciliation. */
|
||||
function StreamingPlainText({ text, className, streamTargetKey }: StreamingPlainTextProps) {
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!streamTargetKey) {
|
||||
return;
|
||||
}
|
||||
const el = ref.current;
|
||||
registerClinicalStreamTarget(streamTargetKey, el);
|
||||
if (el && text && !el.textContent) {
|
||||
el.textContent = text;
|
||||
}
|
||||
return () => registerClinicalStreamTarget(streamTargetKey, null);
|
||||
}, [streamTargetKey, text]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || el.textContent === text) {
|
||||
return;
|
||||
}
|
||||
// Imperative Ollama stream owns the DOM while tokens arrive.
|
||||
if (streamTargetKey) {
|
||||
return;
|
||||
}
|
||||
el.textContent = text;
|
||||
}, [text, streamTargetKey]);
|
||||
|
||||
return <p ref={ref} className={className} />;
|
||||
}
|
||||
|
||||
export default memo(StreamingPlainText);
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Figma: MSK_Workspace_UIX-Design → node 244:2415 (`palette`, 20×20)
|
||||
* https://www.figma.com/design/uakk3T4efS1AIckfTEFSZO/MSK_Workspace_UIX-Design?node-id=244-2415
|
||||
*/
|
||||
export default function PaletteIcon({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M5.25 12.75C3.75 11.25 3.25 9.25 3.75 7.25C4.5 5 6.75 3.5 9.5 3.75C12.25 4 14.25 5.75 14.75 8.25C15.25 10.75 14.25 13 12.5 14.25M11.75 13.5C12.15 13 12.4 12.4 12.5 11.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="6.75" cy="7.75" r="1.05" fill="currentColor" />
|
||||
<circle cx="9.25" cy="6.5" r="1.05" fill="currentColor" />
|
||||
<circle cx="11.75" cy="6.5" r="1.05" fill="currentColor" />
|
||||
<circle cx="14.25" cy="7.75" r="1.05" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import TopBar from './TopBar';
|
||||
import BottomBar from './BottomBar';
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
topBar: React.ComponentProps<typeof TopBar>;
|
||||
}
|
||||
|
||||
export default function AppShell({ children, topBar }: AppShellProps) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopBar {...topBar} />
|
||||
<main className="app-main">{children}</main>
|
||||
<BottomBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export default function BottomBar() {
|
||||
return (
|
||||
<footer className="bottombar glass">
|
||||
<span className="bottombar__copyright">© 2026, by VKIST</span>
|
||||
<style>{`
|
||||
.bottombar {
|
||||
height: var(--bottombar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 var(--space-md);
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bottombar__copyright {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
`}</style>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TopBarProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
actions?: ReactNode;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
export default function TopBar({
|
||||
title = 'Lumina MSK',
|
||||
subtitle,
|
||||
showBack,
|
||||
onBack,
|
||||
actions,
|
||||
phase,
|
||||
}: TopBarProps) {
|
||||
return (
|
||||
<header className="topbar glass">
|
||||
<div className="topbar__left">
|
||||
{showBack && (
|
||||
<button type="button" className="icon-btn" onClick={onBack} aria-label="Quay lại">
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="topbar__brand">
|
||||
<span className="topbar__logo">◈</span>
|
||||
<div>
|
||||
<h1 className="topbar__title">{title}</h1>
|
||||
{subtitle && <p className="topbar__subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar__center">
|
||||
{phase && <span className="topbar__phase chip chip-success">{phase}</span>}
|
||||
</div>
|
||||
<div className="topbar__actions">{actions}</div>
|
||||
<style>{`
|
||||
.topbar {
|
||||
height: var(--topbar-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-md);
|
||||
gap: var(--space-md);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar__logo {
|
||||
color: var(--color-primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
.topbar__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar__subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar__center {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar__phase {
|
||||
font-size: 11px;
|
||||
}
|
||||
.topbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`}</style>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from 'react';
|
||||
import AngleLegendSwitchGuide from './AngleLegendSwitchGuide';
|
||||
import CalibratedDistributionList, { CalibrationStateBanner } from './CalibratedDistributionList.jsx';
|
||||
import {
|
||||
ANGLE_CLASSES,
|
||||
formatAngleClinicalLabel,
|
||||
getAngleMeta,
|
||||
isAngleClass,
|
||||
type AngleClass,
|
||||
type AngleClassificationResult,
|
||||
} from '../../data/angleClassification';
|
||||
import {
|
||||
interpretLogits,
|
||||
predictedClassFromProbabilities,
|
||||
type CalibrationUserConfig,
|
||||
} from '../../lib/calibrationEngine';
|
||||
import { formatRiskFramingVi } from '../../types/calibratedPrediction';
|
||||
import CalibrationMetricHelp from './CalibrationMetricHelp';
|
||||
|
||||
interface AngleClassificationBlobProps {
|
||||
classification: AngleClassificationResult | null;
|
||||
userConfig: CalibrationUserConfig;
|
||||
suppressed?: boolean;
|
||||
}
|
||||
|
||||
const ANGLE_DISTRIBUTION_ENTRIES = ANGLE_CLASSES.map((angleClass) => ({
|
||||
key: angleClass,
|
||||
label: formatAngleClinicalLabel(getAngleMeta(angleClass)),
|
||||
}));
|
||||
|
||||
function formatClassLabel(className: string): string {
|
||||
if (isAngleClass(className)) {
|
||||
return formatAngleClinicalLabel(getAngleMeta(className));
|
||||
}
|
||||
return className;
|
||||
}
|
||||
|
||||
export default function AngleClassificationBlob({
|
||||
classification,
|
||||
userConfig,
|
||||
suppressed,
|
||||
}: AngleClassificationBlobProps) {
|
||||
const liveCalibration = useMemo(() => {
|
||||
const raw = classification?.calibration?.raw_logits;
|
||||
if (!raw?.length) return classification?.calibration ?? null;
|
||||
return interpretLogits(raw, ANGLE_CLASSES, userConfig, formatClassLabel);
|
||||
}, [classification, userConfig]);
|
||||
|
||||
const displayClass: AngleClass | null = useMemo(() => {
|
||||
if (!classification) return null;
|
||||
if (!liveCalibration) return classification.class;
|
||||
const predicted = predictedClassFromProbabilities(liveCalibration.class_probabilities, ANGLE_CLASSES);
|
||||
return isAngleClass(predicted) ? predicted : classification.class;
|
||||
}, [classification, liveCalibration]);
|
||||
|
||||
if (suppressed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!classification || !displayClass) {
|
||||
return (
|
||||
<div className="angle-blob glass">
|
||||
<span className="angle-blob__eyebrow">Phân loại góc chụp</span>
|
||||
<p className="angle-blob__pending">Đang chờ kết quả phân loại góc…</p>
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = getAngleMeta(displayClass);
|
||||
const modelDefaultMeta = getAngleMeta(classification.class);
|
||||
const shiftedFromModel = displayClass !== classification.class;
|
||||
const riskText =
|
||||
liveCalibration?.risk_framing_vi ??
|
||||
formatRiskFramingVi(formatAngleClinicalLabel(meta), classification.confidence);
|
||||
|
||||
return (
|
||||
<div className="angle-blob glass">
|
||||
<span className="angle-blob__eyebrow">Phân loại góc chụp</span>
|
||||
|
||||
<div className="angle-blob__result">
|
||||
<strong>{formatAngleClinicalLabel(meta)}</strong>
|
||||
{shiftedFromModel && (
|
||||
<p className="angle-blob__shift">
|
||||
Mô hình gốc: {formatAngleClinicalLabel(modelDefaultMeta)} — đã điều chỉnh theo T / nghi ngờ lâm sàng.
|
||||
</p>
|
||||
)}
|
||||
{liveCalibration && <CalibrationStateBanner calibration={liveCalibration} />}
|
||||
<p className="angle-blob__risk">{riskText}</p>
|
||||
</div>
|
||||
|
||||
<div className="angle-blob__legend-block">
|
||||
<AngleLegendSwitchGuide angleClass={displayClass} />
|
||||
</div>
|
||||
|
||||
<details className="angle-blob__classes" open={liveCalibration?.decision_state !== 'confident'}>
|
||||
<summary>Phân bố xác suất theo lớp</summary>
|
||||
<CalibrationMetricHelp layout="block" />
|
||||
{liveCalibration ? (
|
||||
<CalibratedDistributionList
|
||||
calibration={liveCalibration}
|
||||
entries={ANGLE_DISTRIBUTION_ENTRIES}
|
||||
predictedKey={displayClass}
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{ANGLE_CLASSES.map((angleClass) => {
|
||||
const item = getAngleMeta(angleClass);
|
||||
const active = angleClass === displayClass;
|
||||
return (
|
||||
<li key={angleClass} className={active ? 'angle-blob__class--active' : undefined}>
|
||||
<span>{formatAngleClinicalLabel(item)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.angle-blob {
|
||||
margin-top: 4px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
.angle-blob__eyebrow {
|
||||
font-size: 0.6875em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.angle-blob__pending {
|
||||
margin: 0;
|
||||
font-size: 0.8125em;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.angle-blob__result strong {
|
||||
display: block;
|
||||
font-size: 0.9375em;
|
||||
font-family: var(--font-headline);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.angle-blob__shift {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.6875em;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.angle-blob__risk {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.8125em;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.angle-blob__legend-block {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(35, 104, 108, 0.06);
|
||||
border-left: 3px solid var(--color-secondary);
|
||||
}
|
||||
.angle-blob__classes {
|
||||
font-size: 0.875em;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.angle-blob__classes summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 1em;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.angle-blob__classes ul {
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.angle-blob__classes li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.angle-blob__class--active > span:first-child {
|
||||
color: var(--color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type AngleLegendColorTone = 'red' | 'purple' | 'cyan' | 'yellow' | 'green' | 'blue';
|
||||
|
||||
interface AngleLegendColorTermProps {
|
||||
tone: AngleLegendColorTone;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AngleLegendColorTerm({ tone, children }: AngleLegendColorTermProps) {
|
||||
return (
|
||||
<span className={`angle-legend-color angle-legend-color--${tone}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const angleLegendColorStyles = `
|
||||
.angle-legend-color {
|
||||
font-weight: 600;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.angle-legend-color--red {
|
||||
color: rgb(185, 28, 28);
|
||||
background: rgba(255, 0, 0, 0.12);
|
||||
}
|
||||
.angle-legend-color--purple {
|
||||
color: rgb(156, 39, 176);
|
||||
background: rgba(255, 0, 255, 0.12);
|
||||
}
|
||||
.angle-legend-color--cyan {
|
||||
color: rgb(0, 131, 143);
|
||||
background: rgba(0, 255, 255, 0.18);
|
||||
}
|
||||
.angle-legend-color--yellow {
|
||||
color: rgb(161, 98, 7);
|
||||
background: rgba(255, 255, 0, 0.22);
|
||||
}
|
||||
.angle-legend-color--green {
|
||||
color: rgb(27, 122, 55);
|
||||
background: rgba(0, 255, 0, 0.14);
|
||||
}
|
||||
.angle-legend-color--blue {
|
||||
color: rgb(25, 78, 166);
|
||||
background: rgba(0, 0, 255, 0.1);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
getAngleMeta,
|
||||
type AngleClass,
|
||||
} from '../../data/angleClassification';
|
||||
import AngleLegendColorTerm, { angleLegendColorStyles } from './AngleLegendColorTerm';
|
||||
|
||||
interface AngleLegendSwitchGuideProps {
|
||||
angleClass: AngleClass;
|
||||
}
|
||||
|
||||
function SupFamilyColorList({ includeHoffa = false }: { includeHoffa?: boolean }) {
|
||||
return (
|
||||
<ul className="angle-switch-guide__colors">
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="red">Đỏ</AngleLegendColorTerm>: Dịch khớp (Joint effusion).
|
||||
</li>
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="purple">Tím</AngleLegendColorTerm>: Màng hoạt dịch (Synovium).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Viền trắng dày</strong> trên overlay: bao quanh vùng viêm (dịch khớp và màng hoạt dịch).
|
||||
</li>
|
||||
{includeHoffa && (
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="cyan">Lam sáng</AngleLegendColorTerm>: Mỡ Hoffa (Hoffa fat pad).
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTransColorList() {
|
||||
return (
|
||||
<ul className="angle-switch-guide__colors">
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="red">Đỏ</AngleLegendColorTerm>: Nang Baker (Baker's cyst) — thay vì dịch
|
||||
khớp như ở mặt trước.
|
||||
</li>
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="cyan">Lam sáng</AngleLegendColorTerm>: Cơ vùng khoeo — thay vì mỡ Hoffa.
|
||||
</li>
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="yellow">Vàng</AngleLegendColorTerm>: Mỡ.
|
||||
</li>
|
||||
<li>
|
||||
<AngleLegendColorTerm tone="green">Xanh lá</AngleLegendColorTerm>: Xương đùi.
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentAngleSection({
|
||||
angleClass,
|
||||
sectionNumber,
|
||||
}: {
|
||||
angleClass: AngleClass;
|
||||
sectionNumber: number;
|
||||
}) {
|
||||
const meta = getAngleMeta(angleClass);
|
||||
|
||||
if (angleClass === 'post-trans') {
|
||||
return (
|
||||
<section className="angle-switch-guide__section">
|
||||
<h4 className="angle-switch-guide__section-title">
|
||||
{sectionNumber}. Góc {meta.labelMedicalEn} ({meta.legendPlaneVi})
|
||||
</h4>
|
||||
<p className="angle-switch-guide__catalog">Mã màu (Theo Phụ lục A.2):</p>
|
||||
<PostTransColorList />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (angleClass === 'sup-up-long') {
|
||||
return (
|
||||
<section className="angle-switch-guide__section">
|
||||
<h4 className="angle-switch-guide__section-title">
|
||||
{sectionNumber}. Góc {meta.labelMedicalEn} ({meta.legendPlaneVi})
|
||||
</h4>
|
||||
<p className="angle-switch-guide__catalog">Mã màu (Theo Phụ lục A.1):</p>
|
||||
<SupFamilyColorList includeHoffa />
|
||||
<p className="angle-switch-guide__tech-note">
|
||||
<strong>Lưu ý kỹ thuật:</strong> Góc này hỗ trợ thước đo độ dày tự động (khung xanh, đường ngọc, đường
|
||||
đỏ trên overlay).
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const noThickness = angleClass === 'sup-trans-flex' || angleClass === 'med-lat';
|
||||
const supLong = getAngleMeta('sup-up-long');
|
||||
|
||||
return (
|
||||
<section className="angle-switch-guide__section">
|
||||
<h4 className="angle-switch-guide__section-title">
|
||||
{sectionNumber}. Góc {meta.labelMedicalEn} ({meta.legendPlaneVi})
|
||||
</h4>
|
||||
<p className="angle-switch-guide__catalog">Mã màu (Theo Phụ lục A.1):</p>
|
||||
<SupFamilyColorList includeHoffa={angleClass === 'med-lat'} />
|
||||
{noThickness && (
|
||||
<p className="angle-switch-guide__tech-note">
|
||||
<strong>Lưu ý kỹ thuật:</strong> Mặt cắt này không hiển thị thước đo độ dày tự động (Tính năng đo độ
|
||||
dày chỉ khả dụng trên mặt cắt dọc {supLong.labelMedicalEn}).
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchTargetSection({
|
||||
angleClass,
|
||||
sectionNumber,
|
||||
}: {
|
||||
angleClass: AngleClass;
|
||||
sectionNumber: number;
|
||||
}) {
|
||||
const isPost = angleClass === 'post-trans';
|
||||
const target = isPost ? getAngleMeta('sup-up-long') : getAngleMeta('post-trans');
|
||||
|
||||
return (
|
||||
<section className="angle-switch-guide__section">
|
||||
<h4 className="angle-switch-guide__section-title">
|
||||
{sectionNumber}. Góc {target.labelMedicalEn} ({target.legendPlaneVi})
|
||||
</h4>
|
||||
<p className="angle-switch-guide__lead">
|
||||
{isPost
|
||||
? 'Khi chuyển sang góc quét phía trên bánh chè, định dạng màu quay về Phụ lục A.1:'
|
||||
: 'Khi chuyển sang góc quét này, định dạng màu sẽ tự động tái cấu trúc (Phụ lục A.2):'}
|
||||
</p>
|
||||
{isPost ? (
|
||||
<SupFamilyColorList includeHoffa />
|
||||
) : (
|
||||
<PostTransColorList />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AngleLegendSwitchGuide({ angleClass }: AngleLegendSwitchGuideProps) {
|
||||
return (
|
||||
<div className="angle-switch-guide">
|
||||
<h3 className="angle-switch-guide__heading">⚠️ Lưu ý quan trọng khi đổi góc quét</h3>
|
||||
<p className="angle-switch-guide__intro">
|
||||
Khi thay đổi góc quét siêu âm, ý nghĩa hiển thị của các vùng màu (overlay) và bong bóng thông tin
|
||||
(tooltip) sẽ thay đổi như sau:
|
||||
</p>
|
||||
<CurrentAngleSection angleClass={angleClass} sectionNumber={1} />
|
||||
<SwitchTargetSection angleClass={angleClass} sectionNumber={2} />
|
||||
<style>{guideStyles}</style>
|
||||
<style>{angleLegendColorStyles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const guideStyles = `
|
||||
.angle-switch-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
.angle-switch-guide__heading {
|
||||
margin: 0;
|
||||
font-size: 0.6875em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.angle-switch-guide__intro {
|
||||
margin: 0;
|
||||
font-size: 0.8125em;
|
||||
line-height: 1.45;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.angle-switch-guide__section {
|
||||
margin: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.angle-switch-guide__section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.8125em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.angle-switch-guide__catalog,
|
||||
.angle-switch-guide__lead {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.angle-switch-guide__colors {
|
||||
margin: 0;
|
||||
padding: 0 0 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 0.8125em;
|
||||
line-height: 1.45;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.angle-switch-guide__tech-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.75em;
|
||||
line-height: 1.45;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.angle-switch-guide__tech-note strong {
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import PaletteIcon from '../icons/PaletteIcon';
|
||||
import { PEN_INK_PALETTE, type PenInkColorOption } from '../../types/canvasAnnotation';
|
||||
|
||||
interface AnnotationColorPickerProps {
|
||||
inkColor: string;
|
||||
onInkColorChange: (hex: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AnnotationColorPicker({
|
||||
inkColor,
|
||||
onInkColorChange,
|
||||
disabled,
|
||||
}: AnnotationColorPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const selectColor = (hex: string) => {
|
||||
onInkColorChange(hex);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="annotation-color-picker" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
open
|
||||
? 'annotation-color-picker__trigger annotation-color-picker__trigger--open'
|
||||
: 'annotation-color-picker__trigger'
|
||||
}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
title="Bảng màu bút vẽ"
|
||||
>
|
||||
<span className="annotation-color-picker__palette-icon" aria-hidden>
|
||||
<PaletteIcon size={18} />
|
||||
</span>
|
||||
<span
|
||||
className="annotation-color-picker__ink-dot"
|
||||
style={{ backgroundColor: inkColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="annotation-color-picker__label">Màu</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id={listId}
|
||||
className="annotation-color-picker__popover glass"
|
||||
role="listbox"
|
||||
aria-label="Chọn màu mực bút vẽ"
|
||||
>
|
||||
<p className="annotation-color-picker__heading">Màu bút vẽ</p>
|
||||
<div className="annotation-color-picker__grid">
|
||||
{PEN_INK_PALETTE.map((option) => (
|
||||
<Swatch
|
||||
key={option.id}
|
||||
option={option}
|
||||
selected={inkColor.toLowerCase() === option.hex.toLowerCase()}
|
||||
onSelect={() => selectColor(option.hex)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Swatch({
|
||||
option,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
option: PenInkColorOption;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const needsContrast =
|
||||
option.contrastOnDark || option.contrastOnLight;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={option.labelVi}
|
||||
title={`${option.labelVi} (${option.hex})`}
|
||||
className={
|
||||
selected
|
||||
? 'annotation-color-picker__swatch annotation-color-picker__swatch--selected'
|
||||
: 'annotation-color-picker__swatch'
|
||||
}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
needsContrast
|
||||
? 'annotation-color-picker__swatch-fill annotation-color-picker__swatch-fill--contrast'
|
||||
: 'annotation-color-picker__swatch-fill'
|
||||
}
|
||||
style={{ backgroundColor: option.hex }}
|
||||
/>
|
||||
{selected && (
|
||||
<span className="annotation-color-picker__check" aria-hidden>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.annotation-color-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.annotation-color-picker__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.annotation-color-picker__trigger:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.annotation-color-picker__trigger--open {
|
||||
background: rgba(35, 104, 108, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
.annotation-color-picker__trigger:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.annotation-color-picker__palette-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.annotation-color-picker__ink-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.annotation-color-picker__label {
|
||||
line-height: 1;
|
||||
}
|
||||
.annotation-color-picker__popover {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 10px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 40;
|
||||
min-width: 188px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(173, 238, 242, 0.35);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
background: rgba(23, 29, 28, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.annotation-color-picker__heading {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(173, 238, 242, 0.9);
|
||||
}
|
||||
.annotation-color-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.annotation-color-picker__swatch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.annotation-color-picker__swatch:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.annotation-color-picker__swatch--selected {
|
||||
outline: 2px solid #adeef2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.annotation-color-picker__swatch-fill {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
}
|
||||
.annotation-color-picker__swatch-fill--contrast {
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(23, 29, 28, 0.25);
|
||||
}
|
||||
.annotation-color-picker__check {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65));
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.annotation-color-picker__label {
|
||||
display: none;
|
||||
}
|
||||
.annotation-color-picker__trigger {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { RefObject } from 'react';
|
||||
import type { CanvasTool } from '../../types/canvasAnnotation';
|
||||
import { useDraggableRibbon } from '../../hooks/useDraggableRibbon';
|
||||
import AnnotationColorPicker from './AnnotationColorPicker';
|
||||
|
||||
interface AnnotationRibbonProps {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
tool: CanvasTool;
|
||||
onToolChange: (tool: CanvasTool) => void;
|
||||
inkColor: string;
|
||||
onInkColorChange: (hex: string) => void;
|
||||
zoom: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
onClearAnnotations: () => void;
|
||||
hasAnnotations: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ToolIcon({ children }: { children: React.ReactNode }) {
|
||||
return <span className="annotation-ribbon__icon" aria-hidden>{children}</span>;
|
||||
}
|
||||
|
||||
export default function AnnotationRibbon({
|
||||
containerRef,
|
||||
tool,
|
||||
onToolChange,
|
||||
inkColor,
|
||||
onInkColorChange,
|
||||
zoom,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetView,
|
||||
onClearAnnotations,
|
||||
hasAnnotations,
|
||||
disabled,
|
||||
}: AnnotationRibbonProps) {
|
||||
const zoomPct = Math.round(zoom * 100);
|
||||
const { panelRef, position, isDragging, dragHandleProps } = useDraggableRibbon(containerRef);
|
||||
|
||||
const panelStyle =
|
||||
position != null
|
||||
? { left: position.x, top: position.y, transform: 'none' as const }
|
||||
: { left: '50%', bottom: 16, top: 'auto' as const, transform: 'translateX(-50%)' };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`annotation-ribbon glass ${disabled ? 'annotation-ribbon--disabled' : ''} ${isDragging ? 'annotation-ribbon--dragging' : ''}`}
|
||||
style={panelStyle}
|
||||
role="toolbar"
|
||||
aria-label="Công cụ chú thích khung hình"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-ribbon__drag-handle"
|
||||
aria-label="Kéo để di chuyển thanh công cụ"
|
||||
title="Kéo để di chuyển"
|
||||
disabled={disabled}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<circle cx="9" cy="7" r="1.5" />
|
||||
<circle cx="15" cy="7" r="1.5" />
|
||||
<circle cx="9" cy="12" r="1.5" />
|
||||
<circle cx="15" cy="12" r="1.5" />
|
||||
<circle cx="9" cy="17" r="1.5" />
|
||||
<circle cx="15" cy="17" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span className="annotation-ribbon__divider" aria-hidden />
|
||||
|
||||
<div className="annotation-ribbon__group" role="group" aria-label="Vẽ và tẩy">
|
||||
<button
|
||||
type="button"
|
||||
className={tool === 'draw' ? 'annotation-ribbon__btn annotation-ribbon__btn--active' : 'annotation-ribbon__btn'}
|
||||
onClick={() => onToolChange('draw')}
|
||||
disabled={disabled}
|
||||
aria-pressed={tool === 'draw'}
|
||||
title="Vẽ — chuột hoặc cảm ứng"
|
||||
>
|
||||
<ToolIcon>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z" />
|
||||
<path d="m13.5 6.5 3 3" />
|
||||
</svg>
|
||||
</ToolIcon>
|
||||
<span className="annotation-ribbon__label">Vẽ</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tool === 'eraser' ? 'annotation-ribbon__btn annotation-ribbon__btn--active' : 'annotation-ribbon__btn'}
|
||||
onClick={() => onToolChange('eraser')}
|
||||
disabled={disabled}
|
||||
aria-pressed={tool === 'eraser'}
|
||||
title="Tẩy nét vẽ"
|
||||
>
|
||||
<ToolIcon>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 20H8l-6.5-6.5a2.1 2.1 0 0 1 0-3L14 4.5a2.1 2.1 0 0 1 3 0L20 7.5" />
|
||||
<path d="m6.5 13.5 6 6" />
|
||||
</svg>
|
||||
</ToolIcon>
|
||||
<span className="annotation-ribbon__label">Tẩy</span>
|
||||
</button>
|
||||
<AnnotationColorPicker
|
||||
inkColor={inkColor}
|
||||
onInkColorChange={onInkColorChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="annotation-ribbon__divider" aria-hidden />
|
||||
|
||||
<div className="annotation-ribbon__group" role="group" aria-label="Di chuyển khung">
|
||||
<button
|
||||
type="button"
|
||||
className={tool === 'pan' ? 'annotation-ribbon__btn annotation-ribbon__btn--active' : 'annotation-ribbon__btn'}
|
||||
onClick={() => onToolChange('pan')}
|
||||
disabled={disabled}
|
||||
aria-pressed={tool === 'pan'}
|
||||
title="Di chuyển — bật để kéo hai ngón (trackpad/cảm ứng) hoặc kéo chuột"
|
||||
>
|
||||
<ToolIcon>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 11V5a2 2 0 0 0-4 0v4M14 11V4a2 2 0 0 0-4 0v7M10 11V5a2 2 0 0 0-4 0v8a8 8 0 0 0 16 0v-5a2 2 0 0 0-4 0" />
|
||||
</svg>
|
||||
</ToolIcon>
|
||||
<span className="annotation-ribbon__label">Di chuyển</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="annotation-ribbon__divider" aria-hidden />
|
||||
|
||||
<div className="annotation-ribbon__group" role="group" aria-label="Phóng to thu nhỏ">
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-ribbon__btn annotation-ribbon__btn--compact"
|
||||
onClick={onZoomOut}
|
||||
disabled={disabled || zoom <= 1}
|
||||
title="Thu nhỏ"
|
||||
aria-label="Thu nhỏ"
|
||||
>
|
||||
<ToolIcon>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M8 11h6M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</ToolIcon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-ribbon__zoom-readout tnum"
|
||||
onClick={onResetView}
|
||||
disabled={disabled}
|
||||
title="Đặt lại vừa khung"
|
||||
>
|
||||
{zoomPct}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-ribbon__btn annotation-ribbon__btn--compact"
|
||||
onClick={onZoomIn}
|
||||
disabled={disabled || zoom >= 4}
|
||||
title="Phóng to"
|
||||
aria-label="Phóng to"
|
||||
>
|
||||
<ToolIcon>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M11 8v6M8 11h6M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</ToolIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasAnnotations && (
|
||||
<>
|
||||
<span className="annotation-ribbon__divider" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="annotation-ribbon__btn annotation-ribbon__btn--ghost"
|
||||
onClick={onClearAnnotations}
|
||||
disabled={disabled}
|
||||
title="Xóa tất cả nét vẽ trên khung này"
|
||||
>
|
||||
<ToolIcon>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
|
||||
</svg>
|
||||
</ToolIcon>
|
||||
<span className="annotation-ribbon__label">Xóa nét</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.annotation-ribbon {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(173, 238, 242, 0.35);
|
||||
max-width: calc(100% - 24px);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.annotation-ribbon--dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.annotation-ribbon--disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.annotation-ribbon__drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
.annotation-ribbon__drag-handle:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.annotation-ribbon--dragging .annotation-ribbon__drag-handle {
|
||||
cursor: grabbing;
|
||||
color: #adeef2;
|
||||
}
|
||||
.annotation-ribbon__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.annotation-ribbon__divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.annotation-ribbon__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.annotation-ribbon__btn--compact {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.annotation-ribbon__btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.annotation-ribbon__btn--active {
|
||||
background: rgba(35, 104, 108, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
.annotation-ribbon__btn--ghost {
|
||||
color: rgba(255, 200, 200, 0.95);
|
||||
}
|
||||
.annotation-ribbon__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.annotation-ribbon__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.annotation-ribbon__label {
|
||||
line-height: 1;
|
||||
}
|
||||
.annotation-ribbon__zoom-readout {
|
||||
min-width: 3.25rem;
|
||||
padding: 8px 6px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #adeef2;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.annotation-ribbon__zoom-readout:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.annotation-ribbon__label {
|
||||
display: none;
|
||||
}
|
||||
.annotation-ribbon__btn {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
OOD_CLINICIAN_BANNER_VI,
|
||||
type CalibratedPrediction,
|
||||
} from '../../types/calibratedPrediction';
|
||||
|
||||
export interface DistributionEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface CalibratedDistributionListProps {
|
||||
calibration: CalibratedPrediction;
|
||||
entries: DistributionEntry[];
|
||||
predictedKey: string;
|
||||
}
|
||||
|
||||
export default function CalibratedDistributionList({
|
||||
calibration,
|
||||
entries,
|
||||
predictedKey,
|
||||
}: CalibratedDistributionListProps) {
|
||||
const maxProb = Math.max(...Object.values(calibration.class_probabilities), 1);
|
||||
|
||||
return (
|
||||
<table className="cal-dist" role="table">
|
||||
<caption className="cal-dist__caption">Phân bố xác suất theo lớp</caption>
|
||||
<tbody>
|
||||
{entries.map(({ key, label }) => {
|
||||
const prob = calibration.class_probabilities[key] ?? 0;
|
||||
const active = key === predictedKey;
|
||||
const ambiguous = calibration.ambiguous_set.includes(key);
|
||||
return (
|
||||
<tr key={key} className={active ? 'cal-dist__row--active' : undefined}>
|
||||
<td className="cal-dist__label-cell">
|
||||
<span className="cal-dist__label">{label}</span>
|
||||
{ambiguous && calibration.decision_state === 'ambiguous' && (
|
||||
<span className="cal-dist__tag">Mơ hồ</span>
|
||||
)}
|
||||
<div className="cal-dist__bar-track" aria-hidden>
|
||||
<div
|
||||
className="cal-dist__bar-fill"
|
||||
style={{ width: `${(prob / maxProb) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="cal-dist__pct-cell">
|
||||
<span className="cal-dist__pct tnum">{prob.toFixed(1)}%</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<style>{styles}</style>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalibrationStateBanner({ calibration }: { calibration: CalibratedPrediction }) {
|
||||
if (calibration.decision_state === 'confident') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className =
|
||||
calibration.decision_state === 'ood_warning'
|
||||
? 'cal-banner cal-banner--ood'
|
||||
: 'cal-banner cal-banner--ambiguous';
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
{calibration.decision_state === 'ood_warning'
|
||||
? OOD_CLINICIAN_BANNER_VI
|
||||
: 'Tập mơ hồ — cần đối chiếu thủ công'}
|
||||
<style>{bannerStyles}</style>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.cal-dist {
|
||||
width: 100%;
|
||||
margin: 8px 0 0;
|
||||
border-collapse: collapse;
|
||||
border: none;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.cal-dist__caption {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.cal-dist td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cal-dist tr + tr td {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.cal-dist__label-cell {
|
||||
width: auto;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.cal-dist__pct-cell {
|
||||
width: 3.75rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cal-dist__label {
|
||||
display: inline;
|
||||
font-size: 1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cal-dist__row--active .cal-dist__label {
|
||||
color: var(--color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cal-dist__tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(232, 168, 56, 0.2);
|
||||
color: #9a6b12;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cal-dist__bar-track {
|
||||
margin-top: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cal-dist__bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cal-dist__pct {
|
||||
font-size: 0.9375em;
|
||||
font-weight: 500;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cal-dist__row--active .cal-dist__pct {
|
||||
color: var(--color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
const bannerStyles = `
|
||||
.cal-banner {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cal-banner--ambiguous {
|
||||
background: rgba(232, 168, 56, 0.12);
|
||||
color: #8a6110;
|
||||
border-left: 3px solid #e8a838;
|
||||
}
|
||||
.cal-banner--ood {
|
||||
background: rgba(186, 26, 26, 0.08);
|
||||
color: #8f1414;
|
||||
border-left: 3px solid var(--color-error);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
CALIBRATION_TIERS,
|
||||
recommendedTemperatureForTier,
|
||||
resolveTierFromTemperature,
|
||||
TEMPERATURE_SLIDER,
|
||||
type CalibrationTierId,
|
||||
} from '../../data/calibrationTiers';
|
||||
import type { CalibrationUserConfig } from '../../lib/calibrationEngine';
|
||||
import CalibrationMetricHelp from './CalibrationMetricHelp';
|
||||
|
||||
interface CalibrationControlsProps {
|
||||
config: CalibrationUserConfig;
|
||||
onChange: (next: CalibrationUserConfig) => void;
|
||||
}
|
||||
|
||||
export default function CalibrationControls({ config, onChange }: CalibrationControlsProps) {
|
||||
const activeTierId = resolveTierFromTemperature(config.temperature);
|
||||
|
||||
const setTemperature = (temperature: number) => {
|
||||
onChange({ ...config, temperature });
|
||||
};
|
||||
|
||||
const snapToTier = (tierId: CalibrationTierId) => {
|
||||
setTemperature(recommendedTemperatureForTier(tierId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cal-ctrl">
|
||||
<div className="cal-ctrl__header">
|
||||
<span className="cal-ctrl__title">Điều chỉnh nhiệt độ (T)</span>
|
||||
<span className="cal-ctrl__hint tnum">T = {config.temperature.toFixed(2)}</span>
|
||||
</div>
|
||||
<CalibrationMetricHelp layout="block" />
|
||||
|
||||
<div className="cal-ctrl__tiers" role="group" aria-label="Bậc hiệu chuẩn lâm sàng">
|
||||
{CALIBRATION_TIERS.map((tier) => {
|
||||
const active = tier.id === activeTierId;
|
||||
return (
|
||||
<button
|
||||
key={tier.id}
|
||||
type="button"
|
||||
className={active ? 'cal-tier cal-tier--active' : 'cal-tier'}
|
||||
onClick={() => snapToTier(tier.id)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="cal-tier__badge">Bậc {tier.tier}</span>
|
||||
<strong className="cal-tier__name">{tier.labelVi}</strong>
|
||||
<span className="cal-tier__rule tnum">{tier.ruleVi}</span>
|
||||
<span className="cal-tier__trigger">{tier.triggerVi}</span>
|
||||
<span className="cal-tier__effect">{tier.uiEffectVi}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<label className="cal-ctrl__slider-label">
|
||||
<span>Kéo T liên tục</span>
|
||||
<span className="tnum">{config.temperature.toFixed(2)}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={Math.round(TEMPERATURE_SLIDER.min * 100)}
|
||||
max={Math.round(TEMPERATURE_SLIDER.max * 100)}
|
||||
step={Math.round(TEMPERATURE_SLIDER.step * 100)}
|
||||
value={Math.round(config.temperature * 100)}
|
||||
className="cal-ctrl__slider"
|
||||
aria-valuemin={TEMPERATURE_SLIDER.min}
|
||||
aria-valuemax={TEMPERATURE_SLIDER.max}
|
||||
aria-valuenow={config.temperature}
|
||||
onChange={(e) => setTemperature(Number(e.target.value) / 100)}
|
||||
/>
|
||||
<div className="cal-ctrl__scale tnum" aria-hidden>
|
||||
<span>0.5</span>
|
||||
<span>1.05</span>
|
||||
<span>1.8</span>
|
||||
<span>2.5</span>
|
||||
</div>
|
||||
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.cal-ctrl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.cal-ctrl__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.cal-ctrl__title {
|
||||
font-size: 0.6875em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.cal-ctrl__hint {
|
||||
font-size: 0.6875em;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cal-ctrl__tiers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cal-tier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--color-surface, #fff);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.cal-tier--active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-secondary);
|
||||
background: rgba(35, 104, 108, 0.1);
|
||||
box-shadow: 0 0 0 1px rgba(35, 104, 108, 0.15);
|
||||
}
|
||||
.cal-tier__badge {
|
||||
font-size: 0.625em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.cal-tier--active .cal-tier__badge {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.cal-tier__name {
|
||||
font-size: 0.8125em;
|
||||
font-family: var(--font-headline);
|
||||
line-height: 1.3;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.cal-tier--active .cal-tier__name {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.cal-tier__rule {
|
||||
font-size: 0.6875em;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.cal-tier__trigger,
|
||||
.cal-tier__effect {
|
||||
font-size: 0.6875em;
|
||||
line-height: 1.4;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.cal-ctrl__slider-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75em;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cal-ctrl__slider {
|
||||
width: 100%;
|
||||
accent-color: var(--color-secondary);
|
||||
}
|
||||
.cal-ctrl__scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.625em;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0.8;
|
||||
margin-top: -4px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
CALIBRATION_METRIC_HELP_FOUNDATION,
|
||||
CALIBRATION_METRIC_HELP_KEY_POINTS,
|
||||
CALIBRATION_METRIC_HELP_KEY_POINTS_TEASER,
|
||||
CALIBRATION_METRIC_HELP_KEY_POINTS_TITLE,
|
||||
CALIBRATION_METRIC_HELP_NARRATIVE,
|
||||
CALIBRATION_METRIC_HELP_NARRATIVE_TEASER,
|
||||
CALIBRATION_METRIC_HELP_NARRATIVE_TITLE,
|
||||
CALIBRATION_METRIC_HELP_READING,
|
||||
CALIBRATION_METRIC_HELP_READING_TITLE,
|
||||
CALIBRATION_METRIC_HELP_REFERENCES_TEASER,
|
||||
CALIBRATION_METRIC_HELP_REFERENCES_TITLE,
|
||||
CALIBRATION_METRIC_HELP_TRIGGER,
|
||||
} from '../../data/calibrationMetricHelp';
|
||||
|
||||
interface CalibrationMetricHelpProps {
|
||||
/** Full-width in-flow panel — avoids clipping in narrow side column */
|
||||
layout?: 'block' | 'inline';
|
||||
}
|
||||
|
||||
interface HelpSectionProps {
|
||||
title: string;
|
||||
teaser: string;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function HelpSection({ title, teaser, defaultOpen, children }: HelpSectionProps) {
|
||||
return (
|
||||
<details className="cal-help-section" open={defaultOpen}>
|
||||
<summary className="cal-help-section__toggle">
|
||||
<span className="cal-help-section__chevron" aria-hidden />
|
||||
<span className="cal-help-section__labels">
|
||||
<span className="cal-help-section__title">{title}</span>
|
||||
<span className="cal-help-section__teaser">{teaser}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="cal-help-section__body">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalibrationMetricHelp({ layout = 'block' }: CalibrationMetricHelpProps) {
|
||||
const isBlock = layout === 'block';
|
||||
|
||||
return (
|
||||
<div className={`cal-metric-help ${isBlock ? 'cal-metric-help--block' : 'cal-metric-help--inline'}`}>
|
||||
<details className="cal-metric-help__details">
|
||||
<summary className="cal-metric-help__trigger">
|
||||
<span className="cal-metric-help__icon" aria-hidden>
|
||||
?
|
||||
</span>
|
||||
<span className="cal-metric-help__trigger-text">{CALIBRATION_METRIC_HELP_TRIGGER}</span>
|
||||
</summary>
|
||||
<div className="cal-metric-help__panel" role="note">
|
||||
<p className="cal-metric-help__intro">
|
||||
Mở từng mục bạn quan tâm — không cần đọc hết một lúc.
|
||||
</p>
|
||||
|
||||
<div className="cal-metric-help__sections">
|
||||
<HelpSection
|
||||
title={CALIBRATION_METRIC_HELP_NARRATIVE_TITLE}
|
||||
teaser={CALIBRATION_METRIC_HELP_NARRATIVE_TEASER}
|
||||
>
|
||||
{CALIBRATION_METRIC_HELP_NARRATIVE.map((paragraph) => (
|
||||
<p key={paragraph.slice(0, 32)} className="cal-metric-help__prose">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</HelpSection>
|
||||
|
||||
<HelpSection
|
||||
title={CALIBRATION_METRIC_HELP_KEY_POINTS_TITLE}
|
||||
teaser={CALIBRATION_METRIC_HELP_KEY_POINTS_TEASER}
|
||||
defaultOpen
|
||||
>
|
||||
<ol className="cal-metric-help__key-points">
|
||||
{CALIBRATION_METRIC_HELP_KEY_POINTS.map((point) => (
|
||||
<li key={point.title} className="cal-metric-help__key-point">
|
||||
<strong>{point.title}.</strong> {point.body}
|
||||
{point.subPoints && (
|
||||
<ul className="cal-metric-help__sub-points">
|
||||
{point.subPoints.map((sub) => (
|
||||
<li key={sub}>{sub}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</HelpSection>
|
||||
|
||||
<HelpSection
|
||||
title={CALIBRATION_METRIC_HELP_REFERENCES_TITLE}
|
||||
teaser={CALIBRATION_METRIC_HELP_REFERENCES_TEASER}
|
||||
>
|
||||
<p className="cal-metric-help__foundation">{CALIBRATION_METRIC_HELP_FOUNDATION}</p>
|
||||
<p className="cal-metric-help__refs-title">{CALIBRATION_METRIC_HELP_READING_TITLE}</p>
|
||||
<ul className="cal-metric-help__refs">
|
||||
{CALIBRATION_METRIC_HELP_READING.map((ref) => (
|
||||
<li key={ref.href}>
|
||||
<a href={ref.href} target="_blank" rel="noopener noreferrer">
|
||||
{ref.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HelpSection>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionStyles = `
|
||||
.cal-help-section {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cal-help-section__toggle {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
user-select: none;
|
||||
}
|
||||
.cal-help-section__toggle::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.cal-help-section__toggle:hover {
|
||||
background: rgba(35, 104, 108, 0.04);
|
||||
}
|
||||
.cal-help-section[open] .cal-help-section__toggle {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: rgba(35, 104, 108, 0.06);
|
||||
}
|
||||
.cal-help-section__chevron {
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: 6px;
|
||||
border-right: 2px solid var(--color-secondary);
|
||||
border-bottom: 2px solid var(--color-secondary);
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.cal-help-section[open] .cal-help-section__chevron {
|
||||
transform: rotate(45deg);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cal-help-section__labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cal-help-section__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-headline);
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.cal-help-section__teaser {
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cal-help-section__body {
|
||||
padding: 12px 14px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
`;
|
||||
|
||||
const styles = `
|
||||
${sectionStyles}
|
||||
|
||||
.cal-metric-help {
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: normal;
|
||||
/* rem base — avoids double-shrink when nested in small em containers */
|
||||
font-size: 13px;
|
||||
}
|
||||
.cal-metric-help--block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.cal-metric-help--inline {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.cal-metric-help__details {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.cal-metric-help__trigger {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
color: var(--color-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
.cal-metric-help__trigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.cal-metric-help__icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(35, 104, 108, 0.35);
|
||||
background: rgba(35, 104, 108, 0.08);
|
||||
color: var(--color-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.cal-metric-help__trigger-text {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-color: rgba(35, 104, 108, 0.35);
|
||||
}
|
||||
.cal-metric-help__panel {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(35, 104, 108, 0.05);
|
||||
border: 1px solid rgba(35, 104, 108, 0.12);
|
||||
border-left: 3px solid var(--color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--color-on-surface-variant);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.cal-metric-help__intro {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.cal-metric-help__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.cal-metric-help__prose {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.cal-metric-help__prose:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cal-metric-help__key-points {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.cal-metric-help__key-point {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cal-metric-help__key-point:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cal-metric-help__key-point strong {
|
||||
color: var(--color-on-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cal-metric-help__sub-points {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 1rem;
|
||||
list-style: disc;
|
||||
}
|
||||
.cal-metric-help__sub-points li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cal-metric-help__foundation {
|
||||
margin: 0 0 10px;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cal-metric-help__refs-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.cal-metric-help__refs {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.cal-metric-help__refs a {
|
||||
color: var(--color-secondary);
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { memo } from 'react';
|
||||
import type { ClinicalChatMessage } from '../../types/clinicalChat';
|
||||
import ChatMarkdown from '../atoms/ChatMarkdown';
|
||||
import ClinicalChatPonderingText from '../atoms/ClinicalChatPonderingText';
|
||||
import StreamingPlainText from '../atoms/StreamingPlainText';
|
||||
import ClinicalChatThought from './ClinicalChatThought';
|
||||
import { streamTargetKey } from '../../lib/llm/clinicalChatStreamRegistry';
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
interface ClinicalChatMessageBubbleProps {
|
||||
message: ClinicalChatMessage;
|
||||
}
|
||||
|
||||
function ClinicalChatMessageBubble({ message }: ClinicalChatMessageBubbleProps) {
|
||||
return (
|
||||
<div className={`clinical-chat__bubble clinical-chat__bubble--${message.role}`}>
|
||||
<div className="clinical-chat__bubble-meta">
|
||||
<span className="clinical-chat__role">
|
||||
{message.role === 'user' ? 'Bạn' : 'AI lâm sàng'}
|
||||
</span>
|
||||
<time className="clinical-chat__time tnum" dateTime={message.timestamp.toISOString()}>
|
||||
{formatTime(message.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
{message.pondering ? (
|
||||
<ClinicalChatPonderingText variant={message.ponderingVariant ?? 'chat'} />
|
||||
) : message.streaming &&
|
||||
!message.content &&
|
||||
!message.tracksThought &&
|
||||
!message.thoughtContent ? (
|
||||
<span className="clinical-chat__typing" aria-label="AI đang trả lời">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{message.tracksThought || message.thoughtContent ? (
|
||||
<ClinicalChatThought
|
||||
messageId={message.id}
|
||||
content={message.thoughtContent ?? ''}
|
||||
thoughtStreaming={Boolean(message.streaming && !message.thoughtComplete)}
|
||||
answerStreaming={message.streaming}
|
||||
/>
|
||||
) : null}
|
||||
{message.content || (message.tracksThought && message.streaming) ? (
|
||||
message.tracksThought ? (
|
||||
<StreamingPlainText
|
||||
text={message.content}
|
||||
streamTargetKey={streamTargetKey(message.id, 'answer')}
|
||||
className="clinical-chat__answer-md chat-md__plain"
|
||||
/>
|
||||
) : (
|
||||
<ChatMarkdown
|
||||
text={message.content}
|
||||
streaming={message.streaming}
|
||||
className="clinical-chat__answer-md"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(
|
||||
ClinicalChatMessageBubble,
|
||||
(prev, next) =>
|
||||
prev.message.id === next.message.id &&
|
||||
prev.message.content === next.message.content &&
|
||||
prev.message.thoughtContent === next.message.thoughtContent &&
|
||||
prev.message.tracksThought === next.message.tracksThought &&
|
||||
prev.message.thoughtComplete === next.message.thoughtComplete &&
|
||||
prev.message.pondering === next.message.pondering &&
|
||||
prev.message.ponderingVariant === next.message.ponderingVariant &&
|
||||
prev.message.streaming === next.message.streaming &&
|
||||
prev.message.role === next.message.role,
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { memo, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
import StreamingPlainText from '../atoms/StreamingPlainText';
|
||||
import { streamTargetKey } from '../../lib/llm/clinicalChatStreamRegistry';
|
||||
|
||||
interface ClinicalChatThoughtProps {
|
||||
messageId: string;
|
||||
content: string;
|
||||
/** True while the thought channel is still being generated. */
|
||||
thoughtStreaming?: boolean;
|
||||
/** True while the answer channel is still streaming (defers heavy markdown parse). */
|
||||
answerStreaming?: boolean;
|
||||
}
|
||||
|
||||
/** Reasoning panel — plain text only (markdown disabled while isolating stream crashes). */
|
||||
function ClinicalChatThought({
|
||||
messageId,
|
||||
content,
|
||||
thoughtStreaming = false,
|
||||
}: ClinicalChatThoughtProps) {
|
||||
const panelId = useId();
|
||||
const wasThoughtStreamingRef = useRef(thoughtStreaming);
|
||||
const userToggledRef = useRef(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(true);
|
||||
userToggledRef.current = false;
|
||||
wasThoughtStreamingRef.current = false;
|
||||
}, [messageId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (thoughtStreaming) {
|
||||
if (!userToggledRef.current) {
|
||||
setExpanded(true);
|
||||
}
|
||||
} else if (wasThoughtStreamingRef.current && !thoughtStreaming && !userToggledRef.current) {
|
||||
setExpanded(false);
|
||||
}
|
||||
wasThoughtStreamingRef.current = thoughtStreaming;
|
||||
}, [thoughtStreaming]);
|
||||
|
||||
if (!content.trim() && !thoughtStreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
userToggledRef.current = true;
|
||||
setExpanded((open) => !open);
|
||||
};
|
||||
|
||||
const label = thoughtStreaming ? 'Đang suy luận' : 'Suy luận';
|
||||
const preview = !thoughtStreaming && !expanded ? content.replace(/\s+/g, ' ').trim().slice(0, 100) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`clinical-chat__thought ${thoughtStreaming ? 'clinical-chat__thought--streaming' : ''} ${expanded ? 'clinical-chat__thought--open' : 'clinical-chat__thought--closed'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="clinical-chat__thought-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className="clinical-chat__thought-chevron" aria-hidden>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span className="clinical-chat__thought-label">🤔 {label}</span>
|
||||
{thoughtStreaming ? (
|
||||
<span className="clinical-chat__thought-live" aria-label="Đang cập nhật">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{!expanded && preview ? (
|
||||
<p className="clinical-chat__thought-preview">{preview}…</p>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div id={panelId} className="clinical-chat__thought-body">
|
||||
<StreamingPlainText
|
||||
text={content}
|
||||
streamTargetKey={streamTargetKey(messageId, 'thought')}
|
||||
className="clinical-chat__thought-md chat-md__plain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ClinicalChatThought, (prev, next) =>
|
||||
prev.messageId === next.messageId &&
|
||||
prev.content === next.content &&
|
||||
prev.thoughtStreaming === next.thoughtStreaming &&
|
||||
prev.answerStreaming === next.answerStreaming,
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { AnnotationPoint } from '../../types/canvasAnnotation';
|
||||
|
||||
interface ClosedLoopPromptProps {
|
||||
anchor: AnnotationPoint;
|
||||
onConfirm: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function ClosedLoopPrompt({ anchor, onConfirm, onDismiss }: ClosedLoopPromptProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onDismiss();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="closed-loop-prompt glass"
|
||||
style={{
|
||||
left: anchor.x,
|
||||
top: anchor.y - 20,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label="Xác nhận vùng đánh dấu"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p className="closed-loop-prompt__label">
|
||||
Bạn có muốn tạo vùng đánh dấu trên khu vực này không?
|
||||
</p>
|
||||
<div className="closed-loop-prompt__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="closed-loop-prompt__btn closed-loop-prompt__btn--confirm"
|
||||
onClick={onConfirm}
|
||||
aria-label="Xác nhận tạo vùng đánh dấu"
|
||||
title="Xác nhận"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="closed-loop-prompt__btn closed-loop-prompt__btn--dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label="Hủy"
|
||||
title="Hủy"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{styles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.closed-loop-prompt {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
transform: translate(-50%, -100%);
|
||||
max-width: min(280px, calc(100% - 24px));
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(173, 238, 242, 0.4);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
background: rgba(23, 29, 28, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.closed-loop-prompt__label {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.closed-loop-prompt__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.closed-loop-prompt__btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.12s;
|
||||
}
|
||||
.closed-loop-prompt__btn:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.closed-loop-prompt__btn--confirm {
|
||||
background: rgba(35, 104, 108, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
.closed-loop-prompt__btn--confirm:hover {
|
||||
background: rgba(35, 104, 108, 1);
|
||||
}
|
||||
.closed-loop-prompt__btn--dismiss {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.closed-loop-prompt__btn--dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { DiagnosticFormDraft, DiseaseProgression, NextProcedure, PowerDopplerScore } from '../../data/diagnosticForm';
|
||||
import {
|
||||
DISEASE_PROGRESSION_OPTIONS,
|
||||
NEXT_PROCEDURE_OPTIONS,
|
||||
POWER_DOPPLER_SCORES,
|
||||
} from '../../data/diagnosticForm';
|
||||
|
||||
interface DiagnosticFormStepFieldsProps {
|
||||
step: number;
|
||||
draft: DiagnosticFormDraft;
|
||||
jointLabel: string;
|
||||
onDraftChange: (draft: DiagnosticFormDraft) => void;
|
||||
readOnlySnapshot?: boolean;
|
||||
}
|
||||
|
||||
export default function DiagnosticFormStepFields({
|
||||
step,
|
||||
draft,
|
||||
jointLabel,
|
||||
onDraftChange,
|
||||
readOnlySnapshot = false,
|
||||
}: DiagnosticFormStepFieldsProps) {
|
||||
const setProgression = (value: DiseaseProgression) => {
|
||||
onDraftChange({
|
||||
...draft,
|
||||
diseaseProgression: draft.diseaseProgression === value ? null : value,
|
||||
});
|
||||
};
|
||||
|
||||
const setDoppler = (value: PowerDopplerScore) => {
|
||||
onDraftChange({
|
||||
...draft,
|
||||
powerDopplerScore: draft.powerDopplerScore === value ? null : value,
|
||||
});
|
||||
};
|
||||
|
||||
const setProcedure = (value: NextProcedure) => {
|
||||
onDraftChange({
|
||||
...draft,
|
||||
nextProcedure: draft.nextProcedure === value ? null : value,
|
||||
});
|
||||
};
|
||||
|
||||
if (step === 0) {
|
||||
return (
|
||||
<div className="diag-form-fields__pill-group" role="group" aria-label="Tiến triển bệnh">
|
||||
{DISEASE_PROGRESSION_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={
|
||||
draft.diseaseProgression === option.value
|
||||
? 'diag-form-fields__pill diag-form-fields__pill--active'
|
||||
: 'diag-form-fields__pill'
|
||||
}
|
||||
onClick={() => setProgression(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="diag-form-fields__measure-block">
|
||||
<p className="diag-form-fields__measure-label">Thang điểm Power Doppler</p>
|
||||
<div className="diag-form-fields__score-group" role="group" aria-label="Power Doppler">
|
||||
{POWER_DOPPLER_SCORES.map((score) => (
|
||||
<button
|
||||
key={score}
|
||||
type="button"
|
||||
className={
|
||||
draft.powerDopplerScore === score
|
||||
? 'diag-form-fields__score diag-form-fields__score--active'
|
||||
: 'diag-form-fields__score'
|
||||
}
|
||||
onClick={() => setDoppler(score)}
|
||||
>
|
||||
{score}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="diag-form-fields__field">
|
||||
<span>
|
||||
Độ dày màng hoạt dịch {jointLabel}{' '}
|
||||
<span className="diag-form-fields__field-note">(không có hoặc không biết: −1)</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="tnum"
|
||||
value={draft.synovialThicknessUnknown ? '−1' : draft.synovialThicknessMm}
|
||||
disabled={draft.synovialThicknessUnknown}
|
||||
onChange={(event) => onDraftChange({ ...draft, synovialThicknessMm: event.target.value })}
|
||||
placeholder="mm"
|
||||
/>
|
||||
</label>
|
||||
<label className="diag-form-fields__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.synovialThicknessUnknown}
|
||||
onChange={(event) =>
|
||||
onDraftChange({
|
||||
...draft,
|
||||
synovialThicknessUnknown: event.target.checked,
|
||||
synovialThicknessMm: event.target.checked ? '' : draft.synovialThicknessMm,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>Không có hoặc không đo được</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
return (
|
||||
<div className="diag-form-fields__anatomy-block">
|
||||
<label className="diag-form-fields__field diag-form-fields__subfield">
|
||||
<span>Khớp gối phải</span>
|
||||
<textarea
|
||||
className="diag-form-fields__textarea--scroll diag-form-fields__textarea--sub"
|
||||
value={draft.anatomyDescriptionRight}
|
||||
onChange={(event) =>
|
||||
onDraftChange({ ...draft, anatomyDescriptionRight: event.target.value })
|
||||
}
|
||||
placeholder="Nhận định lâm sàng và ghi chú về giải phẫu khớp gối phải…"
|
||||
/>
|
||||
</label>
|
||||
<label className="diag-form-fields__field diag-form-fields__subfield">
|
||||
<span>Khớp gối trái</span>
|
||||
<textarea
|
||||
className="diag-form-fields__textarea--scroll diag-form-fields__textarea--sub"
|
||||
value={draft.anatomyDescriptionLeft}
|
||||
onChange={(event) =>
|
||||
onDraftChange({ ...draft, anatomyDescriptionLeft: event.target.value })
|
||||
}
|
||||
placeholder="Nhận định lâm sàng và ghi chú về giải phẫu khớp gối trái…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 3) {
|
||||
return (
|
||||
<div className="diag-form-fields__snapshot">
|
||||
{draft.diagnosisWorkImageDataUrl ? (
|
||||
<img
|
||||
src={draft.diagnosisWorkImageDataUrl}
|
||||
alt="Ảnh siêu âm đã chú thích"
|
||||
className="diag-form-fields__snapshot-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="diag-form-fields__snapshot-empty">Chưa có ảnh chẩn đoán đã chú thích.</div>
|
||||
)}
|
||||
{readOnlySnapshot && draft.diagnosisWorkImageDataUrl && (
|
||||
<p className="diag-form-fields__snapshot-note">Ảnh lưu từ khung chẩn đoán tại thời điểm lập phiếu.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 4) {
|
||||
return (
|
||||
<div className="diag-form-fields__procedure-block">
|
||||
<p className="diag-form-fields__measure-label">Chỉ định thủ thuật tiếp theo</p>
|
||||
<div className="diag-form-fields__procedure-list" role="radiogroup" aria-label="Chỉ định thủ thuật">
|
||||
{NEXT_PROCEDURE_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={
|
||||
draft.nextProcedure === option.value
|
||||
? 'diag-form-fields__procedure-option diag-form-fields__procedure-option--active'
|
||||
: 'diag-form-fields__procedure-option'
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="signOffProcedure"
|
||||
value={option.value}
|
||||
checked={draft.nextProcedure === option.value}
|
||||
onChange={() => setProcedure(option.value)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="diag-form-fields__field">
|
||||
<span>Tại sao cần thực hiện chỉ định</span>
|
||||
<textarea
|
||||
className="diag-form-fields__textarea--scroll diag-form-fields__textarea--compact"
|
||||
value={draft.procedureReason}
|
||||
onChange={(event) => onDraftChange({ ...draft, procedureReason: event.target.value })}
|
||||
placeholder="Giải thích lý do lâm sàng…"
|
||||
/>
|
||||
</label>
|
||||
<label className="diag-form-fields__field">
|
||||
<span>Người phụ trách thực hiện</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.procedureResponsibleName}
|
||||
onChange={(event) =>
|
||||
onDraftChange({ ...draft, procedureResponsibleName: event.target.value })
|
||||
}
|
||||
placeholder="Họ tên bác sĩ / kỹ thuật viên phụ trách"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="diag-form-fields__field">
|
||||
<span className="sr-only">Kết luận bác sĩ</span>
|
||||
<textarea
|
||||
className="diag-form-fields__textarea--scroll"
|
||||
value={draft.doctorConclusion}
|
||||
onChange={(event) => onDraftChange({ ...draft, doctorConclusion: event.target.value })}
|
||||
placeholder="Nhập kết luận / chẩn đoán / yêu cầu cần can thiệp đối với bệnh nhân."
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const diagnosticFormFieldStyles = `
|
||||
.diag-form-fields__pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.diag-form-fields__pill {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--color-on-surface);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.diag-form-fields__pill--active {
|
||||
background: rgba(35, 104, 108, 0.12);
|
||||
border-color: var(--color-secondary);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.diag-form-fields__measure-block,
|
||||
.diag-form-fields__procedure-block,
|
||||
.diag-form-fields__anatomy-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.diag-form-fields__measure-label {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.diag-form-fields__score-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.diag-form-fields__score {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: #fff;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.diag-form-fields__score--active {
|
||||
background: var(--color-secondary);
|
||||
border-color: var(--color-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
.diag-form-fields__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.diag-form-fields__subfield span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.diag-form-fields__field-note {
|
||||
font-weight: 400;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.diag-form-fields__field input,
|
||||
.diag-form-fields__field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
background: #fff;
|
||||
}
|
||||
.diag-form-fields__textarea--scroll {
|
||||
resize: none;
|
||||
height: 140px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.diag-form-fields__textarea--sub {
|
||||
height: 100px;
|
||||
max-height: 100px;
|
||||
}
|
||||
.diag-form-fields__textarea--compact {
|
||||
height: 88px;
|
||||
max-height: 88px;
|
||||
}
|
||||
.diag-form-fields__checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-on-surface-variant);
|
||||
cursor: pointer;
|
||||
}
|
||||
.diag-form-fields__procedure-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.diag-form-fields__procedure-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.diag-form-fields__procedure-option--active {
|
||||
border-color: var(--color-secondary);
|
||||
background: rgba(35, 104, 108, 0.08);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.diag-form-fields__snapshot-img {
|
||||
width: 100%;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: #0a0f0e;
|
||||
}
|
||||
.diag-form-fields__snapshot-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
border: 1px dashed var(--color-outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.diag-form-fields__snapshot-note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react';
|
||||
import { copyTextToClipboard } from '../../lib/copyToClipboard';
|
||||
import {
|
||||
buildMlErrorTicketText,
|
||||
type MlServiceError,
|
||||
} from '../../lib/mlServiceError';
|
||||
|
||||
interface MlServiceErrorPanelProps {
|
||||
error: MlServiceError;
|
||||
frameLabel?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export default function MlServiceErrorPanel({ error, frameLabel, onRetry }: MlServiceErrorPanelProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const showSupportBlock = error.code !== 'NO_RESULT';
|
||||
|
||||
async function copyTicketText() {
|
||||
const text = buildMlErrorTicketText(error, frameLabel);
|
||||
const ok = await copyTextToClipboard(text);
|
||||
if (ok) {
|
||||
setCopyFailed(false);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2500);
|
||||
return;
|
||||
}
|
||||
setCopyFailed(true);
|
||||
window.setTimeout(() => setCopyFailed(false), 4000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ml-svc-error" role="alert" aria-live="polite">
|
||||
<p className="ml-svc-error__title">{error.title}</p>
|
||||
<p className="ml-svc-error__message">{error.message}</p>
|
||||
|
||||
{showSupportBlock && (
|
||||
<div className="ml-svc-error__support">
|
||||
<p className="ml-svc-error__ref-label">Mã tham chiếu hỗ trợ</p>
|
||||
<code className="ml-svc-error__ref" tabIndex={0}>
|
||||
{error.supportReference}
|
||||
</code>
|
||||
<p className="ml-svc-error__ref-hint">
|
||||
Cung cấp mã này khi gửi ticket cho IT — giúp đội kỹ thuật tra cứu nhanh.
|
||||
</p>
|
||||
{copyFailed && (
|
||||
<p className="ml-svc-error__copy-fail">
|
||||
Không sao chép tự động được — hãy chọn và sao chép mã ở trên thủ công.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.ticketHints.length > 0 && (
|
||||
<ul className="ml-svc-error__hints">
|
||||
{error.ticketHints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="ml-svc-error__actions">
|
||||
{onRetry && (
|
||||
<button type="button" className="ml-svc-error__btn" onClick={onRetry}>
|
||||
Thử tải lại
|
||||
</button>
|
||||
)}
|
||||
{showSupportBlock && (
|
||||
<button type="button" className="ml-svc-error__btn ml-svc-error__btn--secondary" onClick={copyTicketText}>
|
||||
{copied ? 'Đã sao chép' : 'Sao chép cho ticket'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSupportBlock && (
|
||||
<details className="ml-svc-error__details">
|
||||
<summary>Chi tiết kỹ thuật (cho IT)</summary>
|
||||
<pre className="ml-svc-error__technical">{error.technicalDetail}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.ml-svc-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 14;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 20px;
|
||||
background: rgba(10, 15, 14, 0.78);
|
||||
color: #f4f7f6;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ml-svc-error__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
max-width: 22rem;
|
||||
}
|
||||
.ml-svc-error__message {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
max-width: 26rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.ml-svc-error__support {
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
max-width: 24rem;
|
||||
}
|
||||
.ml-svc-error__ref-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.ml-svc-error__ref {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
user-select: all;
|
||||
cursor: text;
|
||||
}
|
||||
.ml-svc-error__ref-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ml-svc-error__copy-fail {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.ml-svc-error__hints {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
max-width: 24rem;
|
||||
opacity: 0.88;
|
||||
}
|
||||
.ml-svc-error__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ml-svc-error__btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: inherit;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 7px 14px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ml-svc-error__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.ml-svc-error__btn--secondary {
|
||||
background: transparent;
|
||||
}
|
||||
.ml-svc-error__details {
|
||||
margin-top: 6px;
|
||||
max-width: 26rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ml-svc-error__details summary {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
list-style: none;
|
||||
}
|
||||
.ml-svc-error__details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.ml-svc-error__technical {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PatientCase } from '../../data/mockData';
|
||||
|
||||
interface PatientCardProps {
|
||||
patient: PatientCase;
|
||||
/** Cached inference grade (0–3). Omit mock placeholders — null = not analyzed yet. */
|
||||
cachedAiGrade?: number | null;
|
||||
aiGradeLoading?: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onEdit: (patient: PatientCase) => void;
|
||||
}
|
||||
|
||||
const STATUS_CHIP: Record<PatientCase['status'], string> = {
|
||||
urgent: 'chip-urgent',
|
||||
processing: 'chip-processing',
|
||||
followup: 'chip-followup',
|
||||
ready: 'chip-success',
|
||||
};
|
||||
|
||||
function EditIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PatientCard({
|
||||
patient,
|
||||
cachedAiGrade = null,
|
||||
aiGradeLoading = false,
|
||||
onOpen,
|
||||
onEdit,
|
||||
}: PatientCardProps) {
|
||||
return (
|
||||
<article className="patient-card glass">
|
||||
<div className="patient-card__header">
|
||||
<div className="patient-card__avatar" aria-hidden>
|
||||
{patient.name.charAt(0)}
|
||||
</div>
|
||||
<div className="patient-card__info">
|
||||
<h3>{patient.name}</h3>
|
||||
<p className="tnum">{patient.mrn}</p>
|
||||
</div>
|
||||
<span className={`chip ${STATUS_CHIP[patient.status]}`}>{patient.statusLabel}</span>
|
||||
</div>
|
||||
<div className="patient-card__body">
|
||||
<div className="patient-card__row">
|
||||
<span>Vị trí</span>
|
||||
<strong>{patient.joint}</strong>
|
||||
</div>
|
||||
<div className="patient-card__row">
|
||||
<span>Quét lúc</span>
|
||||
<strong className="tnum">{patient.lastScan}</strong>
|
||||
</div>
|
||||
<div className="patient-card__row">
|
||||
<span>Đề xuất AI</span>
|
||||
{aiGradeLoading ? (
|
||||
<strong className="patient-card__ai-muted">Đang tải…</strong>
|
||||
) : cachedAiGrade != null ? (
|
||||
<strong className="tnum">Độ {cachedAiGrade}</strong>
|
||||
) : (
|
||||
<strong className="patient-card__ai-muted">Chưa phân tích</strong>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="patient-card__actions">
|
||||
<button type="button" className="btn btn-primary patient-card__cta" onClick={() => onOpen(patient.id)}>
|
||||
Mở phiên chẩn đoán
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="patient-card__edit-btn"
|
||||
aria-label={`Chỉnh sửa hồ sơ ${patient.name}`}
|
||||
onClick={() => onEdit(patient)}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
</div>
|
||||
<style>{`
|
||||
.patient-card {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.patient-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.patient-card__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-primary-container), var(--color-secondary-container));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-headline);
|
||||
font-weight: 700;
|
||||
color: var(--color-on-primary-container);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.patient-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.patient-card__info h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.patient-card__info p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.patient-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.patient-card__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
.patient-card__row span {
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.patient-card__ai-muted {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-style: italic;
|
||||
}
|
||||
.patient-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.patient-card__cta {
|
||||
flex: 1 1 78%;
|
||||
min-width: 0;
|
||||
}
|
||||
.patient-card__edit-btn {
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
height: auto;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
color: var(--color-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.patient-card__edit-btn:hover {
|
||||
background: #cbd5e1;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
.patient-card__edit-btn:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}</style>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
eventKindColor,
|
||||
formatRecordedTime,
|
||||
formatReplayClock,
|
||||
type DiagnosticSessionEvent,
|
||||
} from '../../data/diagnosticSessionReplay';
|
||||
|
||||
interface RecordedSessionActionsListProps {
|
||||
events: DiagnosticSessionEvent[];
|
||||
activeEventId?: string;
|
||||
onSelectEvent?: (eventId: string, index: number) => void;
|
||||
}
|
||||
|
||||
export default function RecordedSessionActionsList({
|
||||
events,
|
||||
activeEventId,
|
||||
onSelectEvent,
|
||||
}: RecordedSessionActionsListProps) {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="recorded-actions" aria-label="Danh sách hành động đã ghi">
|
||||
<header className="recorded-actions__header">
|
||||
<h3 className="recorded-actions__title">Danh sách hành động đã ghi</h3>
|
||||
<span className="recorded-actions__count">{events.length} bước</span>
|
||||
</header>
|
||||
<ol className="recorded-actions__list">
|
||||
{events.map((event, index) => (
|
||||
<li key={event.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`recorded-actions__item${activeEventId === event.id ? ' recorded-actions__item--active' : ''}`}
|
||||
onClick={() => onSelectEvent?.(event.id, index)}
|
||||
>
|
||||
<span
|
||||
className="recorded-actions__kind"
|
||||
style={{ background: eventKindColor(event.kind) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="recorded-actions__body">
|
||||
<span className="recorded-actions__item-title">
|
||||
{index + 1}. {event.title}
|
||||
</span>
|
||||
<span className="recorded-actions__item-detail">{event.detail}</span>
|
||||
<span className="recorded-actions__item-meta tnum">
|
||||
{formatReplayClock(event.elapsedSeconds)} · Ghi nhận lúc {formatRecordedTime(event.recordedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<style>{`
|
||||
.recorded-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
}
|
||||
.recorded-actions__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.recorded-actions__title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #76c8b1;
|
||||
}
|
||||
.recorded-actions__count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.recorded-actions__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.recorded-actions__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
color: #e2e8f0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.recorded-actions__item:hover {
|
||||
border-color: rgba(118, 200, 177, 0.35);
|
||||
}
|
||||
.recorded-actions__item--active {
|
||||
border-color: rgba(118, 200, 177, 0.55);
|
||||
background: rgba(26, 95, 96, 0.22);
|
||||
}
|
||||
.recorded-actions__kind {
|
||||
flex-shrink: 0;
|
||||
width: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 999px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.recorded-actions__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.recorded-actions__item-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.recorded-actions__item-detail {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recorded-actions__item-meta {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { RecordingMode } from '../../lib/sessionRecordingTypes';
|
||||
|
||||
const MODE_OPTIONS: { id: RecordingMode; label: string; hint: string }[] = [
|
||||
{
|
||||
id: 'single_frame',
|
||||
label: 'Một khung',
|
||||
hint: 'Ghi trên khung hiện tại — không đổi khung khi đang ghi',
|
||||
},
|
||||
{
|
||||
id: 'multi_frame',
|
||||
label: 'Nhiều khung',
|
||||
hint: 'Một phiên liên tục — mỗi lần đổi khung tạo đoạn mới trên cùng dòng thời gian',
|
||||
},
|
||||
];
|
||||
|
||||
interface RecordingModeSelectorProps {
|
||||
value: RecordingMode;
|
||||
onChange: (mode: RecordingMode) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RecordingModeSelector({ value, onChange, disabled }: RecordingModeSelectorProps) {
|
||||
const activeHint = MODE_OPTIONS.find((option) => option.id === value)?.hint ?? '';
|
||||
|
||||
return (
|
||||
<fieldset className="recording-mode-selector" disabled={disabled}>
|
||||
<legend>Phạm vi ghi phiên</legend>
|
||||
<div className="recording-mode-selector__options">
|
||||
{MODE_OPTIONS.map((option) => (
|
||||
<label key={option.id} className="recording-mode-selector__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="recording-mode"
|
||||
value={option.id}
|
||||
checked={value === option.id}
|
||||
onChange={() => onChange(option.id)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{activeHint && <p className="recording-mode-selector__hint">{activeHint}</p>}
|
||||
|
||||
<style>{`
|
||||
.recording-mode-selector {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.recording-mode-selector legend {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.recording-mode-selector__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.recording-mode-selector__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.recording-mode-selector__option input {
|
||||
accent-color: #76c8b1;
|
||||
}
|
||||
.recording-mode-selector__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #64748b;
|
||||
}
|
||||
.recording-mode-selector:disabled .recording-mode-selector__option {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}</style>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
formatSavedSessionDate,
|
||||
sessionDurationLabel,
|
||||
type SavedSessionRecord,
|
||||
} from '../../lib/savedSessionStore';
|
||||
import type { EncounterGroup } from '../../lib/sessionRecordingTypes';
|
||||
|
||||
interface SavedSessionsDialogProps {
|
||||
open: boolean;
|
||||
sessions: SavedSessionRecord[];
|
||||
encounterGroups: EncounterGroup[];
|
||||
activeSessionId: string | null;
|
||||
onClose: () => void;
|
||||
onLoad: (sessionId: string) => void;
|
||||
onDelete: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
isActive,
|
||||
onLoad,
|
||||
onDelete,
|
||||
nested,
|
||||
}: {
|
||||
session: SavedSessionRecord;
|
||||
isActive: boolean;
|
||||
onLoad: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
nested?: boolean;
|
||||
}) {
|
||||
const eventCount = session.events.length;
|
||||
const duration = sessionDurationLabel(session.events);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`saved-sessions-dialog__item${isActive ? ' saved-sessions-dialog__item--active' : ''}${nested ? ' saved-sessions-dialog__item--nested' : ''}`}
|
||||
>
|
||||
<div className="saved-sessions-dialog__item-main">
|
||||
<strong>{session.name}</strong>
|
||||
<span className="saved-sessions-dialog__meta tnum">
|
||||
{formatSavedSessionDate(session.savedAt)} · {duration} · {eventCount} hành động
|
||||
{session.segments.length > 1 ? ` · ${session.segments.length} khung` : ''}
|
||||
</span>
|
||||
{session.description && <p className="saved-sessions-dialog__desc">{session.description}</p>}
|
||||
</div>
|
||||
<div className="saved-sessions-dialog__actions">
|
||||
<button type="button" className="saved-sessions-dialog__load" onClick={() => onLoad(session.id)}>
|
||||
{isActive ? 'Đang xem' : 'Mở'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="saved-sessions-dialog__delete"
|
||||
onClick={() => onDelete(session.id)}
|
||||
aria-label={`Xóa phiên ${session.name}`}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SavedSessionsDialog({
|
||||
open,
|
||||
sessions,
|
||||
encounterGroups,
|
||||
activeSessionId,
|
||||
onClose,
|
||||
onLoad,
|
||||
onDelete,
|
||||
}: SavedSessionsDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const groupedIds = new Set(encounterGroups.flatMap((group) => group.childRecordIds));
|
||||
const standaloneSessions = sessions.filter((session) => !groupedIds.has(session.id));
|
||||
|
||||
return (
|
||||
<div className="saved-sessions-dialog" role="dialog" aria-modal aria-labelledby="saved-sessions-title">
|
||||
<button type="button" className="saved-sessions-dialog__backdrop" aria-label="Đóng" onClick={onClose} />
|
||||
<div className="saved-sessions-dialog__panel">
|
||||
<header className="saved-sessions-dialog__header">
|
||||
<h3 id="saved-sessions-title">Danh sách phiên đã lưu</h3>
|
||||
<button type="button" className="saved-sessions-dialog__close" onClick={onClose} aria-label="Đóng">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="saved-sessions-dialog__empty">Chưa có phiên ghi nào được lưu cho bệnh nhân này.</p>
|
||||
) : (
|
||||
<ul className="saved-sessions-dialog__list">
|
||||
{encounterGroups.map((group) => {
|
||||
const children = group.childRecordIds
|
||||
.map((id) => sessions.find((session) => session.id === id))
|
||||
.filter((session): session is SavedSessionRecord => Boolean(session));
|
||||
if (children.length === 0) return null;
|
||||
|
||||
return (
|
||||
<li key={group.id} className="saved-sessions-dialog__group">
|
||||
<div className="saved-sessions-dialog__group-header">
|
||||
<strong>{group.displayName ?? `Phiên ${formatSavedSessionDate(group.startedAt)}`}</strong>
|
||||
<span className="saved-sessions-dialog__meta tnum">{children.length} khung</span>
|
||||
</div>
|
||||
<ul className="saved-sessions-dialog__group-children">
|
||||
{children.map((session) => (
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
isActive={session.id === activeSessionId}
|
||||
onLoad={onLoad}
|
||||
onDelete={onDelete}
|
||||
nested
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{standaloneSessions.map((session) => (
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
isActive={session.id === activeSessionId}
|
||||
onLoad={onLoad}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<footer className="saved-sessions-dialog__footer">
|
||||
<button type="button" className="saved-sessions-dialog__footer-btn" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.saved-sessions-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.saved-sessions-dialog__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
cursor: pointer;
|
||||
}
|
||||
.saved-sessions-dialog__panel {
|
||||
position: relative;
|
||||
width: min(480px, 100%);
|
||||
max-height: min(70vh, 520px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.35);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.saved-sessions-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.saved-sessions-dialog__header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.saved-sessions-dialog__close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.saved-sessions-dialog__empty {
|
||||
margin: 0;
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.saved-sessions-dialog__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.saved-sessions-dialog__group {
|
||||
list-style: none;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.saved-sessions-dialog__group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.saved-sessions-dialog__group-header strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
.saved-sessions-dialog__group-children {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.saved-sessions-dialog__item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.saved-sessions-dialog__item--nested {
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, 0.65);
|
||||
}
|
||||
.saved-sessions-dialog__item--active {
|
||||
border-color: rgba(118, 200, 177, 0.45);
|
||||
background: rgba(26, 95, 96, 0.25);
|
||||
}
|
||||
.saved-sessions-dialog__item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.saved-sessions-dialog__item-main strong {
|
||||
font-size: 13px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.saved-sessions-dialog__meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.saved-sessions-dialog__desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.saved-sessions-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.saved-sessions-dialog__load,
|
||||
.saved-sessions-dialog__delete {
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.saved-sessions-dialog__load {
|
||||
background: rgba(26, 95, 96, 0.85);
|
||||
color: #ecfdf5;
|
||||
border-color: rgba(118, 200, 177, 0.35);
|
||||
}
|
||||
.saved-sessions-dialog__delete {
|
||||
background: transparent;
|
||||
color: #fca5a5;
|
||||
border-color: rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
.saved-sessions-dialog__footer {
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid #334155;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.saved-sessions-dialog__footer-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #475569;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
color: #e2e8f0;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import {
|
||||
getSegmentationLegend,
|
||||
rgbToCss,
|
||||
SEGMENTATION_LEGEND_NOTES,
|
||||
type SegmentationAngleType,
|
||||
type SegmentationLegendEntry,
|
||||
type SegmentationLegendNote,
|
||||
type SegmentationLegendNoteIndicator,
|
||||
} from '../../data/segmentationLegend';
|
||||
|
||||
interface SegmentationLegendBubbleProps {
|
||||
visible: boolean;
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
angleType?: SegmentationAngleType;
|
||||
entries?: SegmentationLegendEntry[];
|
||||
showMeasurementNote?: boolean;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Layout {
|
||||
position: Point;
|
||||
size: Size;
|
||||
}
|
||||
|
||||
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
const EDGE_PADDING = 12;
|
||||
const DEFAULT_SIZE: Size = { width: 240, height: 340 };
|
||||
const MIN_SIZE: Size = { width: 200, height: 220 };
|
||||
const HANDLE_HIT = 14;
|
||||
const CORNERS: Corner[] = ['nw', 'ne', 'sw', 'se'];
|
||||
|
||||
function clampSize(size: Size, container: HTMLElement): Size {
|
||||
const maxWidth = Math.max(MIN_SIZE.width, container.clientWidth - EDGE_PADDING * 2);
|
||||
const maxHeight = Math.max(MIN_SIZE.height, container.clientHeight - EDGE_PADDING * 2);
|
||||
|
||||
return {
|
||||
width: Math.min(Math.max(size.width, MIN_SIZE.width), maxWidth),
|
||||
height: Math.min(Math.max(size.height, MIN_SIZE.height), maxHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function clampLayout(layout: Layout, container: HTMLElement): Layout {
|
||||
const size = clampSize(layout.size, container);
|
||||
const maxX = Math.max(0, container.clientWidth - size.width);
|
||||
const maxY = Math.max(0, container.clientHeight - size.height);
|
||||
|
||||
return {
|
||||
size,
|
||||
position: {
|
||||
x: Math.min(Math.max(0, layout.position.x), maxX),
|
||||
y: Math.min(Math.max(0, layout.position.y), maxY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultLayout(container: HTMLElement): Layout {
|
||||
const size = clampSize(DEFAULT_SIZE, container);
|
||||
return clampLayout(
|
||||
{
|
||||
position: {
|
||||
x: container.clientWidth - size.width - EDGE_PADDING,
|
||||
y: EDGE_PADDING,
|
||||
},
|
||||
size,
|
||||
},
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
function NoteIndicator({ kind }: { kind: SegmentationLegendNoteIndicator }) {
|
||||
return (
|
||||
<span className={`seg-legend-bubble__note-indicator seg-legend-bubble__note-indicator--${kind}`} aria-hidden />
|
||||
);
|
||||
}
|
||||
|
||||
function LegendNote({ note }: { note: SegmentationLegendNote }) {
|
||||
return (
|
||||
<p className="seg-legend-bubble__note">
|
||||
{note.indicator && <NoteIndicator kind={note.indicator} />}
|
||||
<span className="seg-legend-bubble__note-copy">
|
||||
<span className="seg-legend-bubble__note-vi">{note.textVi}</span>
|
||||
<span className="seg-legend-bubble__note-en">{note.textEn}</span>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SegmentationLegendBubble({
|
||||
visible,
|
||||
containerRef,
|
||||
angleType = 'sup',
|
||||
entries,
|
||||
showMeasurementNote = false,
|
||||
}: SegmentationLegendBubbleProps) {
|
||||
const bubbleRef = useRef<HTMLElement>(null);
|
||||
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
|
||||
const resizeSessionRef = useRef<{
|
||||
corner: Corner;
|
||||
pointerId: number;
|
||||
startPointer: Point;
|
||||
startLayout: Layout;
|
||||
} | null>(null);
|
||||
|
||||
const [layout, setLayout] = useState<Layout | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const legendItems = entries ?? getSegmentationLegend(angleType);
|
||||
|
||||
const matchesAngle = (note: (typeof SEGMENTATION_LEGEND_NOTES)[number]) =>
|
||||
!note.supOnly || angleType === 'sup';
|
||||
|
||||
const generalNotes = SEGMENTATION_LEGEND_NOTES.filter(
|
||||
(note) => matchesAngle(note) && !note.measurementOnly,
|
||||
);
|
||||
const measurementNotes = showMeasurementNote
|
||||
? SEGMENTATION_LEGEND_NOTES.filter((note) => matchesAngle(note) && note.measurementOnly)
|
||||
: [];
|
||||
const notesCount = generalNotes.length + measurementNotes.length;
|
||||
|
||||
const syncLayout = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
setLayout((current) => {
|
||||
const next = clampLayout(current ?? defaultLayout(container), container);
|
||||
return next;
|
||||
});
|
||||
setIsReady(true);
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setIsReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => syncLayout());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [visible, syncLayout, legendItems.length, notesCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleResize = () => syncLayout();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [visible, syncLayout]);
|
||||
|
||||
const handleDragPointerDown = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const container = containerRef.current;
|
||||
const bubble = bubbleRef.current;
|
||||
if (!container || !bubble || !layout || event.button !== 0 || isResizing) return;
|
||||
|
||||
const bubbleRect = bubble.getBoundingClientRect();
|
||||
dragOffsetRef.current = {
|
||||
x: event.clientX - bubbleRect.left,
|
||||
y: event.clientY - bubbleRect.top,
|
||||
};
|
||||
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDragPointerMove = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
setLayout((current) => {
|
||||
if (!current) return current;
|
||||
return clampLayout(
|
||||
{
|
||||
...current,
|
||||
position: {
|
||||
x: event.clientX - containerRect.left - dragOffsetRef.current.x,
|
||||
y: event.clientY - containerRect.top - dragOffsetRef.current.y,
|
||||
},
|
||||
},
|
||||
container,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const endDrag = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizePointerDown = (corner: Corner) => (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!layout || event.button !== 0) return;
|
||||
|
||||
resizeSessionRef.current = {
|
||||
corner,
|
||||
pointerId: event.pointerId,
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startLayout: layout,
|
||||
};
|
||||
|
||||
setIsResizing(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const session = resizeSessionRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!session || !container || event.pointerId !== session.pointerId) return;
|
||||
|
||||
const deltaX = event.clientX - session.startPointer.x;
|
||||
const deltaY = event.clientY - session.startPointer.y;
|
||||
const { startLayout } = session;
|
||||
|
||||
let nextLayout: Layout = {
|
||||
position: { ...startLayout.position },
|
||||
size: { ...startLayout.size },
|
||||
};
|
||||
|
||||
switch (session.corner) {
|
||||
case 'se':
|
||||
nextLayout.size.width = startLayout.size.width + deltaX;
|
||||
nextLayout.size.height = startLayout.size.height + deltaY;
|
||||
break;
|
||||
case 'sw':
|
||||
nextLayout.size.width = startLayout.size.width - deltaX;
|
||||
nextLayout.size.height = startLayout.size.height + deltaY;
|
||||
nextLayout.position.x = startLayout.position.x + deltaX;
|
||||
break;
|
||||
case 'ne':
|
||||
nextLayout.size.width = startLayout.size.width + deltaX;
|
||||
nextLayout.size.height = startLayout.size.height - deltaY;
|
||||
nextLayout.position.y = startLayout.position.y + deltaY;
|
||||
break;
|
||||
case 'nw':
|
||||
nextLayout.size.width = startLayout.size.width - deltaX;
|
||||
nextLayout.size.height = startLayout.size.height - deltaY;
|
||||
nextLayout.position.x = startLayout.position.x + deltaX;
|
||||
nextLayout.position.y = startLayout.position.y + deltaY;
|
||||
break;
|
||||
}
|
||||
|
||||
setLayout(clampLayout(nextLayout, container));
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
const session = resizeSessionRef.current;
|
||||
if (!session || event.pointerId !== session.pointerId) return;
|
||||
|
||||
resizeSessionRef.current = null;
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
}, [containerRef, isResizing]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={bubbleRef}
|
||||
className={`seg-legend-bubble glass-elevated${isDragging ? ' seg-legend-bubble--dragging' : ''}${isResizing ? ' seg-legend-bubble--resizing' : ''}${isReady ? ' seg-legend-bubble--ready' : ''}`}
|
||||
style={
|
||||
layout
|
||||
? {
|
||||
left: `${layout.position.x}px`,
|
||||
top: `${layout.position.y}px`,
|
||||
width: `${layout.size.width}px`,
|
||||
height: `${layout.size.height}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-label="Chú thích phân đoạn"
|
||||
>
|
||||
{CORNERS.map((corner) => (
|
||||
<button
|
||||
key={corner}
|
||||
type="button"
|
||||
className={`seg-legend-bubble__resize-handle seg-legend-bubble__resize-handle--${corner}`}
|
||||
aria-label={`Đổi kích thước góc ${corner}`}
|
||||
onPointerDown={handleResizePointerDown(corner)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<header
|
||||
className="seg-legend-bubble__header"
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
aria-grabbed={isDragging}
|
||||
>
|
||||
<span className="seg-legend-bubble__drag-handle" aria-hidden>
|
||||
⋮⋮
|
||||
</span>
|
||||
<div className="seg-legend-bubble__header-copy">
|
||||
<span className="seg-legend-bubble__eyebrow">Phân đoạn AI</span>
|
||||
<h3 className="seg-legend-bubble__title">Chú thích màu</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="seg-legend-bubble__body">
|
||||
<ul className="seg-legend-bubble__list" role="list">
|
||||
{legendItems.map((item) => (
|
||||
<li key={item.key} className="seg-legend-bubble__item">
|
||||
<span
|
||||
className={`seg-legend-bubble__swatch${item.highlight ? ' seg-legend-bubble__swatch--highlight' : ''}`}
|
||||
style={{ backgroundColor: rgbToCss(item.color) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="seg-legend-bubble__copy">
|
||||
<span className="seg-legend-bubble__label-vi">{item.nameVi}</span>
|
||||
<span className="seg-legend-bubble__label-en">{item.nameEn}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="seg-legend-bubble__notes">
|
||||
{generalNotes.map((note) => (
|
||||
<LegendNote key={note.id} note={note} />
|
||||
))}
|
||||
{measurementNotes.length > 0 && (
|
||||
<>
|
||||
<div className="seg-legend-bubble__notes-divider" role="separator" aria-hidden />
|
||||
{measurementNotes.map((note) => (
|
||||
<LegendNote key={note.id} note={note} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.seg-legend-bubble {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.seg-legend-bubble--ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.seg-legend-bubble--dragging,
|
||||
.seg-legend-bubble--resizing {
|
||||
box-shadow: 0 20px 48px rgba(77, 141, 145, 0.2);
|
||||
}
|
||||
|
||||
.seg-legend-bubble--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle {
|
||||
position: absolute;
|
||||
width: ${HANDLE_HIT}px;
|
||||
height: ${HANDLE_HIT}px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.seg-legend-bubble:hover .seg-legend-bubble__resize-handle,
|
||||
.seg-legend-bubble--resizing .seg-legend-bubble__resize-handle,
|
||||
.seg-legend-bubble__resize-handle:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid var(--color-secondary);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 1px 4px rgba(23, 29, 28, 0.18);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--nw {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--nw::after {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--ne {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--ne::after {
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--sw {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--sw::after {
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--se {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__resize-handle--se::after {
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--color-outline-variant);
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.seg-legend-bubble--dragging .seg-legend-bubble__header {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__drag-handle {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
letter-spacing: -2px;
|
||||
color: var(--color-outline);
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__header-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__eyebrow {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
touch-action: pan-y;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(35, 104, 108, 0.42) transparent;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__body::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(35, 104, 108, 0.38);
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__body::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(35, 104, 108, 0.58);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 4px 0 0;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border: 1px solid rgba(23, 29, 28, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__swatch--highlight {
|
||||
box-shadow:
|
||||
0 0 0 2px #fff,
|
||||
0 0 0 3px rgba(23, 29, 28, 0.25);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__label-vi,
|
||||
.seg-legend-bubble__label-en,
|
||||
.seg-legend-bubble__note,
|
||||
.seg-legend-bubble__note-en {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__label-vi {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__label-en {
|
||||
font-size: 11px;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__notes {
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid rgba(23, 29, 28, 0.14);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__notes-divider {
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
background: rgba(23, 29, 28, 0.16);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-on-surface);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--bbox-green {
|
||||
border: 2px solid rgb(0, 255, 0);
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--outline-white {
|
||||
border: 3px solid rgb(255, 255, 255);
|
||||
background: rgba(255, 0, 0, 0.35);
|
||||
box-shadow: 0 0 0 1px rgba(23, 29, 28, 0.2);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--vline-cyan,
|
||||
.seg-legend-bubble__note-indicator--vline-red {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--vline-cyan::before,
|
||||
.seg-legend-bubble__note-indicator--vline-cyan::after,
|
||||
.seg-legend-bubble__note-indicator--vline-red::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--vline-cyan::before {
|
||||
left: 3px;
|
||||
background: rgb(0, 255, 255);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--vline-cyan::after {
|
||||
right: 3px;
|
||||
background: rgb(0, 255, 255);
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-indicator--vline-red::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgb(255, 0, 0);
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-vi {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.seg-legend-bubble__note-en {
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.35;
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
interface SegmentationOverlayProps {
|
||||
overlaySrc: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function SegmentationOverlay({ overlaySrc, isLoading }: SegmentationOverlayProps) {
|
||||
if (isLoading && !overlaySrc) {
|
||||
return (
|
||||
<div
|
||||
className="segmentation-overlay segmentation-overlay--loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Đang phân đoạn AI"
|
||||
>
|
||||
<div className="segmentation-overlay__loading-card">
|
||||
<span className="segmentation-overlay__spinner" aria-hidden />
|
||||
<p className="segmentation-overlay__loading-title">Đang phân đoạn AI…</p>
|
||||
<p className="segmentation-overlay__loading-subtitle">AI segmentation in progress…</p>
|
||||
</div>
|
||||
<style>{`
|
||||
.segmentation-overlay--loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(10, 15, 14, 0.58);
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.segmentation-overlay__loading-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(92%, 360px);
|
||||
padding: 20px 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(173, 238, 242, 0.35);
|
||||
background: rgba(23, 29, 28, 0.88);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.segmentation-overlay__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid rgba(173, 238, 242, 0.28);
|
||||
border-top-color: var(--color-secondary-container);
|
||||
border-radius: 50%;
|
||||
animation: segmentation-overlay-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.segmentation-overlay__loading-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.01em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.segmentation-overlay__loading-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
@keyframes segmentation-overlay-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!overlaySrc) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={overlaySrc}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="diagnostic-canvas__overlay diagnostic-canvas__overlay--mask"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface SessionCancelRecordDialogProps {
|
||||
open: boolean;
|
||||
onDismiss: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function SessionCancelRecordDialog({
|
||||
open,
|
||||
onDismiss,
|
||||
onConfirm,
|
||||
}: SessionCancelRecordDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="session-cancel-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-cancel-title"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<div className="session-cancel-dialog__card" onClick={(event) => event.stopPropagation()}>
|
||||
<h3 id="session-cancel-title" className="session-cancel-dialog__title">
|
||||
Hủy phiên ghi?
|
||||
</h3>
|
||||
<p className="session-cancel-dialog__caption">
|
||||
Phiên ghi hiện tại chưa được lưu sẽ bị xóa. Ảnh DICOM đã tải lên vẫn được giữ lại.
|
||||
</p>
|
||||
<div className="session-cancel-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="session-cancel-dialog__btn session-cancel-dialog__btn--dismiss"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Quay lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="session-cancel-dialog__btn session-cancel-dialog__btn--confirm"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Hủy ghi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.session-cancel-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-cancel-dialog__card {
|
||||
width: min(420px, 100%);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: #1e293b;
|
||||
padding: 22px 20px 18px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
|
||||
cursor: default;
|
||||
}
|
||||
.session-cancel-dialog__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.session-cancel-dialog__caption {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.session-cancel-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.session-cancel-dialog__btn {
|
||||
border-radius: 10px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-cancel-dialog__btn--dismiss {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.session-cancel-dialog__btn--dismiss:hover {
|
||||
background: rgba(51, 65, 85, 0.65);
|
||||
}
|
||||
.session-cancel-dialog__btn--confirm {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
.session-cancel-dialog__btn--confirm:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
`}</style>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { SessionSaveUiState } from '../../lib/sessionLifecycle';
|
||||
|
||||
interface SessionLifecycleToolbarProps {
|
||||
status: 'idle' | 'recording' | 'paused' | 'ended';
|
||||
saveState: SessionSaveUiState;
|
||||
saveName: string;
|
||||
savedSessionCount: number;
|
||||
onStart: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onEnd: () => void;
|
||||
onReset: () => void;
|
||||
onNewSession: () => void;
|
||||
canStartNewSession: boolean;
|
||||
onSaveClick: () => void;
|
||||
onSaveNameChange: (value: string) => void;
|
||||
onConfirmSave: () => void;
|
||||
onCancelSave: () => void;
|
||||
onCancelRecord: () => void;
|
||||
canCancelRecord: boolean;
|
||||
onOpenSessionsList: () => void;
|
||||
}
|
||||
|
||||
function RecordDotIcon({ pulsing }: { pulsing: boolean }) {
|
||||
return <span className={`lifecycle-toolbar__rec-dot${pulsing ? ' lifecycle-toolbar__rec-dot--pulse' : ''}`} aria-hidden />;
|
||||
}
|
||||
|
||||
function PauseBarsIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StopIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NewSessionIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||
<path d="M3 4v5h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelRecordIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8 8l8 8M16 8l-8 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ListIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionLifecycleToolbar({
|
||||
status,
|
||||
saveState,
|
||||
saveName,
|
||||
savedSessionCount,
|
||||
onStart,
|
||||
onPause,
|
||||
onResume,
|
||||
onEnd,
|
||||
onReset,
|
||||
onNewSession,
|
||||
canStartNewSession,
|
||||
onSaveClick,
|
||||
onSaveNameChange,
|
||||
onConfirmSave,
|
||||
onCancelSave,
|
||||
onCancelRecord,
|
||||
canCancelRecord,
|
||||
onOpenSessionsList,
|
||||
}: SessionLifecycleToolbarProps) {
|
||||
const isRecording = status === 'recording';
|
||||
const isPaused = status === 'paused';
|
||||
const isEnded = status === 'ended';
|
||||
const canEnd = isRecording || isPaused;
|
||||
const canConfirmSave = saveName.trim().length > 0;
|
||||
|
||||
const handlePrimaryRecordClick = () => {
|
||||
if (isPaused || isEnded) {
|
||||
onResume();
|
||||
return;
|
||||
}
|
||||
onStart();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lifecycle-toolbar-wrap">
|
||||
<div className="lifecycle-toolbar" role="toolbar" aria-label="Điều khiển vòng đời phiên ghi">
|
||||
{isPaused ? (
|
||||
<button type="button" className="lifecycle-toolbar__btn lifecycle-toolbar__btn--resume" onClick={onResume}>
|
||||
<ResumeIcon />
|
||||
<span>Tiếp tục ghi</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`lifecycle-toolbar__btn lifecycle-toolbar__btn--record${isRecording ? ' lifecycle-toolbar__btn--recording' : ''}`}
|
||||
onClick={handlePrimaryRecordClick}
|
||||
disabled={isRecording}
|
||||
aria-pressed={isRecording}
|
||||
>
|
||||
<RecordDotIcon pulsing={isRecording} />
|
||||
<span>{isRecording ? 'Đang ghi...' : 'Ghi'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="lifecycle-toolbar__divider" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--pause"
|
||||
onClick={onPause}
|
||||
disabled={!isRecording}
|
||||
>
|
||||
<PauseBarsIcon />
|
||||
<span>Tạm dừng</span>
|
||||
</button>
|
||||
|
||||
<span className="lifecycle-toolbar__divider" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--end"
|
||||
onClick={onEnd}
|
||||
disabled={!canEnd}
|
||||
>
|
||||
<StopIcon />
|
||||
<span>Kết thúc ghi</span>
|
||||
</button>
|
||||
|
||||
<span className="lifecycle-toolbar__divider" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--new"
|
||||
onClick={onNewSession}
|
||||
disabled={!canStartNewSession}
|
||||
title="Tạo phiên ghi trống mới — nhấn Ghi khi sẵn sàng"
|
||||
>
|
||||
<NewSessionIcon />
|
||||
<span>Phiên mới</span>
|
||||
</button>
|
||||
|
||||
<span className="lifecycle-toolbar__divider" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--reset"
|
||||
onClick={onReset}
|
||||
title="Xóa phiên hiện tại và ghi lại từ đầu"
|
||||
>
|
||||
<ResetIcon />
|
||||
<span>Làm mới từ đầu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lifecycle-toolbar__save-row">
|
||||
<div className="lifecycle-toolbar__save-group">
|
||||
{saveState === 'confirm' ? (
|
||||
<>
|
||||
<label className="lifecycle-toolbar__save-label">
|
||||
<span className="sr-only">Tên phiên ghi</span>
|
||||
<input
|
||||
type="text"
|
||||
className="lifecycle-toolbar__save-input"
|
||||
value={saveName}
|
||||
placeholder="Đặt tên phiên ghi…"
|
||||
onChange={(event) => onSaveNameChange(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--save lifecycle-toolbar__btn--save-confirm"
|
||||
onClick={onConfirmSave}
|
||||
disabled={!canConfirmSave}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Xác nhận lưu</span>
|
||||
</button>
|
||||
<button type="button" className="lifecycle-toolbar__save-cancel" onClick={onCancelSave}>
|
||||
Hủy
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`lifecycle-toolbar__btn lifecycle-toolbar__btn--save lifecycle-toolbar__btn--save-${saveState}`}
|
||||
onClick={onSaveClick}
|
||||
disabled={saveState !== 'savable'}
|
||||
aria-disabled={saveState !== 'savable'}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>Lưu phiên</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--cancel-record"
|
||||
onClick={onCancelRecord}
|
||||
disabled={!canCancelRecord}
|
||||
title="Hủy phiên ghi hiện tại, không lưu"
|
||||
>
|
||||
<CancelRecordIcon />
|
||||
<span>Hủy ghi</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--sessions"
|
||||
onClick={onOpenSessionsList}
|
||||
>
|
||||
<ListIcon />
|
||||
<span>Danh sách phiên ({savedSessionCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.lifecycle-toolbar-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.lifecycle-toolbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
.lifecycle-toolbar__save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lifecycle-toolbar__save-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.lifecycle-toolbar__save-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.lifecycle-toolbar__save-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(118, 200, 177, 0.45);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #f8fafc;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lifecycle-toolbar__save-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
.lifecycle-toolbar__save-cancel {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 6px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lifecycle-toolbar__divider {
|
||||
width: 1px;
|
||||
background: #334155;
|
||||
margin: 4px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lifecycle-toolbar__btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #e2e8f0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lifecycle-toolbar__btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lifecycle-toolbar__btn--record {
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
}
|
||||
.lifecycle-toolbar__btn--recording {
|
||||
border-color: rgba(220, 38, 38, 0.45);
|
||||
background: rgba(30, 23, 23, 0.85);
|
||||
}
|
||||
.lifecycle-toolbar__btn--resume {
|
||||
border-color: rgba(118, 200, 177, 0.35);
|
||||
color: #76c8b1;
|
||||
}
|
||||
.lifecycle-toolbar__btn--pause:not(:disabled):hover {
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
.lifecycle-toolbar__btn--end:not(:disabled) {
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
}
|
||||
.lifecycle-toolbar__btn--end:not(:disabled):hover {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
background: rgba(69, 10, 10, 0.35);
|
||||
}
|
||||
.lifecycle-toolbar__btn--new {
|
||||
border-color: rgba(118, 200, 177, 0.4);
|
||||
color: #a7f3d0;
|
||||
background: rgba(15, 45, 48, 0.35);
|
||||
}
|
||||
.lifecycle-toolbar__btn--new:not(:disabled):hover {
|
||||
border-color: rgba(118, 200, 177, 0.65);
|
||||
background: rgba(26, 95, 96, 0.45);
|
||||
color: #ecfdf5;
|
||||
}
|
||||
.lifecycle-toolbar__btn--reset {
|
||||
border-color: rgba(251, 191, 36, 0.5);
|
||||
color: #fcd34d;
|
||||
background: rgba(146, 64, 14, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
.lifecycle-toolbar__btn--reset:hover {
|
||||
border-color: rgba(251, 191, 36, 0.75);
|
||||
background: rgba(146, 64, 14, 0.34);
|
||||
color: #fef08a;
|
||||
}
|
||||
.lifecycle-toolbar__btn--reset svg {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.lifecycle-toolbar__btn--save {
|
||||
flex: 0 1 auto;
|
||||
min-width: 108px;
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
.lifecycle-toolbar__btn--save-savable {
|
||||
border-color: rgba(118, 200, 177, 0.55);
|
||||
color: #76c8b1;
|
||||
background: rgba(26, 95, 96, 0.35);
|
||||
}
|
||||
.lifecycle-toolbar__btn--save-savable:not(:disabled):hover {
|
||||
background: rgba(26, 95, 96, 0.55);
|
||||
color: #ecfdf5;
|
||||
}
|
||||
.lifecycle-toolbar__btn--save-confirm {
|
||||
flex: 0 0 auto;
|
||||
border-color: rgba(118, 200, 177, 0.65);
|
||||
color: #ecfdf5;
|
||||
background: rgba(26, 95, 96, 0.9);
|
||||
}
|
||||
.lifecycle-toolbar__btn--cancel-record {
|
||||
flex: 0 1 auto;
|
||||
min-width: 96px;
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
background: rgba(69, 10, 10, 0.22);
|
||||
}
|
||||
.lifecycle-toolbar__btn--cancel-record:not(:disabled):hover {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
background: rgba(127, 29, 29, 0.38);
|
||||
color: #fee2e2;
|
||||
}
|
||||
.lifecycle-toolbar__btn--sessions {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.lifecycle-toolbar__btn--sessions:hover {
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
}
|
||||
.lifecycle-toolbar__rec-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lifecycle-toolbar__rec-dot--pulse {
|
||||
animation: lifecycle-rec-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes lifecycle-rec-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface SessionNewSessionDialogProps {
|
||||
open: boolean;
|
||||
onDismiss: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function SessionNewSessionDialog({
|
||||
open,
|
||||
onDismiss,
|
||||
onConfirm,
|
||||
}: SessionNewSessionDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="session-new-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-new-title"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<div className="session-new-dialog__card" onClick={(event) => event.stopPropagation()}>
|
||||
<h3 id="session-new-title" className="session-new-dialog__title">
|
||||
Tạo phiên ghi mới?
|
||||
</h3>
|
||||
<p className="session-new-dialog__caption">
|
||||
Canvas và nhật ký phiên hiện tại sẽ được xóa. Phiên mới ở trạng thái chờ — nhấn{' '}
|
||||
<strong>Ghi</strong> khi sẵn sàng bắt đầu. Ảnh DICOM vẫn được giữ lại.
|
||||
</p>
|
||||
<div className="session-new-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="session-new-dialog__btn session-new-dialog__btn--dismiss"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Quay lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="session-new-dialog__btn session-new-dialog__btn--confirm"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Phiên mới
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.session-new-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-new-dialog__card {
|
||||
width: min(440px, 100%);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: #1e293b;
|
||||
padding: 22px 20px 18px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
|
||||
cursor: default;
|
||||
}
|
||||
.session-new-dialog__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.session-new-dialog__caption {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.session-new-dialog__caption strong {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.session-new-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.session-new-dialog__btn {
|
||||
border-radius: 10px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-new-dialog__btn--dismiss {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.session-new-dialog__btn--dismiss:hover {
|
||||
background: rgba(51, 65, 85, 0.65);
|
||||
}
|
||||
.session-new-dialog__btn--confirm {
|
||||
border: 1px solid rgba(118, 200, 177, 0.55);
|
||||
background: rgba(26, 95, 96, 0.85);
|
||||
color: #ecfdf5;
|
||||
}
|
||||
.session-new-dialog__btn--confirm:hover {
|
||||
background: rgba(26, 95, 96, 1);
|
||||
}
|
||||
`}</style>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface SessionResetConfirmDialogProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirmAndRecord: () => void;
|
||||
onConfirmDeleteOnly: () => void;
|
||||
}
|
||||
|
||||
export default function SessionResetConfirmDialog({
|
||||
open,
|
||||
onCancel,
|
||||
onConfirmAndRecord,
|
||||
onConfirmDeleteOnly,
|
||||
}: SessionResetConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="session-reset-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-reset-title"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="session-reset-dialog__card"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h3 id="session-reset-title" className="session-reset-dialog__title">
|
||||
Xóa toàn bộ tiến trình lịch sử?
|
||||
</h3>
|
||||
<p className="session-reset-dialog__caption">
|
||||
Hành động này sẽ xóa vĩnh viễn tất cả nhật ký thao tác và danh sách tệp ảnh DICOM đã tải lên trong
|
||||
phiên làm việc này. Chọn <strong>Xóa & Ghi lại</strong> để bắt đầu ghi ngay, hoặc{' '}
|
||||
<strong>Chỉ xóa & Dừng</strong> để xóa và dừng ghi (không tự ghi lại).
|
||||
</p>
|
||||
<div className="session-reset-dialog__actions">
|
||||
<button type="button" className="session-reset-dialog__btn session-reset-dialog__btn--cancel" onClick={onCancel}>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="session-reset-dialog__btn session-reset-dialog__btn--delete-only"
|
||||
onClick={onConfirmDeleteOnly}
|
||||
>
|
||||
Chỉ xóa & Dừng
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="session-reset-dialog__btn session-reset-dialog__btn--confirm"
|
||||
onClick={onConfirmAndRecord}
|
||||
>
|
||||
Xóa & Ghi lại
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.session-reset-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-reset-dialog__card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(460px, 100%);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: #1e293b;
|
||||
padding: 22px 20px 18px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
|
||||
cursor: default;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.session-reset-dialog__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.session-reset-dialog__caption {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.session-reset-dialog__caption strong {
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.session-reset-dialog__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.session-reset-dialog__btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 10px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.session-reset-dialog__btn--cancel {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.session-reset-dialog__btn--cancel:hover {
|
||||
background: rgba(51, 65, 85, 0.65);
|
||||
}
|
||||
.session-reset-dialog__btn--delete-only {
|
||||
border: 1px solid rgba(251, 191, 36, 0.55);
|
||||
background: rgba(146, 64, 14, 0.18);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.session-reset-dialog__btn--delete-only:hover {
|
||||
background: rgba(146, 64, 14, 0.3);
|
||||
border-color: rgba(251, 191, 36, 0.75);
|
||||
}
|
||||
.session-reset-dialog__btn--confirm {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
.session-reset-dialog__btn--confirm:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
`}</style>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo } from 'react';
|
||||
import { GRADE_LABELS } from '../../data/mockData';
|
||||
import type { SynovitisSeverityResult } from '../../data/synovitisSeverity';
|
||||
import {
|
||||
INFLAMMATION_CLASSES,
|
||||
INFLAMMATION_LABELS,
|
||||
type InflammationClassificationResult,
|
||||
} from '../../data/inflammationClassification';
|
||||
import {
|
||||
interpretLogits,
|
||||
predictedClassFromProbabilities,
|
||||
type CalibrationUserConfig,
|
||||
} from '../../lib/calibrationEngine';
|
||||
import CalibratedDistributionList, { CalibrationStateBanner } from './CalibratedDistributionList';
|
||||
import CalibrationMetricHelp from './CalibrationMetricHelp';
|
||||
|
||||
interface SeverityBadgeProps {
|
||||
severity: SynovitisSeverityResult | null;
|
||||
severityLoading?: boolean;
|
||||
inflammation: InflammationClassificationResult | null;
|
||||
inflammationLoading?: boolean;
|
||||
userConfig: CalibrationUserConfig;
|
||||
suppressed?: boolean;
|
||||
}
|
||||
|
||||
const GRADE_COLORS: Record<number, string> = {
|
||||
0: '#76c8b1',
|
||||
1: '#5fb89e',
|
||||
2: '#e8a838',
|
||||
3: '#ba1a1a',
|
||||
};
|
||||
|
||||
const INFLAMMATION_DISTRIBUTION_ENTRIES = INFLAMMATION_CLASSES.map((key) => ({
|
||||
key,
|
||||
label: INFLAMMATION_LABELS[key],
|
||||
}));
|
||||
|
||||
export default function SeverityBadge({
|
||||
severity,
|
||||
severityLoading = false,
|
||||
inflammation,
|
||||
inflammationLoading = false,
|
||||
userConfig,
|
||||
suppressed,
|
||||
}: SeverityBadgeProps) {
|
||||
const liveCalibration = useMemo(() => {
|
||||
const raw = inflammation?.calibration?.raw_logits;
|
||||
if (!raw?.length) return inflammation?.calibration ?? null;
|
||||
return interpretLogits(raw, INFLAMMATION_CLASSES, userConfig, (key) => INFLAMMATION_LABELS[key as keyof typeof INFLAMMATION_LABELS] ?? key);
|
||||
}, [inflammation, userConfig]);
|
||||
|
||||
const predictedKey = useMemo(() => {
|
||||
if (!liveCalibration) return inflammation?.detected ? 'inflammation' : 'no_inflammation';
|
||||
return predictedClassFromProbabilities(liveCalibration.class_probabilities, INFLAMMATION_CLASSES);
|
||||
}, [liveCalibration, inflammation]);
|
||||
|
||||
if (suppressed) {
|
||||
return (
|
||||
<div className="severity-badge severity-badge--suppressed glass">
|
||||
<span className="severity-badge__label">Đề xuất AI bị ẩn</span>
|
||||
<p>Chế độ an toàn — đánh giá thủ công bắt buộc</p>
|
||||
<style>{badgeStyles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grade = severity?.level ?? null;
|
||||
const gradeLabel = severity?.label ?? (grade != null ? GRADE_LABELS[grade] : null);
|
||||
|
||||
return (
|
||||
<div className="severity-panel">
|
||||
{severityLoading ? (
|
||||
<div className="severity-panel__block glass">
|
||||
<span className="severity-panel__eyebrow">Viêm màng hoạt dịch — độ nặng (từ phân đoạn)</span>
|
||||
<p className="severity-panel__pending">Đang chờ kết quả độ nặng từ phân đoạn…</p>
|
||||
</div>
|
||||
) : grade != null && gradeLabel ? (
|
||||
<div className="severity-badge glass" style={{ borderColor: GRADE_COLORS[grade] ?? GRADE_COLORS[0] }}>
|
||||
<span className="severity-badge__grade tnum" style={{ color: GRADE_COLORS[grade] ?? GRADE_COLORS[0] }}>
|
||||
{grade}
|
||||
</span>
|
||||
<div>
|
||||
<span className="severity-badge__label">Viêm màng hoạt dịch — độ nặng (từ phân đoạn)</span>
|
||||
<strong>{gradeLabel}</strong>
|
||||
{severity?.description && (
|
||||
<p className="severity-panel__desc">{severity.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inflammationLoading ? (
|
||||
<div className="severity-panel__block glass">
|
||||
<span className="severity-panel__eyebrow">Phát hiện viêm</span>
|
||||
<p className="severity-panel__pending">Đang chờ kết quả phát hiện viêm…</p>
|
||||
</div>
|
||||
) : inflammation && liveCalibration ? (
|
||||
<div className="severity-panel__inflam glass">
|
||||
<span className="severity-panel__eyebrow">Phát hiện viêm</span>
|
||||
<CalibrationMetricHelp layout="block" />
|
||||
<strong className="severity-panel__pred">
|
||||
{INFLAMMATION_LABELS[predictedKey as keyof typeof INFLAMMATION_LABELS]}
|
||||
</strong>
|
||||
<CalibrationStateBanner calibration={liveCalibration} />
|
||||
<p className="severity-panel__risk">{liveCalibration.risk_framing_vi}</p>
|
||||
<CalibratedDistributionList
|
||||
calibration={liveCalibration}
|
||||
entries={INFLAMMATION_DISTRIBUTION_ENTRIES}
|
||||
predictedKey={predictedKey}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<style>{badgeStyles}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeStyles = `
|
||||
.severity-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.severity-panel__block {
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.severity-panel__pending {
|
||||
margin: 0;
|
||||
font-size: 0.8125em;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.severity-panel__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
}
|
||||
.severity-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
.severity-badge--suppressed {
|
||||
border-color: var(--color-error);
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.severity-badge--suppressed p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.severity-badge__grade {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
}
|
||||
.severity-badge__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.severity-badge strong {
|
||||
font-size: 18px;
|
||||
font-family: var(--font-headline);
|
||||
}
|
||||
.severity-panel__inflam {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.severity-panel__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.6875em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.severity-panel__pred {
|
||||
font-size: 0.9375em;
|
||||
font-family: var(--font-headline);
|
||||
}
|
||||
.severity-panel__risk {
|
||||
margin: 0;
|
||||
font-size: 0.8125em;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.45;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,49 @@
|
||||
interface StatusSummaryCardProps {
|
||||
label: string;
|
||||
count: number;
|
||||
variant: 'urgent' | 'processing' | 'followup';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const VARIANT_CLASS = {
|
||||
urgent: 'chip-urgent',
|
||||
processing: 'chip-processing',
|
||||
followup: 'chip-followup',
|
||||
};
|
||||
|
||||
export default function StatusSummaryCard({
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
description,
|
||||
}: StatusSummaryCardProps) {
|
||||
return (
|
||||
<div className="status-card glass">
|
||||
<span className={`chip ${VARIANT_CLASS[variant]}`}>{label}</span>
|
||||
<p className="status-card__count tnum">{count}</p>
|
||||
<p className="status-card__desc">{description}</p>
|
||||
<style>{`
|
||||
.status-card {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.status-card__count {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--color-secondary);
|
||||
margin: 4px 0 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.status-card__desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import PatientProfileModal from './PatientProfileModal';
|
||||
import type { NewPatientInput } from '../../lib/patientStore';
|
||||
|
||||
interface AddPatientProfileModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: NewPatientInput) => Promise<void>;
|
||||
}
|
||||
|
||||
/** @deprecated Use PatientProfileModal with mode="add" */
|
||||
export default function AddPatientProfileModal(props: AddPatientProfileModalProps) {
|
||||
return <PatientProfileModal mode="add" {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import {
|
||||
DIAGNOSTIC_FORM_STEP_COUNT,
|
||||
DIAGNOSTIC_FORM_STEP_TITLES,
|
||||
type DiagnosticFormDraft,
|
||||
} from '../../data/diagnosticForm';
|
||||
import DiagnosticFormStepFields, { diagnosticFormFieldStyles } from '../molecules/DiagnosticFormStepFields';
|
||||
|
||||
interface DiagnosisSignOffOverlayProps {
|
||||
open: boolean;
|
||||
draft: DiagnosticFormDraft;
|
||||
doctorName: string;
|
||||
jointLabel?: string;
|
||||
facilityName?: string;
|
||||
onDraftChange: (draft: DiagnosticFormDraft) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function stepHint(step: number): string | null {
|
||||
if (step === 0) return 'Nếu bệnh nhân lần đầu, có thể bỏ qua bước này.';
|
||||
return null;
|
||||
}
|
||||
|
||||
function DocumentIcon() {
|
||||
return (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
|
||||
<path d="M14 2v6h6M12 18v-6M9 15h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiagnosisSignOffOverlay({
|
||||
open,
|
||||
draft,
|
||||
doctorName,
|
||||
jointLabel = 'khớp gối',
|
||||
facilityName = 'Phòng khám MSK — Hệ thống Ghi nhận Lâm sàng',
|
||||
onDraftChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DiagnosisSignOffOverlayProps) {
|
||||
const titleId = useId();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setStep(0);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onCancel();
|
||||
if (event.key === 'ArrowRight' && step < DIAGNOSTIC_FORM_STEP_COUNT - 1) {
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
if (event.key === 'ArrowLeft' && step > 0) {
|
||||
setStep((s) => s - 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onCancel, step]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isFirst = step === 0;
|
||||
const isLast = step === DIAGNOSTIC_FORM_STEP_COUNT - 1;
|
||||
const heading = DIAGNOSTIC_FORM_STEP_TITLES[step] ?? '';
|
||||
const bodyScrollable = step === 2 || step === 4;
|
||||
|
||||
return (
|
||||
<div className="diag-signoff" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
className="diag-signoff__backdrop"
|
||||
aria-label="Đóng xác nhận chẩn đoán"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<div
|
||||
className="diag-signoff__modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="diag-signoff__header">
|
||||
<span className="diag-signoff__badge" aria-hidden>
|
||||
<DocumentIcon />
|
||||
</span>
|
||||
<h2 id={titleId} className="diag-signoff__title">
|
||||
Xác nhận kết quả chẩn đoán
|
||||
</h2>
|
||||
<p className="diag-signoff__facility">{facilityName}</p>
|
||||
</header>
|
||||
|
||||
<p className="diag-signoff__liability">
|
||||
Bằng việc thực hiện thao tác này, bạn đồng ý là người{' '}
|
||||
<strong className="diag-signoff__liability-em">
|
||||
chịu trách nhiệm về mặt pháp lý & y đức
|
||||
</strong>{' '}
|
||||
cho kết quả chẩn đoán này!
|
||||
</p>
|
||||
|
||||
<article className="diag-signoff__carousel-card">
|
||||
<div className="diag-signoff__carousel-head">
|
||||
<h3 className="diag-signoff__carousel-title">{heading}</h3>
|
||||
<div className="diag-signoff__carousel-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="diag-signoff__nav-btn"
|
||||
disabled={isFirst}
|
||||
onClick={() => setStep((s) => s - 1)}
|
||||
aria-label="Bước trước"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="diag-signoff__nav-btn"
|
||||
disabled={isLast}
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
aria-label="Bước sau"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stepHint(step) && <p className="diag-signoff__step-hint">{stepHint(step)}</p>}
|
||||
|
||||
<div
|
||||
className={
|
||||
bodyScrollable
|
||||
? 'diag-signoff__carousel-body diag-signoff__carousel-body--scroll'
|
||||
: 'diag-signoff__carousel-body'
|
||||
}
|
||||
>
|
||||
<DiagnosticFormStepFields
|
||||
step={step}
|
||||
draft={draft}
|
||||
jointLabel={jointLabel}
|
||||
onDraftChange={onDraftChange}
|
||||
readOnlySnapshot
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="diag-signoff__carousel-foot">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
<span>
|
||||
Ký xác nhận bởi: <strong>{doctorName}</strong>
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<div className="diag-signoff__dots" role="tablist" aria-label="Các bước xác nhận">
|
||||
{Array.from({ length: DIAGNOSTIC_FORM_STEP_COUNT }, (_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={step === index}
|
||||
aria-label={`Bước ${index + 1}`}
|
||||
className={step === index ? 'diag-signoff__dot diag-signoff__dot--active' : 'diag-signoff__dot'}
|
||||
onClick={() => setStep(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="diag-signoff__actions">
|
||||
<button type="button" className="diag-signoff__confirm btn btn-primary" onClick={onConfirm}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
Xác nhận & Cập nhật
|
||||
</button>
|
||||
<button type="button" className="diag-signoff__cancel btn btn-ghost" onClick={onCancel}>
|
||||
Hủy bỏ
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`${diagnosticFormFieldStyles}${styles}`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.diag-signoff {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.diag-signoff__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.diag-signoff__modal {
|
||||
position: relative;
|
||||
width: min(480px, 100%);
|
||||
max-height: min(92vh, 860px);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
box-shadow: 0 24px 48px rgba(10, 15, 14, 0.22);
|
||||
}
|
||||
.diag-signoff__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.diag-signoff__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
background: rgba(35, 104, 108, 0.12);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.diag-signoff__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1a2423;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.diag-signoff__facility {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.diag-signoff__liability {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-on-surface);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.diag-signoff__liability-em {
|
||||
font-weight: 800;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
color: #0a0f0e;
|
||||
}
|
||||
.diag-signoff__carousel-card {
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: 12px;
|
||||
background: rgba(245, 250, 249, 0.5);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.diag-signoff__carousel-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 14px 14px 8px;
|
||||
border-bottom: 1px solid rgba(35, 104, 108, 0.1);
|
||||
}
|
||||
.diag-signoff__carousel-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.diag-signoff__carousel-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.diag-signoff__nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--color-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.diag-signoff__nav-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.diag-signoff__step-hint {
|
||||
margin: 0;
|
||||
padding: 0 14px 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-style: italic;
|
||||
}
|
||||
.diag-signoff__carousel-body {
|
||||
padding: 14px;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.diag-signoff__carousel-body--scroll {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.diag-signoff__carousel-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid rgba(35, 104, 108, 0.1);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.diag-signoff__carousel-foot strong {
|
||||
color: var(--color-on-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
.diag-signoff__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.diag-signoff__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(35, 104, 108, 0.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
.diag-signoff__dot--active {
|
||||
width: 22px;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
.diag-signoff__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.diag-signoff__confirm {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.diag-signoff__cancel {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: #fff;
|
||||
color: var(--color-on-surface);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AngleClassificationResult } from '../../data/angleClassification';
|
||||
import type { InflammationClassificationResult } from '../../data/inflammationClassification';
|
||||
import type { SynovitisSeverityResult } from '../../data/synovitisSeverity';
|
||||
import { angleSupportsThicknessMeasurement } from '../../data/angleClassification';
|
||||
import { SCAN_FRAMES, type ScanFrame } from '../../data/scanFrames';
|
||||
import { getScanFramesForPatient } from '../../data/patientScanProfiles.generated';
|
||||
import { useImageCanvasTools } from '../../hooks/useImageCanvasTools';
|
||||
import { useDicomFrameRecording } from '../../hooks/useDicomFrameRecording';
|
||||
import { useSegmentationOverlay } from '../../hooks/useSegmentationOverlay';
|
||||
import { useSessionLifecycle } from '../../lib/sessionLifecycle';
|
||||
import type { FrameOverlaySnapshot } from '../../lib/sessionRecordingTypes';
|
||||
import AnnotationRibbon from '../molecules/AnnotationRibbon';
|
||||
import ClosedLoopPrompt from '../molecules/ClosedLoopPrompt';
|
||||
import SegmentationLegendBubble from '../molecules/SegmentationLegendBubble';
|
||||
import SegmentationOverlay from '../molecules/SegmentationOverlay';
|
||||
import MlServiceErrorPanel from '../molecules/MlServiceErrorPanel';
|
||||
|
||||
interface DiagnosticCanvasProps {
|
||||
showMask: boolean;
|
||||
onToggleMask: () => void;
|
||||
showGradcam?: boolean;
|
||||
onToggleGradcam?: () => void;
|
||||
dimmed?: boolean;
|
||||
frameIndex?: number;
|
||||
onFrameIndexChange?: (index: number) => void;
|
||||
onAngleClassificationChange?: (result: AngleClassificationResult | null) => void;
|
||||
onInflammationClassificationChange?: (result: InflammationClassificationResult | null) => void;
|
||||
onSynovitisSeverityChange?: (result: SynovitisSeverityResult | null) => void;
|
||||
onSegmentationLoadingChange?: (loading: boolean) => void;
|
||||
onRegisterSnapshotCapture?: (capture: () => Promise<string | null>) => void;
|
||||
onReplayViewport?: (viewport: { showMask: boolean; showGradcam: boolean }) => void;
|
||||
segmentOverlayReplay?: FrameOverlaySnapshot | null;
|
||||
patientMrn?: string;
|
||||
patientId?: string;
|
||||
scanFrames?: ScanFrame[];
|
||||
}
|
||||
|
||||
export default function DiagnosticCanvas({
|
||||
showMask,
|
||||
onToggleMask,
|
||||
showGradcam = false,
|
||||
onToggleGradcam,
|
||||
dimmed,
|
||||
frameIndex: frameIndexProp,
|
||||
onFrameIndexChange,
|
||||
onAngleClassificationChange,
|
||||
onInflammationClassificationChange,
|
||||
onSynovitisSeverityChange,
|
||||
onSegmentationLoadingChange,
|
||||
onRegisterSnapshotCapture,
|
||||
onReplayViewport,
|
||||
segmentOverlayReplay,
|
||||
patientMrn,
|
||||
patientId,
|
||||
scanFrames: scanFramesProp,
|
||||
}: DiagnosticCanvasProps) {
|
||||
const activeFrames =
|
||||
(scanFramesProp && scanFramesProp.length > 0)
|
||||
? scanFramesProp
|
||||
: patientId
|
||||
? getScanFramesForPatient(patientId)
|
||||
: SCAN_FRAMES;
|
||||
const framesForCanvas = activeFrames.length > 0 ? activeFrames : SCAN_FRAMES;
|
||||
|
||||
const [internalFrameIndex, setInternalFrameIndex] = useState(0);
|
||||
const frameIndex = frameIndexProp ?? internalFrameIndex;
|
||||
|
||||
const {
|
||||
handleFrameSwitch,
|
||||
setRecordingContext,
|
||||
isSingleFrameNavLocked,
|
||||
activeSegmentLabel,
|
||||
replayFrameRestore,
|
||||
clearReplayFrameRestore,
|
||||
draftToastMessage,
|
||||
} = useSessionLifecycle();
|
||||
const [frameNavNotice, setFrameNavNotice] = useState<string | null>(null);
|
||||
const lockedFrameIndexRef = useRef<number | null>(null);
|
||||
const frameIndexRef = useRef(frameIndex);
|
||||
frameIndexRef.current = frameIndex;
|
||||
|
||||
const applyFrameIndex = useCallback(
|
||||
(resolved: number) => {
|
||||
if (onFrameIndexChange) {
|
||||
onFrameIndexChange(resolved);
|
||||
} else {
|
||||
setInternalFrameIndex(resolved);
|
||||
}
|
||||
},
|
||||
[onFrameIndexChange],
|
||||
);
|
||||
|
||||
const setFrameIndex = useCallback(
|
||||
(next: number | ((prev: number) => number)) => {
|
||||
const current = frameIndexRef.current;
|
||||
const resolved = typeof next === 'function' ? next(current) : next;
|
||||
if (
|
||||
isSingleFrameNavLocked &&
|
||||
lockedFrameIndexRef.current != null &&
|
||||
resolved !== lockedFrameIndexRef.current
|
||||
) {
|
||||
setFrameNavNotice('Đang ghi một khung — chọn Nhiều khung để ghi khi đổi khung');
|
||||
return;
|
||||
}
|
||||
applyFrameIndex(resolved);
|
||||
},
|
||||
[applyFrameIndex, isSingleFrameNavLocked],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSingleFrameNavLocked) {
|
||||
if (lockedFrameIndexRef.current === null) {
|
||||
lockedFrameIndexRef.current = frameIndex;
|
||||
}
|
||||
return;
|
||||
}
|
||||
lockedFrameIndexRef.current = null;
|
||||
}, [frameIndex, isSingleFrameNavLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSingleFrameNavLocked || lockedFrameIndexRef.current === null) return;
|
||||
if (frameIndex !== lockedFrameIndexRef.current) {
|
||||
applyFrameIndex(lockedFrameIndexRef.current);
|
||||
}
|
||||
}, [applyFrameIndex, frameIndex, isSingleFrameNavLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameNavNotice) return;
|
||||
const timer = window.setTimeout(() => setFrameNavNotice(null), 3500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [frameNavNotice]);
|
||||
|
||||
const frame = framesForCanvas[frameIndex] ?? framesForCanvas[0]!;
|
||||
const frameLabel = `Khung ${frameIndex + 1} / ${framesForCanvas.length} · ${frame.label}`;
|
||||
const frameRef = useMemo(
|
||||
() => ({
|
||||
frameId: frame.id,
|
||||
frameIndex,
|
||||
frameLabel,
|
||||
imageSrc: frame.src,
|
||||
}),
|
||||
[frame.id, frameIndex, frameLabel, frame.src],
|
||||
);
|
||||
|
||||
const lockFrameNav = isSingleFrameNavLocked;
|
||||
|
||||
const { overlaySrc, interpretation, angleClassification, inflammationClassification, synovitisSeverity, isLoading, isSegmentationLoading, error, source, retry } =
|
||||
useSegmentationOverlay(frame.id, frame.src, framesForCanvas, patientMrn);
|
||||
|
||||
useEffect(() => {
|
||||
applyFrameIndex(0);
|
||||
}, [patientId, framesForCanvas.length, applyFrameIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onAngleClassificationChange?.(angleClassification);
|
||||
}, [angleClassification, onAngleClassificationChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onInflammationClassificationChange?.(inflammationClassification);
|
||||
}, [inflammationClassification, onInflammationClassificationChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSynovitisSeverityChange?.(synovitisSeverity);
|
||||
}, [synovitisSeverity, onSynovitisSeverityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSegmentationLoadingChange?.(isSegmentationLoading);
|
||||
}, [isSegmentationLoading, onSegmentationLoadingChange]);
|
||||
|
||||
const goPrev = () =>
|
||||
setFrameIndex((current) => (current > 0 ? current - 1 : framesForCanvas.length - 1));
|
||||
const goNext = () =>
|
||||
setFrameIndex((current) => (current < framesForCanvas.length - 1 ? current + 1 : 0));
|
||||
|
||||
const thicknessMm = interpretation?.measurement?.thickness_mm;
|
||||
const showMeasurementLegendNotes = showMask && interpretation?.angleType === 'sup';
|
||||
const showThicknessHud =
|
||||
showMask &&
|
||||
angleClassification != null &&
|
||||
angleSupportsThicknessMeasurement(angleClassification.class) &&
|
||||
thicknessMm != null;
|
||||
|
||||
const {
|
||||
canvasRef,
|
||||
viewportRef,
|
||||
stageRef,
|
||||
tool,
|
||||
setTool,
|
||||
inkColor,
|
||||
setInkColor,
|
||||
zoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetView,
|
||||
clearAnnotations,
|
||||
closedLoopPrompt,
|
||||
closedLoopPromptViewportAnchor,
|
||||
confirmClosedLoopPrompt,
|
||||
dismissClosedLoopPrompt,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
viewportCursor,
|
||||
hasAnnotations,
|
||||
captureSnapshot,
|
||||
importOverlayForFrame,
|
||||
importAllOverlays,
|
||||
getViewportState,
|
||||
} = useImageCanvasTools(frame.id);
|
||||
|
||||
useEffect(() => {
|
||||
const pan = getViewportState();
|
||||
setRecordingContext(frameRef, {
|
||||
zoom: pan.zoom,
|
||||
panX: pan.panX,
|
||||
panY: pan.panY,
|
||||
showMask,
|
||||
showGradcam,
|
||||
});
|
||||
}, [frameRef, getViewportState, setRecordingContext, showGradcam, showMask, zoom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!segmentOverlayReplay) return;
|
||||
if (segmentOverlayReplay.frameId !== frame.id) return;
|
||||
importOverlayForFrame(segmentOverlayReplay);
|
||||
}, [frame.id, importOverlayForFrame, segmentOverlayReplay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!segmentOverlayReplay) return;
|
||||
if (segmentOverlayReplay.frameId !== frame.id) return;
|
||||
importOverlayForFrame(segmentOverlayReplay);
|
||||
}, [frame.id, importOverlayForFrame, segmentOverlayReplay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replayFrameRestore) return;
|
||||
if (!isSingleFrameNavLocked && replayFrameRestore.frameIndex !== frameIndex) {
|
||||
setFrameIndex(replayFrameRestore.frameIndex);
|
||||
}
|
||||
importAllOverlays(replayFrameRestore.overlaysByFrameId);
|
||||
onReplayViewport?.({
|
||||
showMask: replayFrameRestore.viewport.showMask,
|
||||
showGradcam: replayFrameRestore.viewport.showGradcam,
|
||||
});
|
||||
clearReplayFrameRestore();
|
||||
}, [
|
||||
clearReplayFrameRestore,
|
||||
frameIndex,
|
||||
importAllOverlays,
|
||||
isSingleFrameNavLocked,
|
||||
onReplayViewport,
|
||||
replayFrameRestore,
|
||||
setFrameIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterSnapshotCapture?.(captureSnapshot);
|
||||
}, [captureSnapshot, onRegisterSnapshotCapture]);
|
||||
|
||||
const dicomRecording = useDicomFrameRecording({
|
||||
viewportRef,
|
||||
frameRef,
|
||||
frameCount: framesForCanvas.length,
|
||||
patientId,
|
||||
tool,
|
||||
showMask,
|
||||
showGradcam,
|
||||
zoom,
|
||||
onMaskToggle: onToggleMask,
|
||||
onToolChange: setTool,
|
||||
onZoomIn: zoomIn,
|
||||
onZoomOut: zoomOut,
|
||||
onResetView: resetView,
|
||||
onClearAnnotations: clearAnnotations,
|
||||
onFrameSwitch: handleFrameSwitch,
|
||||
getViewportState,
|
||||
});
|
||||
|
||||
const panHandlers =
|
||||
tool === 'pan'
|
||||
? { onPointerDown, onPointerMove, onPointerUp }
|
||||
: { onPointerDown: undefined, onPointerMove: undefined, onPointerUp: undefined };
|
||||
|
||||
const drawHandlers =
|
||||
tool === 'draw' || tool === 'eraser'
|
||||
? { onPointerDown, onPointerMove, onPointerUp }
|
||||
: { onPointerDown: undefined, onPointerMove: undefined, onPointerUp: undefined };
|
||||
|
||||
return (
|
||||
<div className={`diagnostic-canvas glass ${dimmed ? 'diagnostic-canvas--dimmed' : ''}`}>
|
||||
<div className="diagnostic-canvas__toolbar">
|
||||
<div className="diagnostic-canvas__frame-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="diagnostic-canvas__nav-btn"
|
||||
onClick={goPrev}
|
||||
disabled={lockFrameNav}
|
||||
aria-label="Khung trước"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="diagnostic-canvas__label">{frameLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="diagnostic-canvas__nav-btn"
|
||||
onClick={goNext}
|
||||
disabled={lockFrameNav}
|
||||
aria-label="Khung sau"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<div className="diagnostic-canvas__toggles">
|
||||
<label className="diagnostic-canvas__toggle">
|
||||
<input type="checkbox" checked={showMask} onChange={dicomRecording.handleMaskToggle} />
|
||||
Kết quả phân đoạn
|
||||
</label>
|
||||
{onToggleGradcam && (
|
||||
<label className="diagnostic-canvas__toggle">
|
||||
<input type="checkbox" checked={showGradcam} onChange={onToggleGradcam} />
|
||||
GradCAM
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="diagnostic-canvas__viewport"
|
||||
style={{ cursor: viewportCursor }}
|
||||
>
|
||||
{activeSegmentLabel && (
|
||||
<span className="diagnostic-canvas__segment-chip">{activeSegmentLabel}</span>
|
||||
)}
|
||||
{draftToastMessage && (
|
||||
<div className="diagnostic-canvas__draft-toast" role="status">{draftToastMessage}</div>
|
||||
)}
|
||||
{frameNavNotice && (
|
||||
<div className="diagnostic-canvas__draft-toast" role="status">{frameNavNotice}</div>
|
||||
)}
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="diagnostic-canvas__stage"
|
||||
{...panHandlers}
|
||||
>
|
||||
<img
|
||||
src={frame.src}
|
||||
alt={`Khung siêu âm — ${frame.label}`}
|
||||
className="diagnostic-canvas__frame"
|
||||
draggable={false}
|
||||
/>
|
||||
{showGradcam && (
|
||||
<img
|
||||
src="/assets/gradcam-overlay.svg"
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="diagnostic-canvas__overlay diagnostic-canvas__overlay--gradcam"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{showMask && (
|
||||
<SegmentationOverlay overlaySrc={overlaySrc} isLoading={isLoading} />
|
||||
)}
|
||||
{showMask && error && !isLoading && (
|
||||
<MlServiceErrorPanel
|
||||
error={error}
|
||||
frameLabel={frameLabel}
|
||||
onRetry={source === 'backend' ? retry : undefined}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="diagnostic-canvas__annotation-canvas"
|
||||
aria-label="Lớp chú thích lâm sàng"
|
||||
style={{
|
||||
pointerEvents: tool === 'draw' || tool === 'eraser' ? 'auto' : 'none',
|
||||
}}
|
||||
{...drawHandlers}
|
||||
/>
|
||||
</div>
|
||||
{closedLoopPrompt && closedLoopPromptViewportAnchor && (
|
||||
<ClosedLoopPrompt
|
||||
anchor={closedLoopPromptViewportAnchor}
|
||||
onConfirm={confirmClosedLoopPrompt}
|
||||
onDismiss={dismissClosedLoopPrompt}
|
||||
/>
|
||||
)}
|
||||
<AnnotationRibbon
|
||||
containerRef={viewportRef}
|
||||
tool={tool}
|
||||
onToolChange={dicomRecording.handleToolChange}
|
||||
inkColor={inkColor}
|
||||
onInkColorChange={setInkColor}
|
||||
zoom={zoom}
|
||||
onZoomIn={dicomRecording.handleZoomIn}
|
||||
onZoomOut={dicomRecording.handleZoomOut}
|
||||
onResetView={dicomRecording.handleResetView}
|
||||
onClearAnnotations={dicomRecording.handleClearAnnotations}
|
||||
hasAnnotations={hasAnnotations}
|
||||
disabled={dimmed}
|
||||
/>
|
||||
<SegmentationLegendBubble
|
||||
visible={showMask && !!interpretation}
|
||||
containerRef={viewportRef}
|
||||
angleType={interpretation?.angleType ?? 'sup'}
|
||||
entries={interpretation?.legendEntries}
|
||||
showMeasurementNote={showMeasurementLegendNotes}
|
||||
/>
|
||||
{showThicknessHud && (
|
||||
<div className="diagnostic-canvas__hud">
|
||||
<span className="tnum">{thicknessMm.toFixed(2)} mm</span>
|
||||
<span>Độ dày (dịch + màng hoạt dịch)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<style>{`
|
||||
.diagnostic-canvas {
|
||||
flex: 1;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.diagnostic-canvas--dimmed {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.diagnostic-canvas__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px var(--space-md);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.diagnostic-canvas__frame-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.diagnostic-canvas__nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: var(--color-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.diagnostic-canvas__nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.diagnostic-canvas__nav-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.diagnostic-canvas__nav-btn:disabled:hover {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.diagnostic-canvas__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.diagnostic-canvas__toggles {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.diagnostic-canvas__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.diagnostic-canvas__viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
background: #0a0f0e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.diagnostic-canvas__segment-chip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 60;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #ecfdf5;
|
||||
background: rgba(220, 38, 38, 0.85);
|
||||
border: 1px solid rgba(254, 202, 202, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
.diagnostic-canvas__draft-toast {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
max-width: min(90%, 420px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border: 1px solid rgba(118, 200, 177, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
.diagnostic-canvas__stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.diagnostic-canvas__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.diagnostic-canvas__annotation-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
z-index: 500;
|
||||
}
|
||||
.diagnostic-canvas__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
.diagnostic-canvas__overlay--gradcam {
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.diagnostic-canvas__overlay--mask {
|
||||
opacity: 1;
|
||||
mix-blend-mode: normal;
|
||||
z-index: 10;
|
||||
}
|
||||
.diagnostic-canvas__hud {
|
||||
position: absolute;
|
||||
bottom: 72px;
|
||||
left: 12px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.diagnostic-canvas__hud span:first-child {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.diagnostic-canvas__hud span:last-child {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
max-width: 11rem;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ACCEPTED_SCAN_FILE_INPUT,
|
||||
isAcceptedScanFile,
|
||||
JOINT_OPTIONS,
|
||||
PATIENT_STATUS_OPTIONS,
|
||||
type PatientStatusOption,
|
||||
} from '../../data/patientConstants';
|
||||
import type { PatientCase, PatientStatus } from '../../data/mockData';
|
||||
import type { NewPatientInput, UpdatePatientInput } from '../../lib/patientStore';
|
||||
import type { ScanAssetMeta } from '../../lib/patientScanAssetsStore';
|
||||
import { usePatientStore } from '../../lib/patientStore';
|
||||
import { PATIENT_PROFILE_MODAL_STYLES } from './patientProfileModalStyles';
|
||||
|
||||
interface StagedNewFile {
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number;
|
||||
status: 'queued' | 'uploading' | 'done';
|
||||
}
|
||||
|
||||
interface ExistingAssetState {
|
||||
meta: ScanAssetMeta;
|
||||
markedForDeletion: boolean;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
mrn: string;
|
||||
joint: string;
|
||||
status: PatientStatus;
|
||||
}
|
||||
|
||||
type PatientProfileModalProps =
|
||||
| {
|
||||
mode: 'add';
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: NewPatientInput) => Promise<void>;
|
||||
}
|
||||
| {
|
||||
mode: 'edit';
|
||||
patient: PatientCase;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: UpdatePatientInput) => Promise<void>;
|
||||
};
|
||||
|
||||
function formFromPatient(patient: PatientCase): FormState {
|
||||
return {
|
||||
name: patient.name,
|
||||
mrn: patient.mrn,
|
||||
joint: patient.joint,
|
||||
status: patient.status,
|
||||
};
|
||||
}
|
||||
|
||||
function PlusUserIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M19 8v6M22 11h-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadCloudIcon() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
||||
<path d="M12 16V4m0 0 4 4m-4-4-4 4" />
|
||||
<path d="M4 17v1a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function simulateUpload(id: string, onProgress: (id: string, progress: number) => void): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let progress = 0;
|
||||
const tick = () => {
|
||||
progress += 12 + Math.random() * 18;
|
||||
if (progress >= 100) {
|
||||
onProgress(id, 100);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
onProgress(id, Math.round(progress));
|
||||
window.setTimeout(tick, 120 + Math.random() * 80);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
export default function PatientProfileModal(props: PatientProfileModalProps) {
|
||||
const { open, onClose, mode } = props;
|
||||
const patient = mode === 'edit' ? props.patient : null;
|
||||
const { getPatientScanAssets } = usePatientStore();
|
||||
|
||||
const titleId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [form, setForm] = useState<FormState>(
|
||||
mode === 'edit' && patient ? formFromPatient(patient) : { name: '', mrn: '', joint: JOINT_OPTIONS[0], status: 'processing' },
|
||||
);
|
||||
const [initialForm, setInitialForm] = useState<FormState>(form);
|
||||
const [existingAssets, setExistingAssets] = useState<ExistingAssetState[]>([]);
|
||||
const [newFiles, setNewFiles] = useState<StagedNewFile[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [assetsLoading, setAssetsLoading] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const empty: FormState = { name: '', mrn: '', joint: JOINT_OPTIONS[0], status: 'processing' };
|
||||
setForm(mode === 'edit' && patient ? formFromPatient(patient) : empty);
|
||||
setInitialForm(mode === 'edit' && patient ? formFromPatient(patient) : empty);
|
||||
setExistingAssets([]);
|
||||
setNewFiles([]);
|
||||
setDragOver(false);
|
||||
setSubmitting(false);
|
||||
setFileError(null);
|
||||
setAssetsLoading(false);
|
||||
}, [mode, patient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'edit' && patient) {
|
||||
const hydrated = formFromPatient(patient);
|
||||
setForm(hydrated);
|
||||
setInitialForm(hydrated);
|
||||
setAssetsLoading(true);
|
||||
void getPatientScanAssets(patient.id)
|
||||
.then((assets) => {
|
||||
const rows = assets.map((meta) => ({ meta, markedForDeletion: false }));
|
||||
setExistingAssets(rows);
|
||||
})
|
||||
.finally(() => setAssetsLoading(false));
|
||||
}
|
||||
}, [open, mode, patient, getPatientScanAssets, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !submitting) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose, submitting]);
|
||||
|
||||
const requiredFilled =
|
||||
form.name.trim().length > 0 &&
|
||||
form.mrn.trim().length > 0 &&
|
||||
form.joint.length > 0 &&
|
||||
Boolean(form.status);
|
||||
|
||||
const formChanged =
|
||||
form.name.trim() !== initialForm.name.trim() ||
|
||||
form.mrn.trim() !== initialForm.mrn.trim() ||
|
||||
form.joint !== initialForm.joint ||
|
||||
form.status !== initialForm.status;
|
||||
|
||||
const assetsChanged =
|
||||
existingAssets.some((asset) => asset.markedForDeletion) || newFiles.length > 0;
|
||||
|
||||
const activeAssetCount =
|
||||
existingAssets.filter((asset) => !asset.markedForDeletion).length + newFiles.length;
|
||||
|
||||
const hasChanges = mode === 'edit' ? formChanged || assetsChanged : true;
|
||||
|
||||
const assetStateValid = !assetsChanged || activeAssetCount > 0;
|
||||
const canSubmitAdd = requiredFilled && newFiles.length > 0 && !submitting;
|
||||
const canSubmit =
|
||||
mode === 'add'
|
||||
? canSubmitAdd
|
||||
: requiredFilled && hasChanges && assetStateValid && !submitting && !assetsLoading;
|
||||
|
||||
const addFiles = (files: FileList | File[]) => {
|
||||
const incoming = Array.from(files);
|
||||
const valid = incoming.filter(isAcceptedScanFile);
|
||||
const invalidCount = incoming.length - valid.length;
|
||||
|
||||
if (invalidCount > 0) {
|
||||
setFileError('Chỉ chấp nhận ảnh PNG, JPEG hoặc DICOM (.dcm).');
|
||||
} else {
|
||||
setFileError(null);
|
||||
}
|
||||
|
||||
if (valid.length === 0) return;
|
||||
|
||||
const existingNames = new Set([
|
||||
...existingAssets.filter((a) => !a.markedForDeletion).map((a) => a.meta.fileName),
|
||||
...newFiles.map((item) => item.file.name),
|
||||
]);
|
||||
|
||||
const next = valid
|
||||
.filter((file) => !existingNames.has(file.name))
|
||||
.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
progress: 0,
|
||||
status: 'queued' as const,
|
||||
}));
|
||||
|
||||
if (next.length > 0) setNewFiles((current) => [...current, ...next]);
|
||||
};
|
||||
|
||||
const removeNewFile = (id: string) => {
|
||||
if (submitting) return;
|
||||
setNewFiles((current) => current.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const toggleDeleteExisting = (id: string) => {
|
||||
if (submitting) return;
|
||||
setExistingAssets((current) =>
|
||||
current.map((asset) =>
|
||||
asset.meta.id === id ? { ...asset, markedForDeletion: !asset.markedForDeletion } : asset,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
const uploadTargets = mode === 'add' ? newFiles : newFiles.filter((f) => f.status !== 'done');
|
||||
|
||||
setNewFiles((current) =>
|
||||
current.map((item) => ({ ...item, status: 'uploading' as const, progress: 0 })),
|
||||
);
|
||||
|
||||
const updateProgress = (id: string, progress: number) => {
|
||||
setNewFiles((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id ? { ...item, progress, status: progress >= 100 ? 'done' : 'uploading' } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(uploadTargets.map((item) => simulateUpload(item.id, updateProgress)));
|
||||
|
||||
if (mode === 'add') {
|
||||
await props.onSubmit({
|
||||
name: form.name,
|
||||
mrn: form.mrn,
|
||||
joint: form.joint,
|
||||
status: form.status,
|
||||
scanFiles: newFiles.map((item) => item.file),
|
||||
});
|
||||
} else if (patient) {
|
||||
await props.onSubmit({
|
||||
patientId: patient.id,
|
||||
name: form.name,
|
||||
mrn: form.mrn,
|
||||
joint: form.joint,
|
||||
status: form.status,
|
||||
removedAssetIds: existingAssets.filter((a) => a.markedForDeletion).map((a) => a.meta.id),
|
||||
newScanFiles: newFiles.map((item) => item.file),
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modalTitle = useMemo(() => {
|
||||
if (mode === 'edit' && patient) return `Chỉnh sửa hồ sơ: ${patient.name}`;
|
||||
return 'Thêm hồ sơ bệnh nhân mới';
|
||||
}, [mode, patient]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const showAssetInventory = mode === 'edit';
|
||||
|
||||
return (
|
||||
<div className="patient-profile-modal" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
className="patient-profile-modal__backdrop"
|
||||
aria-label={mode === 'edit' ? 'Đóng biểu mẫu chỉnh sửa' : 'Đóng biểu mẫu thêm bệnh nhân'}
|
||||
onClick={() => !submitting && onClose()}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<div className="patient-profile-modal__sheet" role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||
<header className="patient-profile-modal__header">
|
||||
<h2 id={titleId} className="patient-profile-modal__title">
|
||||
{modalTitle}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="patient-profile-modal__close icon-btn"
|
||||
aria-label="Đóng"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="patient-profile-modal__body">
|
||||
<div className="patient-profile-modal__grid">
|
||||
<label className="patient-profile-modal__field patient-profile-modal__field--full">
|
||||
<span className="patient-profile-modal__label">Họ và tên bệnh nhân</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Ví dụ: Nguyễn Văn An"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="patient-profile-modal__field">
|
||||
<span className="patient-profile-modal__label">Mã bệnh nhân (MRN)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="tnum"
|
||||
value={form.mrn}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, mrn: event.target.value }))}
|
||||
placeholder="BN-2024-XXXX"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="patient-profile-modal__field">
|
||||
<span className="patient-profile-modal__label">Vị trí khớp chụp</span>
|
||||
<select
|
||||
value={form.joint}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, joint: event.target.value }))}
|
||||
disabled={submitting}
|
||||
>
|
||||
{JOINT_OPTIONS.map((joint) => (
|
||||
<option key={joint} value={joint}>
|
||||
{joint}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="patient-profile-modal__field patient-profile-modal__field--full">
|
||||
<span className="patient-profile-modal__label">Trạng thái tiếp nhận</span>
|
||||
<div className="patient-profile-modal__status-pills" role="radiogroup" aria-label="Trạng thái tiếp nhận">
|
||||
{PATIENT_STATUS_OPTIONS.map((option: PatientStatusOption) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={form.status === option.value}
|
||||
className={`patient-profile-modal__status-pill chip ${option.chipClass} ${
|
||||
form.status === option.value ? 'patient-profile-modal__status-pill--active' : ''
|
||||
}`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, status: option.value }))}
|
||||
disabled={submitting}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`patient-profile-modal__dropzone ${dragOver ? 'patient-profile-modal__dropzone--active' : ''}`}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node)) return;
|
||||
setDragOver(false);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
if (submitting) return;
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<UploadCloudIcon />
|
||||
<p className="patient-profile-modal__dropzone-primary">
|
||||
{mode === 'edit'
|
||||
? 'Kéo và thả tệp DICOM/ảnh mới tại đây để tải lên'
|
||||
: 'Kéo và thả ảnh siêu âm hoặc DICOM tại đây'}
|
||||
</p>
|
||||
<p className="patient-profile-modal__dropzone-secondary">
|
||||
hoặc{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="patient-profile-modal__browse-link"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting}
|
||||
>
|
||||
Chọn tệp từ máy tính
|
||||
</button>
|
||||
</p>
|
||||
<p className="patient-profile-modal__dropzone-caption">
|
||||
Hỗ trợ PNG, JPEG và DICOM (.dcm). Tự động trích xuất mốc thời gian quét.
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_SCAN_FILE_INPUT}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
if (event.target.files) addFiles(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fileError && <p className="patient-profile-modal__file-error">{fileError}</p>}
|
||||
|
||||
{mode === 'add' && newFiles.length > 0 && (
|
||||
<ul className="patient-profile-modal__file-list" aria-label="Danh sách tệp quét">
|
||||
{newFiles.map((item) => (
|
||||
<li key={item.id} className="patient-profile-modal__file-item">
|
||||
<span className="patient-profile-modal__file-icon" aria-hidden>
|
||||
<FileIcon />
|
||||
</span>
|
||||
<div className="patient-profile-modal__file-meta">
|
||||
<span className="patient-profile-modal__file-name">{item.file.name}</span>
|
||||
<span className="patient-profile-modal__file-size tnum">{formatFileSize(item.file.size)}</span>
|
||||
{(item.status === 'uploading' || item.status === 'done') && (
|
||||
<div className="patient-profile-modal__progress" aria-hidden>
|
||||
<div className="patient-profile-modal__progress-bar" style={{ width: `${item.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="patient-profile-modal__file-remove icon-btn"
|
||||
aria-label={`Xóa ${item.file.name}`}
|
||||
onClick={() => removeNewFile(item.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showAssetInventory && (
|
||||
<section className="patient-profile-modal__inventory">
|
||||
<h3 className="patient-profile-modal__inventory-title">Danh sách tệp dữ liệu hiện tại</h3>
|
||||
{assetsLoading ? (
|
||||
<p className="patient-profile-modal__inventory-loading">Đang tải danh sách tệp…</p>
|
||||
) : existingAssets.length === 0 ? (
|
||||
<p className="patient-profile-modal__inventory-empty">Chưa có tệp dữ liệu được lưu.</p>
|
||||
) : (
|
||||
<ul className="patient-profile-modal__file-list">
|
||||
{existingAssets.map((asset) => (
|
||||
<li
|
||||
key={asset.meta.id}
|
||||
className={`patient-profile-modal__file-item ${
|
||||
asset.markedForDeletion ? 'patient-profile-modal__file-item--deleted' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="patient-profile-modal__file-icon" aria-hidden>
|
||||
<FileIcon />
|
||||
</span>
|
||||
<div className="patient-profile-modal__file-meta">
|
||||
<span className="patient-profile-modal__file-name">{asset.meta.fileName}</span>
|
||||
<span className="patient-profile-modal__file-size tnum">
|
||||
{formatFileSize(asset.meta.size)}
|
||||
{asset.markedForDeletion && (
|
||||
<span className="patient-profile-modal__delete-badge"> · Sẽ xóa</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
asset.markedForDeletion
|
||||
? 'patient-profile-modal__undo-link'
|
||||
: 'patient-profile-modal__trash-btn icon-btn'
|
||||
}
|
||||
aria-label={
|
||||
asset.markedForDeletion
|
||||
? `Hoàn tác xóa ${asset.meta.fileName}`
|
||||
: `Đánh dấu xóa ${asset.meta.fileName}`
|
||||
}
|
||||
onClick={() => toggleDeleteExisting(asset.meta.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{asset.markedForDeletion ? 'Hoàn tác' : <TrashIcon />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && newFiles.length > 0 && (
|
||||
<section className="patient-profile-modal__inventory">
|
||||
<h3 className="patient-profile-modal__inventory-title">Tệp tải lên mới</h3>
|
||||
<ul className="patient-profile-modal__file-list">
|
||||
{newFiles.map((item) => (
|
||||
<li key={item.id} className="patient-profile-modal__file-item patient-profile-modal__file-item--new">
|
||||
<span className="patient-profile-modal__file-icon" aria-hidden>
|
||||
<FileIcon />
|
||||
</span>
|
||||
<div className="patient-profile-modal__file-meta">
|
||||
<span className="patient-profile-modal__file-name">
|
||||
{item.file.name}
|
||||
<span className="patient-profile-modal__new-badge">Tải lên mới</span>
|
||||
</span>
|
||||
<span className="patient-profile-modal__file-size tnum">{formatFileSize(item.file.size)}</span>
|
||||
{item.status === 'uploading' && (
|
||||
<div className="patient-profile-modal__progress" aria-hidden>
|
||||
<div className="patient-profile-modal__progress-bar" style={{ width: `${item.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="patient-profile-modal__file-remove icon-btn"
|
||||
aria-label={`Xóa ${item.file.name}`}
|
||||
onClick={() => removeNewFile(item.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="patient-profile-modal__footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={submitting}>
|
||||
Hủy bỏ
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!canSubmit} onClick={() => void handleSubmit()}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<span className="patient-profile-modal__spinner" aria-hidden />
|
||||
{mode === 'edit' ? 'Đang lưu…' : 'Đang tải lên…'}
|
||||
</>
|
||||
) : mode === 'edit' ? (
|
||||
<>
|
||||
<SaveIcon />
|
||||
Lưu thay đổi
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusUserIcon />
|
||||
Tạo hồ sơ & tải lên
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{PATIENT_PROFILE_MODAL_STYLES}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { PointerTraceSample } from '../../data/diagnosticSessionReplay';
|
||||
import type { ReplayViewportState } from '../../hooks/useDiagnosticSessionReplay';
|
||||
|
||||
interface ReplaySandboxOverlayProps {
|
||||
viewport: ReplayViewportState;
|
||||
}
|
||||
|
||||
function PointerTrail({ points }: { points: PointerTraceSample[] }) {
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const segmentCount = points.length - 1;
|
||||
return (
|
||||
<>
|
||||
{points.slice(1).map((point, index) => {
|
||||
const previous = points[index];
|
||||
const progress = segmentCount <= 1 ? 1 : (index + 1) / segmentCount;
|
||||
const opacity = 0.06 + progress * 0.74;
|
||||
const width = 0.12 + progress * 0.5;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${previous.elapsedSeconds}-${point.elapsedSeconds}-${index}`}
|
||||
x1={previous.x}
|
||||
y1={previous.y}
|
||||
x2={point.x}
|
||||
y2={point.y}
|
||||
stroke="#ff007f"
|
||||
strokeOpacity={opacity}
|
||||
strokeWidth={width}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReplaySandboxOverlay({ viewport }: ReplaySandboxOverlayProps) {
|
||||
if (!viewport.active) return null;
|
||||
|
||||
const useTraceMotion = viewport.showPointerTrace && viewport.trailPoints.length > 0;
|
||||
const penColor = viewport.penColor ?? '#ff007f';
|
||||
const ghostSize = viewport.isOverlayEditing ? 16 : 14;
|
||||
|
||||
return (
|
||||
<div className="replay-sandbox" aria-hidden={false}>
|
||||
<div className="replay-sandbox__shield" aria-label="Chế độ xem lại — tương tác bị khóa" />
|
||||
|
||||
{viewport.showPointerTrace && (
|
||||
<svg
|
||||
className="replay-sandbox__trail"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden
|
||||
>
|
||||
<PointerTrail points={viewport.trailPoints} />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`replay-sandbox__ghost ${viewport.ripple ? 'replay-sandbox__ghost--ripple' : ''}${viewport.isOverlayEditing ? ' replay-sandbox__ghost--drawing' : ''}`}
|
||||
style={{
|
||||
left: `${viewport.ghostX}%`,
|
||||
top: `${viewport.ghostY}%`,
|
||||
transition: useTraceMotion ? 'left 0.08s linear, top 0.08s linear' : 'left 0.45s ease, top 0.45s ease',
|
||||
['--replay-pen-color' as string]: penColor,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="replay-sandbox__ghost-core"
|
||||
style={{
|
||||
width: ghostSize,
|
||||
height: ghostSize,
|
||||
background: penColor,
|
||||
boxShadow: `0 0 0 4px ${penColor}40, 0 0 18px ${penColor}a8`,
|
||||
}}
|
||||
/>
|
||||
{viewport.ripple && (
|
||||
<span className="replay-sandbox__ghost-ring" style={{ borderColor: `${penColor}bf` }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewport.event && (
|
||||
<div className="replay-sandbox__event-chip">
|
||||
<strong>{viewport.event.title}</strong>
|
||||
<span>{viewport.event.detail}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.replay-sandbox {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
}
|
||||
.replay-sandbox__shield {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.replay-sandbox__trail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
.replay-sandbox__ghost {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
.replay-sandbox__ghost--drawing .replay-sandbox__ghost-core {
|
||||
animation: replay-pen-pulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes replay-pen-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
.replay-sandbox__ghost-core {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #ff007f;
|
||||
box-shadow: 0 0 0 4px rgba(255, 0, 127, 0.25), 0 0 18px rgba(255, 0, 127, 0.65);
|
||||
}
|
||||
.replay-sandbox__ghost-ring {
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 0, 127, 0.75);
|
||||
animation: replay-ghost-ring 0.65s ease-out forwards;
|
||||
}
|
||||
@keyframes replay-ghost-ring {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.95;
|
||||
}
|
||||
to {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.replay-sandbox__event-chip {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
max-width: min(360px, calc(100% - 24px));
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.92);
|
||||
color: #f8fafc;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.replay-sandbox__event-chip strong {
|
||||
color: #76c8b1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.replay-sandbox__event-chip span {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
eventKindColor,
|
||||
formatRecordedTime,
|
||||
formatReplayClock,
|
||||
tooltipForEvent,
|
||||
} from '../../data/diagnosticSessionReplay';
|
||||
import {
|
||||
useDiagnosticSessionReplay,
|
||||
type PlaybackSpeed,
|
||||
type ReplayViewportState,
|
||||
} from '../../hooks/useDiagnosticSessionReplay';
|
||||
import {
|
||||
applyEventEdits,
|
||||
loadEventEdits,
|
||||
loadSessionDescription,
|
||||
loadShowPointerTracePreference,
|
||||
loadStepNotes,
|
||||
saveEventEdit,
|
||||
saveSessionDescription,
|
||||
saveShowPointerTracePreference,
|
||||
saveStepNote,
|
||||
} from '../../lib/sessionReplayNotesStore';
|
||||
import { useSessionLifecycle } from '../../lib/sessionLifecycle';
|
||||
import type { FrameOverlaySnapshot, SegmentViewportState } from '../../lib/sessionRecordingTypes';
|
||||
import SessionLifecycleToolbar from '../molecules/SessionLifecycleToolbar';
|
||||
import SessionCancelRecordDialog from '../molecules/SessionCancelRecordDialog';
|
||||
import SessionNewSessionDialog from '../molecules/SessionNewSessionDialog';
|
||||
import SessionResetConfirmDialog from '../molecules/SessionResetConfirmDialog';
|
||||
import SavedSessionsDialog from '../molecules/SavedSessionsDialog';
|
||||
import RecordingModeSelector from '../molecules/RecordingModeSelector';
|
||||
import RecordedSessionActionsList from '../molecules/RecordedSessionActionsList';
|
||||
|
||||
interface ReviewDiagnosticSessionPanelProps {
|
||||
patientId: string;
|
||||
patientName: string;
|
||||
patientMrn: string;
|
||||
onViewportChange?: (state: ReplayViewportState) => void;
|
||||
onFrameRestore?: (
|
||||
frameId: string,
|
||||
frameIndex: number,
|
||||
segmentOverlay: FrameOverlaySnapshot,
|
||||
) => void;
|
||||
onReplaySegmentViewport?: (viewport: SegmentViewportState) => void;
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipBackIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M11 19V5l-7 7 7 7z" />
|
||||
<path d="M18 19V5l-7 7 7 7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipForwardIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<path d="M13 5v14l7-7-7-7z" />
|
||||
<path d="M6 5v14l7-7-7-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewDiagnosticSessionPanel({
|
||||
patientId,
|
||||
onViewportChange,
|
||||
onFrameRestore,
|
||||
onReplaySegmentViewport,
|
||||
}: ReviewDiagnosticSessionPanelProps) {
|
||||
const lifecycle = useSessionLifecycle();
|
||||
const sessionLog = lifecycle.sessionLog;
|
||||
const isSessionEnded = lifecycle.status === 'ended';
|
||||
const isLiveCapture = lifecycle.status === 'recording' || lifecycle.status === 'paused';
|
||||
|
||||
const [eventEdits, setEventEdits] = useState(() => loadEventEdits(patientId));
|
||||
const [showPointerTrace, setShowPointerTrace] = useState(() => loadShowPointerTracePreference(patientId));
|
||||
const resolvedLog = useMemo(
|
||||
() => ({
|
||||
...sessionLog,
|
||||
events: sessionLog.events.map((event) => applyEventEdits(event, eventEdits)),
|
||||
}),
|
||||
[sessionLog, eventEdits],
|
||||
);
|
||||
|
||||
const replay = useDiagnosticSessionReplay({
|
||||
log: resolvedLog,
|
||||
showPointerTrace,
|
||||
onViewportChange: isLiveCapture ? undefined : onViewportChange,
|
||||
onFrameRestore:
|
||||
isLiveCapture || lifecycle.status !== 'ended'
|
||||
? undefined
|
||||
: (frameId, frameIndex, segmentOverlay) =>
|
||||
onFrameRestore?.(frameId, frameIndex, segmentOverlay),
|
||||
onReplaySegmentViewport: isLiveCapture ? undefined : onReplaySegmentViewport,
|
||||
});
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const prevLifecycleStatusRef = useRef<string | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
||||
const [sessionDescription, setSessionDescription] = useState(() => loadSessionDescription(patientId));
|
||||
const [stepNotes, setStepNotes] = useState<Record<string, string>>(() => loadStepNotes(patientId));
|
||||
|
||||
useEffect(() => {
|
||||
setSessionDescription(loadSessionDescription(patientId));
|
||||
setStepNotes(loadStepNotes(patientId));
|
||||
setEventEdits(loadEventEdits(patientId));
|
||||
setShowPointerTrace(loadShowPointerTracePreference(patientId));
|
||||
}, [patientId, sessionLog.events.length, lifecycle.status, lifecycle.activeSavedSessionId]);
|
||||
|
||||
const replayRef = useRef(replay);
|
||||
replayRef.current = replay;
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevLifecycleStatusRef.current;
|
||||
prevLifecycleStatusRef.current = lifecycle.status;
|
||||
if (lifecycle.status === 'ended' && prev !== 'ended') {
|
||||
replayRef.current.switchMode('carousel');
|
||||
replayRef.current.goToStep(0);
|
||||
replayRef.current.resetPlaybackPosition();
|
||||
}
|
||||
if (lifecycle.status === 'recording' && prev === 'idle') {
|
||||
replayRef.current.resetPlayback();
|
||||
}
|
||||
}, [lifecycle.status]);
|
||||
|
||||
const persistSessionDescription = useCallback(
|
||||
(value: string) => {
|
||||
setSessionDescription(value);
|
||||
saveSessionDescription(patientId, value);
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const persistStepNote = useCallback(
|
||||
(eventId: string, value: string) => {
|
||||
setStepNotes((current) => {
|
||||
const next = { ...current };
|
||||
if (value.trim()) next[eventId] = value.trim();
|
||||
else delete next[eventId];
|
||||
return next;
|
||||
});
|
||||
saveStepNote(patientId, eventId, value);
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const persistEventEdit = useCallback(
|
||||
(eventId: string, field: 'title' | 'detail', value: string) => {
|
||||
const patch = field === 'title' ? { title: value } : { detail: value };
|
||||
setEventEdits(saveEventEdit(patientId, eventId, patch));
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const progressPct = Math.min(
|
||||
100,
|
||||
Math.max(0, (replay.elapsedSeconds / Math.max(replay.totalDuration, 0.001)) * 100),
|
||||
);
|
||||
const hasRecordedSession = replay.events.length > 0;
|
||||
const activeEventId = replay.activeEvent?.id ?? '';
|
||||
const activeStepNote = stepNotes[activeEventId] ?? '';
|
||||
const activeTitleValue = replay.activeEvent?.title ?? '';
|
||||
const activeDetailValue = replay.activeEvent?.detail ?? '';
|
||||
|
||||
const hoveredEvent = hoveredEventId
|
||||
? replay.events.find((event) => event.id === hoveredEventId) ?? null
|
||||
: null;
|
||||
const hoveredTickLeft = hoveredEvent
|
||||
? Math.min(98, Math.max(2, (hoveredEvent.elapsedSeconds / replay.totalDuration) * 100))
|
||||
: 50;
|
||||
|
||||
const updateTimelineHover = useCallback(
|
||||
(clientX: number) => {
|
||||
if (isScrubbing) {
|
||||
setHoveredEventId(null);
|
||||
return;
|
||||
}
|
||||
const track = timelineRef.current;
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const x = clientX - rect.left;
|
||||
const hitRadiusPx = 14;
|
||||
let closestId: string | null = null;
|
||||
let closestDist = hitRadiusPx;
|
||||
for (const event of replay.events) {
|
||||
const eventX = (event.elapsedSeconds / replay.totalDuration) * rect.width;
|
||||
const dist = Math.abs(x - eventX);
|
||||
if (dist <= closestDist) {
|
||||
closestDist = dist;
|
||||
closestId = event.id;
|
||||
}
|
||||
}
|
||||
setHoveredEventId(closestId);
|
||||
},
|
||||
[isScrubbing, replay.events, replay.totalDuration],
|
||||
);
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = timelineRef.current;
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
replay.seek(ratio * replay.totalDuration);
|
||||
},
|
||||
[replay],
|
||||
);
|
||||
|
||||
const onTimelinePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if ((event.target as HTMLElement).closest('.review-session__timeline-tick')) return;
|
||||
setIsScrubbing(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
seekFromClientX(event.clientX);
|
||||
};
|
||||
|
||||
const onTimelinePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbing) return;
|
||||
seekFromClientX(event.clientX);
|
||||
};
|
||||
|
||||
const onTimelinePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isScrubbing) return;
|
||||
setIsScrubbing(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="review-session">
|
||||
<header className="review-session__header">
|
||||
<h2 className="review-session__title">Xem lại phiên chẩn đoán</h2>
|
||||
<RecordingModeSelector
|
||||
value={lifecycle.recordingMode}
|
||||
onChange={lifecycle.setRecordingMode}
|
||||
disabled={lifecycle.status === 'recording' || lifecycle.status === 'paused'}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<SessionLifecycleToolbar
|
||||
status={lifecycle.status}
|
||||
saveState={lifecycle.saveUiState}
|
||||
saveName={lifecycle.saveNameDraft}
|
||||
savedSessionCount={lifecycle.savedSessions.length}
|
||||
onStart={lifecycle.startRecording}
|
||||
onPause={lifecycle.pauseRecording}
|
||||
onResume={lifecycle.resumeRecording}
|
||||
onEnd={lifecycle.endRecording}
|
||||
onReset={lifecycle.requestReset}
|
||||
onNewSession={lifecycle.requestNewSession}
|
||||
canStartNewSession={lifecycle.canStartNewSession}
|
||||
onSaveClick={lifecycle.beginSaveSession}
|
||||
onSaveNameChange={lifecycle.setSaveNameDraft}
|
||||
onConfirmSave={lifecycle.confirmSaveSession}
|
||||
onCancelSave={lifecycle.cancelSaveSession}
|
||||
onCancelRecord={lifecycle.requestCancelRecording}
|
||||
canCancelRecord={lifecycle.canCancelRecording}
|
||||
onOpenSessionsList={lifecycle.openSessionsDialog}
|
||||
/>
|
||||
|
||||
<SessionCancelRecordDialog
|
||||
open={lifecycle.cancelRecordDialogOpen}
|
||||
onDismiss={lifecycle.dismissCancelRecording}
|
||||
onConfirm={lifecycle.confirmCancelRecording}
|
||||
/>
|
||||
|
||||
<SessionNewSessionDialog
|
||||
open={lifecycle.newSessionDialogOpen}
|
||||
onDismiss={lifecycle.dismissNewSession}
|
||||
onConfirm={lifecycle.confirmNewSession}
|
||||
/>
|
||||
|
||||
<SavedSessionsDialog
|
||||
open={lifecycle.sessionsDialogOpen}
|
||||
sessions={lifecycle.savedSessions}
|
||||
encounterGroups={lifecycle.encounterGroups}
|
||||
activeSessionId={lifecycle.activeSavedSessionId}
|
||||
onClose={lifecycle.closeSessionsDialog}
|
||||
onLoad={lifecycle.loadSavedSession}
|
||||
onDelete={lifecycle.deleteSavedSession}
|
||||
/>
|
||||
|
||||
<SessionResetConfirmDialog
|
||||
open={lifecycle.resetDialogOpen}
|
||||
onCancel={lifecycle.cancelReset}
|
||||
onConfirmAndRecord={() => void lifecycle.confirmReset()}
|
||||
onConfirmDeleteOnly={() => void lifecycle.confirmResetOnly()}
|
||||
/>
|
||||
|
||||
<div className="review-session__tabs" role="tablist" aria-label="Chế độ xem lại phiên">
|
||||
<div
|
||||
className="review-session__tab-indicator"
|
||||
style={{ transform: replay.mode === 'continuous' ? 'translateX(0%)' : 'translateX(100%)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={replay.mode === 'continuous'}
|
||||
className={`review-session__tab ${replay.mode === 'continuous' ? 'review-session__tab--active' : ''}`}
|
||||
onClick={() => replay.switchMode('continuous')}
|
||||
>
|
||||
<span className="review-session__tab-icon" aria-hidden>▶</span>
|
||||
Phát liên tục
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={replay.mode === 'carousel'}
|
||||
className={`review-session__tab ${replay.mode === 'carousel' ? 'review-session__tab--active' : ''}`}
|
||||
onClick={() => replay.switchMode('carousel')}
|
||||
>
|
||||
<span className="review-session__tab-icon" aria-hidden>☰</span>
|
||||
Từng bước hành động
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="review-session__session-desc">
|
||||
<span className="review-session__session-desc-label">Mô tả phiên chẩn đoán</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={sessionDescription}
|
||||
placeholder="Ghi chú tổng quan: mục tiêu phiên, bối cảnh lâm sàng, điểm cần rà soát…"
|
||||
onChange={(event) => persistSessionDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{isSessionEnded && (
|
||||
<RecordedSessionActionsList
|
||||
events={replay.events}
|
||||
activeEventId={activeEventId}
|
||||
onSelectEvent={(_eventId, index) => replay.goToStep(index)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="review-session__stage">
|
||||
{!hasRecordedSession ? (
|
||||
<div className="review-session__empty" role="status">
|
||||
<p className="review-session__empty-title">Chưa có phiên ghi</p>
|
||||
<p className="review-session__empty-detail">
|
||||
Nhấn <strong>Ghi</strong> trên thanh công cụ để bắt đầu ghi lại các thao tác chẩn đoán.
|
||||
Sau khi kết thúc phiên, thanh thời gian và phát lại sẽ hiển thị tại đây.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="review-session__trace-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPointerTrace}
|
||||
onChange={(event) => {
|
||||
const enabled = event.target.checked;
|
||||
setShowPointerTrace(enabled);
|
||||
saveShowPointerTracePreference(patientId, enabled);
|
||||
}}
|
||||
/>
|
||||
<span>Hiển thị vết chuột (comet)</span>
|
||||
</label>
|
||||
|
||||
{replay.mode === 'continuous' ? (
|
||||
<div className="review-session__continuous">
|
||||
<div
|
||||
className="review-session__timeline-wrap"
|
||||
onMouseMove={(event) => updateTimelineHover(event.clientX)}
|
||||
onMouseLeave={() => setHoveredEventId(null)}
|
||||
>
|
||||
<div className="review-session__timeline-tooltip-slot" aria-hidden={!hoveredEvent}>
|
||||
{hoveredEvent && (
|
||||
<div
|
||||
className="review-session__timeline-tooltip"
|
||||
role="tooltip"
|
||||
style={{ left: `${hoveredTickLeft}%` }}
|
||||
>
|
||||
{tooltipForEvent(hoveredEvent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={`review-session__timeline ${isScrubbing ? 'review-session__timeline--scrubbing' : ''} ${replay.isPlaying ? 'review-session__timeline--playing' : ''}`}
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={replay.totalDuration}
|
||||
aria-valuenow={Math.round(replay.elapsedSeconds)}
|
||||
aria-label="Thanh thời gian phát lại — kéo để chọn vị trí xem"
|
||||
onPointerDown={onTimelinePointerDown}
|
||||
onPointerMove={onTimelinePointerMove}
|
||||
onPointerUp={onTimelinePointerUp}
|
||||
onPointerCancel={onTimelinePointerUp}
|
||||
>
|
||||
<div
|
||||
className="review-session__timeline-fill"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="review-session__timeline-thumb"
|
||||
style={{ left: `${progressPct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
{replay.events.map((event) => {
|
||||
const left = Math.min(
|
||||
98,
|
||||
Math.max(2, (event.elapsedSeconds / replay.totalDuration) * 100),
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
className="review-session__timeline-tick"
|
||||
style={{ left: `${left}%`, background: eventKindColor(event.kind) }}
|
||||
aria-label={tooltipForEvent(event)}
|
||||
onPointerDown={(pointerEvent) => pointerEvent.stopPropagation()}
|
||||
onClick={() => replay.seek(event.elapsedSeconds)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="review-session__timeline-times">
|
||||
<span className="tnum">{formatReplayClock(replay.elapsedSeconds)}</span>
|
||||
<span className="tnum">{formatReplayClock(replay.totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review-session__deck">
|
||||
<button type="button" className="review-session__deck-btn" aria-label="Lùi 10 giây" onClick={() => replay.skip(-10)}>
|
||||
<SkipBackIcon />
|
||||
</button>
|
||||
<button type="button" className="review-session__deck-btn review-session__deck-btn--primary" aria-label={replay.isPlaying ? 'Tạm dừng' : 'Phát'} onClick={replay.togglePlay}>
|
||||
{replay.isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
<button type="button" className="review-session__deck-btn" aria-label="Tiến 10 giây" onClick={() => replay.skip(10)}>
|
||||
<SkipForwardIcon />
|
||||
</button>
|
||||
|
||||
<div className="review-session__speed">
|
||||
<button
|
||||
type="button"
|
||||
className="review-session__speed-btn tnum"
|
||||
aria-expanded={speedMenuOpen}
|
||||
onClick={() => setSpeedMenuOpen((open) => !open)}
|
||||
>
|
||||
{replay.speed}x
|
||||
</button>
|
||||
{speedMenuOpen && (
|
||||
<ul className="review-session__speed-menu" role="menu">
|
||||
{replay.speeds.map((value) => (
|
||||
<li key={value}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={value === replay.speed ? 'review-session__speed-option--active' : ''}
|
||||
onClick={() => {
|
||||
replay.setSpeed(value as PlaybackSpeed);
|
||||
setSpeedMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{value}x
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="review-session__carousel-block">
|
||||
<div className="review-session__carousel">
|
||||
<button
|
||||
type="button"
|
||||
className="review-session__carousel-nav"
|
||||
aria-label="Bước trước"
|
||||
disabled={replay.carouselIndex <= 0}
|
||||
onClick={() => replay.goCarousel(-1)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
<article className="review-session__event-card">
|
||||
{replay.activeEvent && (
|
||||
<>
|
||||
<p className="review-session__event-step-label">
|
||||
Hành động {replay.carouselIndex + 1} / {replay.totalSteps}
|
||||
</p>
|
||||
<label className="review-session__event-field">
|
||||
<span className="review-session__event-field-label">Tên hành động</span>
|
||||
<input
|
||||
key={`${activeEventId}-title`}
|
||||
type="text"
|
||||
className="review-session__event-title-input"
|
||||
value={activeTitleValue}
|
||||
onChange={(event) => persistEventEdit(activeEventId, 'title', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="review-session__event-field">
|
||||
<span className="review-session__event-field-label">Chi tiết</span>
|
||||
<textarea
|
||||
key={`${activeEventId}-detail`}
|
||||
rows={2}
|
||||
className="review-session__event-detail-input"
|
||||
value={activeDetailValue}
|
||||
onChange={(event) => persistEventEdit(activeEventId, 'detail', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="review-session__event-time tnum">
|
||||
Ghi nhận lúc: {formatRecordedTime(replay.activeEvent.recordedAt)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="review-session__carousel-nav"
|
||||
aria-label="Bước sau"
|
||||
disabled={replay.carouselIndex >= replay.totalSteps - 1}
|
||||
onClick={() => replay.goCarousel(1)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="review-session__step-note">
|
||||
<span className="review-session__step-note-label">Mô tả bước này (tự ghi trong phiên)</span>
|
||||
<textarea
|
||||
key={activeEventId}
|
||||
rows={3}
|
||||
value={activeStepNote}
|
||||
placeholder="Ví dụ: Bác sĩ xác nhận vùng tràn dịch suprapatellar và so sánh với ca trước…"
|
||||
onChange={(event) => persistStepNote(activeEventId, event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.review-session {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(30, 41, 59, 0.98);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.review-session__header {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.review-session__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #ffffff;
|
||||
}
|
||||
.review-session__tabs {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
}
|
||||
.review-session__tab-indicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: calc(50% - 4px);
|
||||
height: calc(100% - 8px);
|
||||
border-radius: 8px;
|
||||
background: #1a5f60;
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
.review-session__tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 10px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.review-session__tab--active {
|
||||
color: #ffffff;
|
||||
}
|
||||
.review-session__tab-icon {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.review-session__session-desc {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.review-session__session-desc-label,
|
||||
.review-session__step-note-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.review-session__session-desc textarea,
|
||||
.review-session__step-note textarea {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
}
|
||||
.review-session__session-desc textarea:focus,
|
||||
.review-session__step-note textarea:focus {
|
||||
outline: none;
|
||||
border-color: #76c8b1;
|
||||
box-shadow: 0 0 0 2px rgba(118, 200, 177, 0.25);
|
||||
}
|
||||
.review-session__stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.review-session__empty {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
.review-session__empty-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.review-session__empty-detail {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.review-session__empty-detail strong {
|
||||
color: #76c8b1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.review-session__trace-toggle {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.review-session__trace-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #76c8b1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-session__continuous {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.review-session__timeline-wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
.review-session__timeline-tooltip-slot {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.review-session__timeline {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
.review-session__timeline--scrubbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.review-session__timeline-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #1a5f60, #76c8b1);
|
||||
pointer-events: none;
|
||||
transition: width 0.08s linear;
|
||||
}
|
||||
.review-session__timeline--scrubbing .review-session__timeline-fill {
|
||||
transition: none;
|
||||
}
|
||||
.review-session__timeline--playing .review-session__timeline-fill,
|
||||
.review-session__timeline--playing .review-session__timeline-thumb {
|
||||
transition: none;
|
||||
}
|
||||
.review-session__timeline-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 2px solid #1a5f60;
|
||||
box-shadow: 0 0 0 4px rgba(26, 95, 96, 0.25);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
transition: left 0.08s linear, transform 0.12s ease;
|
||||
}
|
||||
.review-session__timeline--scrubbing .review-session__timeline-thumb {
|
||||
transition: none;
|
||||
transform: translate(-50%, -50%) scale(1.12);
|
||||
}
|
||||
.review-session__timeline-tick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.review-session__timeline-tooltip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
max-width: min(280px, 90vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.review-session__timeline-times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.review-session__deck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.review-session__deck-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
color: #e2e8f0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-session__deck-btn--primary {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #1a5f60;
|
||||
border-color: #1a5f60;
|
||||
color: #fff;
|
||||
}
|
||||
.review-session__speed {
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.review-session__speed-btn {
|
||||
min-width: 52px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-session__speed-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 72px;
|
||||
z-index: 5;
|
||||
}
|
||||
.review-session__speed-menu button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.review-session__speed-menu button:hover,
|
||||
.review-session__speed-option--active {
|
||||
background: rgba(26, 95, 96, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
.review-session__carousel-block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.review-session__carousel {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.review-session__carousel-nav {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
color: #e2e8f0;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.review-session__carousel-nav:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.review-session__event-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(118, 200, 177, 0.25);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.review-session__event-step-label {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #76c8b1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.review-session__event-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.review-session__event-field-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.review-session__event-title-input,
|
||||
.review-session__event-detail-input {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.review-session__event-title-input {
|
||||
font-weight: 600;
|
||||
color: #76c8b1;
|
||||
}
|
||||
.review-session__event-detail-input {
|
||||
resize: vertical;
|
||||
min-height: 52px;
|
||||
}
|
||||
.review-session__event-title-input:focus,
|
||||
.review-session__event-detail-input:focus {
|
||||
outline: none;
|
||||
border-color: #76c8b1;
|
||||
box-shadow: 0 0 0 2px rgba(118, 200, 177, 0.25);
|
||||
}
|
||||
.review-session__event-time {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.review-session__step-note {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/** Local copy — originals commented out in mockData.ts (overlay not mounted in App). */
|
||||
const SOCRATIC_PROMPTS = [
|
||||
'Vùng dày echo-free ở hố suprapatellar — bạn xác nhận đây là tràn dịch nhẹ hay mô pannus?',
|
||||
'Tăng sinh mạch Doppler grade 2 có phù hợp với viêm màng hoạt dịch độ 2 không?',
|
||||
'Có cấu trúc giả âm (acoustic shadow) nào cần loại trừ khỏi chấm điểm không?',
|
||||
];
|
||||
|
||||
const MORPHOLOGY_TEMPLATE = [
|
||||
{ id: 'effusion', label: 'Tràn dịch khớp', checked: false },
|
||||
{ id: 'synovium', label: 'Dày màng hoạt dịch', checked: true },
|
||||
{ id: 'pannus', label: 'Mô pannus', checked: false },
|
||||
{ id: 'erosion', label: 'Xói mòn xương', checked: false },
|
||||
{ id: 'shadow', label: 'Bóng âm thanh giả', checked: false },
|
||||
{ id: 'difference', label: 'Khác', checked: false },
|
||||
];
|
||||
|
||||
interface SafetyEscalationOverlayProps {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export default function SafetyEscalationOverlay({ onDismiss }: SafetyEscalationOverlayProps) {
|
||||
const [activePrompt, setActivePrompt] = useState(0);
|
||||
const [response, setResponse] = useState('');
|
||||
const [morphology, setMorphology] = useState(MORPHOLOGY_TEMPLATE);
|
||||
|
||||
const toggleMorphology = (id: string) => {
|
||||
setMorphology((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, checked: !item.checked } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="safety-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-title">
|
||||
<div className="safety-overlay__backdrop" />
|
||||
<div className="safety-overlay__panel glass-elevated">
|
||||
<header className="safety-overlay__header">
|
||||
<div>
|
||||
{/* <span className="chip chip-urgent">Chế độ Leo thang An toàn</span> */}
|
||||
<h2 id="safety-title">Đối thoại Socratic — Circuit Breaker</h2>
|
||||
<p>Đề xuất AI bị ẩn để tránh thiên kiến xác nhận</p>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button type="button" className="icon-btn" onClick={onDismiss} aria-label="Đóng">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="safety-overlay__body">
|
||||
<section className="safety-overlay__dialogue">
|
||||
<h3>Hội thoại Socratic</h3>
|
||||
<div className="safety-overlay__prompt glass">
|
||||
<span className="safety-overlay__prompt-label">LLM Explainer</span>
|
||||
<p>{SOCRATIC_PROMPTS[activePrompt]}</p>
|
||||
</div>
|
||||
<textarea
|
||||
className="safety-overlay__input"
|
||||
placeholder="Mô tả quan sát lâm sàng của bạn..."
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="safety-overlay__prompt-nav">
|
||||
{SOCRATIC_PROMPTS.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`safety-overlay__dot ${i === activePrompt ? 'safety-overlay__dot--active' : ''}`}
|
||||
onClick={() => setActivePrompt(i)}
|
||||
aria-label={`Câu hỏi ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="safety-overlay__morphology">
|
||||
<h3>Đánh giá giải phẫu thủ công</h3>
|
||||
<p className="safety-overlay__morph-desc">
|
||||
Liêụ mẫu có những đặc điểm cấu trúc như sau không - Nếu có, hãy chọn các dấu hiệu giải phẫu tương ứng?
|
||||
</p>
|
||||
<ul className="safety-overlay__checklist">
|
||||
{morphology.map((item) => (
|
||||
<li key={item.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => toggleMorphology(item.id)}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="safety-overlay__footer">
|
||||
<button type="button" className="btn btn-ghost" disabled>
|
||||
Ký & Hoàn tất (bị khóa)
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary">
|
||||
Xác nhận đánh giá thủ công
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.safety-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.safety-overlay__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(23, 29, 28, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.safety-overlay__panel {
|
||||
position: relative;
|
||||
width: min(720px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
.safety-overlay__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.safety-overlay__header h2 {
|
||||
font-size: 20px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.safety-overlay__header p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.safety-overlay__body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
.safety-overlay__dialogue h3,
|
||||
.safety-overlay__morphology h3 {
|
||||
font-size: 14px;
|
||||
color: var(--color-secondary);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.safety-overlay__morphology {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.safety-overlay__prompt {
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.safety-overlay__prompt-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-tertiary);
|
||||
}
|
||||
.safety-overlay__prompt p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.safety-overlay__input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
resize: vertical;
|
||||
}
|
||||
.safety-overlay__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.safety-overlay__prompt-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.safety-overlay__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--color-outline-variant);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.safety-overlay__dot--active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
.safety-overlay__morph-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.safety-overlay__checklist {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 4px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.safety-overlay__checklist::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.safety-overlay__checklist::-webkit-scrollbar-thumb {
|
||||
background: var(--color-outline-variant);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.safety-overlay__checklist label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.safety-overlay__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
.safety-overlay__footer .btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.safety-overlay__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AngleClassificationResult } from '../../data/angleClassification';
|
||||
import type { InflammationClassificationResult } from '../../data/inflammationClassification';
|
||||
import type { SynovitisSeverityResult } from '../../data/synovitisSeverity';
|
||||
import {
|
||||
loadCalibrationUserConfig,
|
||||
saveCalibrationUserConfig,
|
||||
type CalibrationUserConfig,
|
||||
} from '../../lib/calibrationEngine';
|
||||
import AngleClassificationBlob from '../molecules/AngleClassificationBlob';
|
||||
import CalibrationControls from '../molecules/CalibrationControls';
|
||||
import ClinicalChatPanel from '../molecules/ClinicalChatPanel';
|
||||
import SeverityBadge from '../molecules/SeverityBadge';
|
||||
import { ANGLE_CLASS_META } from '../../data/angleClassification';
|
||||
import DiagnosticFormDrawer from './DiagnosticFormDrawer';
|
||||
import { EMPTY_DIAGNOSTIC_FORM_DRAFT, type DiagnosticFormDraft } from '../../data/diagnosticForm';
|
||||
import {
|
||||
hasDiagnosticFormDraft,
|
||||
loadDiagnosticFormDraft,
|
||||
saveDiagnosticFormDraft,
|
||||
} from '../../lib/diagnosticFormDraftStore';
|
||||
|
||||
interface SideNavBarProps {
|
||||
severity: SynovitisSeverityResult | null;
|
||||
severityLoading?: boolean;
|
||||
inflammation: InflammationClassificationResult | null;
|
||||
inflammationLoading?: boolean;
|
||||
angleClassification?: AngleClassificationResult | null;
|
||||
patientName: string;
|
||||
patientMrn: string;
|
||||
patientJoint?: string;
|
||||
chatGrade?: number | null;
|
||||
onCaptureDiagnosisSnapshot?: () => Promise<string | null>;
|
||||
draftRevision?: number;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT = EMPTY_DIAGNOSTIC_FORM_DRAFT;
|
||||
|
||||
export default function SideNavBar({
|
||||
severity,
|
||||
severityLoading = false,
|
||||
inflammation,
|
||||
inflammationLoading = false,
|
||||
angleClassification,
|
||||
patientName,
|
||||
patientMrn,
|
||||
patientJoint,
|
||||
chatGrade = null,
|
||||
onCaptureDiagnosisSnapshot,
|
||||
draftRevision = 0,
|
||||
}: SideNavBarProps) {
|
||||
const [userConfig, setUserConfig] = useState<CalibrationUserConfig>(loadCalibrationUserConfig);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formDraft, setFormDraft] = useState<DiagnosticFormDraft>(EMPTY_DRAFT);
|
||||
const [hasStoredDraft, setHasStoredDraft] = useState(false);
|
||||
const persistDraftTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
saveCalibrationUserConfig(userConfig);
|
||||
}, [userConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void hasDiagnosticFormDraft(patientMrn).then((exists) => {
|
||||
if (!cancelled) setHasStoredDraft(exists);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [patientMrn, draftRevision]);
|
||||
|
||||
const persistDraft = useCallback(
|
||||
async (draft: DiagnosticFormDraft) => {
|
||||
const saved = await saveDiagnosticFormDraft(patientMrn, draft);
|
||||
setHasStoredDraft(saved);
|
||||
},
|
||||
[patientMrn],
|
||||
);
|
||||
|
||||
const schedulePersistDraft = useCallback(
|
||||
(draft: DiagnosticFormDraft) => {
|
||||
if (persistDraftTimeoutRef.current) clearTimeout(persistDraftTimeoutRef.current);
|
||||
persistDraftTimeoutRef.current = setTimeout(() => {
|
||||
persistDraftTimeoutRef.current = null;
|
||||
void persistDraft(draft);
|
||||
}, 400);
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (persistDraftTimeoutRef.current) clearTimeout(persistDraftTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(draft: DiagnosticFormDraft) => {
|
||||
setFormDraft(draft);
|
||||
if (formOpen) schedulePersistDraft(draft);
|
||||
},
|
||||
[formOpen, schedulePersistDraft],
|
||||
);
|
||||
|
||||
const hasCalibratableOutput = Boolean(
|
||||
inflammation?.calibration?.raw_logits?.length ||
|
||||
angleClassification?.calibration?.raw_logits?.length,
|
||||
);
|
||||
|
||||
const angleLabel = angleClassification
|
||||
? ANGLE_CLASS_META[angleClassification.class].labelVi
|
||||
: null;
|
||||
|
||||
const openDiagnosticForm = async () => {
|
||||
const stored = await loadDiagnosticFormDraft(patientMrn);
|
||||
const base = stored ?? EMPTY_DRAFT;
|
||||
let snapshot = base.diagnosisWorkImageDataUrl;
|
||||
if (!snapshot && onCaptureDiagnosisSnapshot) {
|
||||
snapshot = await onCaptureDiagnosisSnapshot();
|
||||
}
|
||||
setFormDraft({ ...base, diagnosisWorkImageDataUrl: snapshot });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeDiagnosticForm = () => {
|
||||
if (persistDraftTimeoutRef.current) {
|
||||
clearTimeout(persistDraftTimeoutRef.current);
|
||||
persistDraftTimeoutRef.current = null;
|
||||
}
|
||||
void persistDraft(formDraft);
|
||||
setFormOpen(false);
|
||||
};
|
||||
|
||||
const saveDiagnosticForm = () => {
|
||||
if (persistDraftTimeoutRef.current) {
|
||||
clearTimeout(persistDraftTimeoutRef.current);
|
||||
persistDraftTimeoutRef.current = null;
|
||||
}
|
||||
void persistDraft(formDraft);
|
||||
setFormOpen(false);
|
||||
};
|
||||
|
||||
const refreshDiagnosisSnapshot = async (): Promise<boolean> => {
|
||||
const snapshot = onCaptureDiagnosisSnapshot
|
||||
? await onCaptureDiagnosisSnapshot()
|
||||
: null;
|
||||
if (snapshot) {
|
||||
setFormDraft((prev) => ({ ...prev, diagnosisWorkImageDataUrl: snapshot }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="side-nav glass-elevated">
|
||||
<section className="side-nav__section">
|
||||
<h3>Đề xuất AI</h3>
|
||||
|
||||
{hasCalibratableOutput && (
|
||||
<CalibrationControls config={userConfig} onChange={setUserConfig} />
|
||||
)}
|
||||
|
||||
<SeverityBadge
|
||||
severity={severity}
|
||||
severityLoading={severityLoading}
|
||||
inflammation={inflammation}
|
||||
inflammationLoading={inflammationLoading}
|
||||
userConfig={userConfig}
|
||||
/>
|
||||
<AngleClassificationBlob
|
||||
classification={angleClassification ?? null}
|
||||
userConfig={userConfig}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="side-nav__section side-nav__section--chat">
|
||||
<h3>Thảo luận kết quả</h3>
|
||||
<p className="side-nav__intro">
|
||||
Nếu đề xuất AI chưa thuyết phục hoặc cần làm rõ, trao đổi trực tiếp tại đây — không cần dừng quy trình.
|
||||
</p>
|
||||
<ClinicalChatPanel
|
||||
key={patientMrn}
|
||||
grade={chatGrade}
|
||||
angleClass={angleClassification?.class ?? null}
|
||||
severityLoading={severityLoading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="side-nav__section">
|
||||
<h3>Thao tác nhanh</h3>
|
||||
<div className="side-nav__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void openDiagnosticForm()}
|
||||
>
|
||||
{hasStoredDraft ? 'Sửa phiếu' : 'Lập phiếu'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="side-nav__canvas-hint">
|
||||
Để đánh dấu hoặc bao vùng trên ảnh siêu âm, dùng thanh công cụ nổi trên khung hình (Vẽ, vùng khép kín, Tẩy).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>{`
|
||||
.side-nav {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
font-size: 1em;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.side-nav__section h3 {
|
||||
font-size: 0.875em;
|
||||
font-weight: 600;
|
||||
color: var(--color-secondary);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.side-nav__intro {
|
||||
font-size: 0.8125em;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.side-nav__section--chat {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.side-nav__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.side-nav__actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.side-nav__actions .btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.side-nav__canvas-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
|
||||
<DiagnosticFormDrawer
|
||||
open={formOpen}
|
||||
patientName={patientName}
|
||||
patientMrn={patientMrn}
|
||||
jointLabel={patientJoint?.toLowerCase() ?? 'khớp gối'}
|
||||
aiGrade={severity?.level}
|
||||
angleLabel={angleLabel}
|
||||
draft={formDraft}
|
||||
onDraftChange={handleDraftChange}
|
||||
onClose={closeDiagnosticForm}
|
||||
onSave={saveDiagnosticForm}
|
||||
onRefreshSnapshot={refreshDiagnosisSnapshot}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
export type SidebarLayerId = 'review' | 'diagnosis';
|
||||
|
||||
interface SidebarLayerCarouselProps {
|
||||
reviewLayer: ReactNode;
|
||||
diagnosisLayer: ReactNode;
|
||||
activeLayer?: SidebarLayerId;
|
||||
onLayerChange?: (layer: SidebarLayerId) => void;
|
||||
}
|
||||
|
||||
function ChevronUpIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidebarLayerCarousel({
|
||||
reviewLayer,
|
||||
diagnosisLayer,
|
||||
activeLayer: controlledLayer,
|
||||
onLayerChange,
|
||||
}: SidebarLayerCarouselProps) {
|
||||
const [internalLayer, setInternalLayer] = useState<SidebarLayerId>('diagnosis');
|
||||
const activeLayer = controlledLayer ?? internalLayer;
|
||||
|
||||
const setLayer = (layer: SidebarLayerId) => {
|
||||
if (onLayerChange) onLayerChange(layer);
|
||||
else setInternalLayer(layer);
|
||||
};
|
||||
|
||||
const rotateForward = () => setLayer(activeLayer === 'review' ? 'diagnosis' : 'review');
|
||||
const backgroundLayer: SidebarLayerId = activeLayer === 'review' ? 'diagnosis' : 'review';
|
||||
const backgroundLabel =
|
||||
backgroundLayer === 'review' ? 'Xem lại phiên chẩn đoán' : 'Thông tin chẩn đoán';
|
||||
|
||||
return (
|
||||
<div className="sidebar-carousel">
|
||||
<div className="sidebar-carousel__nav">
|
||||
<button type="button" className="sidebar-carousel__nav-btn" aria-label="Xoay lớp lên" onClick={rotateForward}>
|
||||
<ChevronUpIcon />
|
||||
</button>
|
||||
<button type="button" className="sidebar-carousel__nav-btn" aria-label="Xoay lớp xuống" onClick={rotateForward}>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-carousel__stack">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-carousel__peek sidebar-carousel__peek--${backgroundLayer}`}
|
||||
aria-label={`Chuyển sang ${backgroundLabel}`}
|
||||
onClick={() => setLayer(backgroundLayer)}
|
||||
>
|
||||
<span className="sidebar-carousel__peek-label">{backgroundLabel}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`sidebar-carousel__foreground sidebar-carousel__foreground--${activeLayer}`}
|
||||
>
|
||||
<div className="sidebar-carousel__foreground-inner">
|
||||
{/* Keep both layers mounted so heavy sub-trees (e.g. local LLM chat) are not torn down on card switch. */}
|
||||
<div
|
||||
className={`sidebar-carousel__layer${activeLayer === 'review' ? ' sidebar-carousel__layer--active' : ''}`}
|
||||
aria-hidden={activeLayer !== 'review'}
|
||||
>
|
||||
{reviewLayer}
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-carousel__layer${activeLayer === 'diagnosis' ? ' sidebar-carousel__layer--active' : ''}`}
|
||||
aria-hidden={activeLayer !== 'diagnosis'}
|
||||
>
|
||||
{diagnosisLayer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.sidebar-carousel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar-carousel__nav {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
right: 8px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.sidebar-carousel__nav-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--color-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.sidebar-carousel__nav-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.sidebar-carousel__stack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(5, 107, 88, 0.18);
|
||||
}
|
||||
.sidebar-carousel__peek {
|
||||
flex: 0 0 28px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
}
|
||||
.sidebar-carousel__peek--review {
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.sidebar-carousel__peek--diagnosis {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
color: var(--color-on-surface-variant);
|
||||
border-bottom: 1px solid rgba(5, 107, 88, 0.12);
|
||||
}
|
||||
.sidebar-carousel__peek-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar-carousel__foreground {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -4px 20px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
.sidebar-carousel__foreground--review {
|
||||
background: rgba(30, 41, 59, 0.98);
|
||||
}
|
||||
.sidebar-carousel__foreground--diagnosis {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.sidebar-carousel__foreground-inner {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-carousel__layer {
|
||||
display: none;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.sidebar-carousel__layer--active {
|
||||
display: block;
|
||||
}
|
||||
.sidebar-carousel__foreground--diagnosis .side-nav {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
export const PATIENT_PROFILE_MODAL_STYLES = `
|
||||
.patient-profile-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.patient-profile-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.patient-profile-modal__sheet {
|
||||
position: relative;
|
||||
width: min(640px, 100%);
|
||||
max-height: min(90vh, 880px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
.patient-profile-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 32px 32px 0;
|
||||
}
|
||||
.patient-profile-modal__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.patient-profile-modal__close {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.patient-profile-modal__body {
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.patient-profile-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.patient-profile-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.patient-profile-modal__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.patient-profile-modal__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.patient-profile-modal__field input,
|
||||
.patient-profile-modal__field select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: #fff;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.patient-profile-modal__field input:focus,
|
||||
.patient-profile-modal__field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(5, 107, 88, 0.15);
|
||||
}
|
||||
.patient-profile-modal__status-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.patient-profile-modal__status-pill {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
opacity: 0.72;
|
||||
transition: opacity 0.15s, border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.patient-profile-modal__status-pill:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
.patient-profile-modal__status-pill--active {
|
||||
opacity: 1;
|
||||
border-color: currentColor;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.patient-profile-modal__dropzone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.patient-profile-modal__dropzone--active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(5, 107, 88, 0.06);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.patient-profile-modal__dropzone-primary {
|
||||
margin: 12px 0 4px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.patient-profile-modal__dropzone-secondary {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.patient-profile-modal__browse-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.patient-profile-modal__browse-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.patient-profile-modal__dropzone-caption {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
max-width: 420px;
|
||||
margin-inline: auto;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.patient-profile-modal__file-error {
|
||||
margin: -8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-error);
|
||||
}
|
||||
.patient-profile-modal__inventory-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
.patient-profile-modal__inventory-loading,
|
||||
.patient-profile-modal__inventory-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-style: italic;
|
||||
}
|
||||
.patient-profile-modal__file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.patient-profile-modal__file-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.patient-profile-modal__file-item--deleted {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.patient-profile-modal__file-item--deleted .patient-profile-modal__file-name,
|
||||
.patient-profile-modal__file-item--deleted .patient-profile-modal__file-size {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.patient-profile-modal__file-item--new {
|
||||
border-color: rgba(5, 107, 88, 0.35);
|
||||
background: rgba(118, 200, 177, 0.12);
|
||||
}
|
||||
.patient-profile-modal__file-icon {
|
||||
color: var(--color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.patient-profile-modal__file-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.patient-profile-modal__file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.patient-profile-modal__file-size {
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.patient-profile-modal__new-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--color-primary-container);
|
||||
color: var(--color-on-primary-container);
|
||||
text-decoration: none;
|
||||
}
|
||||
.patient-profile-modal__delete-badge {
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.patient-profile-modal__progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-container-high);
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.patient-profile-modal__progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-container));
|
||||
transition: width 0.12s ease-out;
|
||||
}
|
||||
.patient-profile-modal__file-remove,
|
||||
.patient-profile-modal__trash-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.patient-profile-modal__trash-btn {
|
||||
color: var(--color-error);
|
||||
border-color: rgba(186, 26, 26, 0.25);
|
||||
}
|
||||
.patient-profile-modal__trash-btn:hover:not(:disabled) {
|
||||
background: var(--color-error-container);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
.patient-profile-modal__undo-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 8px;
|
||||
color: var(--color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.patient-profile-modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 0 32px 32px;
|
||||
border-top: 1px solid var(--color-outline-variant);
|
||||
padding-top: 20px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.patient-profile-modal__spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: patient-profile-modal-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes patient-profile-modal-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.patient-profile-modal__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.patient-profile-modal__header,
|
||||
.patient-profile-modal__body,
|
||||
.patient-profile-modal__footer {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PatientListShellProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filter: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
summary: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ value: 'all', label: 'Tất cả' },
|
||||
{ value: 'urgent', label: 'Khẩn cấp' },
|
||||
{ value: 'processing', label: 'Đang xử lý' },
|
||||
{ value: 'followup', label: 'Theo dõi' },
|
||||
];
|
||||
|
||||
export default function PatientListShell({
|
||||
search,
|
||||
onSearchChange,
|
||||
filter,
|
||||
onFilterChange,
|
||||
summary,
|
||||
children,
|
||||
}: PatientListShellProps) {
|
||||
return (
|
||||
<div className="patient-list-shell">
|
||||
<section className="patient-list-shell__hero">
|
||||
<div>
|
||||
<h2>Danh sách công việc</h2>
|
||||
<p>Quản lý hàng đợi lâm sàng và khởi tạo phiên quét siêu âm</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="patient-list-shell__summary">{summary}</section>
|
||||
|
||||
<section className="patient-list-shell__toolbar glass">
|
||||
<input
|
||||
type="search"
|
||||
className="patient-list-shell__search"
|
||||
placeholder="Tìm bệnh nhân, mã BN..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
aria-label="Tìm kiếm bệnh nhân"
|
||||
/>
|
||||
<div className="patient-list-shell__filters" role="tablist" aria-label="Bộ lọc trạng thái">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === f.value}
|
||||
className={`patient-list-shell__filter ${filter === f.value ? 'patient-list-shell__filter--active' : ''}`}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="patient-list-shell__grid">{children}</section>
|
||||
|
||||
<style>{`
|
||||
.patient-list-shell {
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.patient-list-shell__hero h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
.patient-list-shell__hero p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.patient-list-shell__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-lg) 0;
|
||||
}
|
||||
.patient-list-shell__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.patient-list-shell__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.patient-list-shell__search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(5, 107, 88, 0.15);
|
||||
}
|
||||
.patient-list-shell__filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.patient-list-shell__filter {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
.patient-list-shell__filter--active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.patient-list-shell__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.patient-list-shell__summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
interface WorkspaceShellProps {
|
||||
zoneA: ReactNode;
|
||||
zoneB: ReactNode;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'lumina-workspace-zone-a-ratio';
|
||||
const ZONE_A_DEFAULT = 0.6;
|
||||
const ZONE_A_MIN = 0.48;
|
||||
const ZONE_A_MAX = 0.78;
|
||||
const DIVIDER_WIDTH_PX = 10;
|
||||
const BASELINE_ZONE_B_RATIO = 1 - ZONE_A_DEFAULT;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function readStoredRatio(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return ZONE_A_DEFAULT;
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? clamp(parsed, ZONE_A_MIN, ZONE_A_MAX) : ZONE_A_DEFAULT;
|
||||
} catch {
|
||||
return ZONE_A_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
function panelScaleFromZoneARatio(zoneARatio: number): number {
|
||||
const zoneBRatio = 1 - zoneARatio;
|
||||
return clamp(zoneBRatio / BASELINE_ZONE_B_RATIO, 0.82, 1.15);
|
||||
}
|
||||
|
||||
export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const [zoneARatio, setZoneARatio] = useState(readStoredRatio);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDividerHovered, setIsDividerHovered] = useState(false);
|
||||
const [isWideLayout, setIsWideLayout] = useState(true);
|
||||
|
||||
const panelScale = panelScaleFromZoneARatio(zoneARatio);
|
||||
const showBlob = isDividerHovered || isDragging;
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, String(zoneARatio));
|
||||
}, [zoneARatio]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(min-width: 1025px)');
|
||||
const update = () => setIsWideLayout(media.matches);
|
||||
update();
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
const updateRatioFromPointer = useCallback((clientX: number) => {
|
||||
const shell = shellRef.current;
|
||||
if (!shell) return;
|
||||
|
||||
const rect = shell.getBoundingClientRect();
|
||||
const padding = 16;
|
||||
const innerWidth = rect.width - padding * 2;
|
||||
const usable = innerWidth - DIVIDER_WIDTH_PX;
|
||||
if (usable <= 0) return;
|
||||
|
||||
const pointerX = clientX - rect.left - padding;
|
||||
setZoneARatio(clamp(pointerX / usable, ZONE_A_MIN, ZONE_A_MAX));
|
||||
}, []);
|
||||
|
||||
const onBlobPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isWideLayout) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setIsDragging(true);
|
||||
updateRatioFromPointer(event.clientX);
|
||||
};
|
||||
|
||||
const onBlobPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDragging) return;
|
||||
updateRatioFromPointer(event.clientX);
|
||||
};
|
||||
|
||||
const onBlobPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDragging) return;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const onBlobDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
setZoneARatio(ZONE_A_DEFAULT);
|
||||
};
|
||||
|
||||
const onDividerMouseLeave = () => {
|
||||
if (!isDragging) {
|
||||
setIsDividerHovered(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shellStyle = {
|
||||
'--workspace-zone-a-pct': `${(zoneARatio * 100).toFixed(2)}%`,
|
||||
'--workspace-panel-scale': String(panelScale),
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={shellRef}
|
||||
className={`workspace-shell ${isDragging ? 'workspace-shell--dragging' : ''}`}
|
||||
style={shellStyle}
|
||||
>
|
||||
<div className="workspace-shell__zone-a">{zoneA}</div>
|
||||
|
||||
{isWideLayout && (
|
||||
<div
|
||||
className="workspace-shell__divider"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={Math.round(zoneARatio * 100)}
|
||||
aria-valuemin={Math.round(ZONE_A_MIN * 100)}
|
||||
aria-valuemax={Math.round(ZONE_A_MAX * 100)}
|
||||
aria-label="Vùng chia khung siêu âm và bảng phụ"
|
||||
onMouseEnter={() => setIsDividerHovered(true)}
|
||||
onMouseLeave={onDividerMouseLeave}
|
||||
>
|
||||
{showBlob && (
|
||||
<div
|
||||
className="workspace-shell__divider-blob glass-elevated"
|
||||
role="slider"
|
||||
aria-label="Kéo để điều chỉnh độ rộng khung siêu âm và bảng phụ"
|
||||
aria-valuenow={Math.round(zoneARatio * 100)}
|
||||
aria-valuemin={Math.round(ZONE_A_MIN * 100)}
|
||||
aria-valuemax={Math.round(ZONE_A_MAX * 100)}
|
||||
onPointerDown={onBlobPointerDown}
|
||||
onPointerMove={onBlobPointerMove}
|
||||
onPointerUp={onBlobPointerUp}
|
||||
onPointerCancel={onBlobPointerUp}
|
||||
onDoubleClick={onBlobDoubleClick}
|
||||
>
|
||||
<span className="workspace-shell__divider-blob-grip" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="workspace-shell__zone-b">{zoneB}</div>
|
||||
|
||||
<style>{`
|
||||
.workspace-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-md);
|
||||
user-select: ${isDragging ? 'none' : 'auto'};
|
||||
}
|
||||
.workspace-shell--dragging {
|
||||
cursor: col-resize;
|
||||
}
|
||||
.workspace-shell__zone-a {
|
||||
flex: 0 0 var(--workspace-zone-a-pct);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: flex-basis 0.05s linear;
|
||||
}
|
||||
.workspace-shell--dragging .workspace-shell__zone-a {
|
||||
transition: none;
|
||||
}
|
||||
.workspace-shell__zone-b {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: calc(1rem * var(--workspace-panel-scale, 1));
|
||||
}
|
||||
.workspace-shell__divider {
|
||||
position: relative;
|
||||
flex: 0 0 ${DIVIDER_WIDTH_PX}px;
|
||||
margin: 0 2px;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
.workspace-shell__divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
bottom: 8%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2px;
|
||||
background: var(--color-outline-variant);
|
||||
transition: background 0.15s, width 0.15s;
|
||||
}
|
||||
.workspace-shell__divider:hover::before,
|
||||
.workspace-shell--dragging .workspace-shell__divider::before {
|
||||
background: var(--color-secondary);
|
||||
width: 3px;
|
||||
}
|
||||
.workspace-shell__divider-blob {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
z-index: 3;
|
||||
animation: workspace-divider-blob-in 0.15s ease-out;
|
||||
}
|
||||
@keyframes workspace-divider-blob-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.92);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
.workspace-shell__divider-blob-grip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
.workspace-shell__divider-blob-grip span {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.workspace-shell {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.workspace-shell__zone-a,
|
||||
.workspace-shell__zone-b {
|
||||
flex: 1 1 auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { SegmentationAngleType } from './segmentationLegend';
|
||||
import type { CalibratedPrediction, DecisionState } from '../types/calibratedPrediction';
|
||||
import {
|
||||
estimateMisclassificationRate,
|
||||
formatRiskFramingVi,
|
||||
} from '../types/calibratedPrediction';
|
||||
|
||||
/** Angle labels returned by the angle classifier (legacy API §1.1). */
|
||||
export const ANGLE_CLASSES = ['med-lat', 'post-trans', 'sup-trans-flex', 'sup-up-long'] as const;
|
||||
|
||||
export type AngleClass = (typeof ANGLE_CLASSES)[number];
|
||||
|
||||
export interface AngleClassMeta {
|
||||
class: AngleClass;
|
||||
/** Clinician-facing English name (not the ML class slug). */
|
||||
labelMedicalEn: string;
|
||||
/** Clinician-facing Vietnamese name. */
|
||||
labelVi: string;
|
||||
/** Short plane description for legend switch guide (parenthetical). */
|
||||
legendPlaneVi: string;
|
||||
/** Legend catalog for this angle (Phụ lục A — SUP vs POST). */
|
||||
segmentLegendType: SegmentationAngleType;
|
||||
/** Whether thickness overlay notes apply (sup-up-long only per §1.1). */
|
||||
supportsThicknessMeasurement: boolean;
|
||||
}
|
||||
|
||||
export const ANGLE_CLASS_META: Record<AngleClass, AngleClassMeta> = {
|
||||
'med-lat': {
|
||||
class: 'med-lat',
|
||||
labelMedicalEn: 'Medial Lat',
|
||||
labelVi: 'Góc quét mặt trong mặt ngoài',
|
||||
legendPlaneVi: 'Mặt cắt trong — ngoài',
|
||||
segmentLegendType: 'sup',
|
||||
supportsThicknessMeasurement: false,
|
||||
},
|
||||
'post-trans': {
|
||||
class: 'post-trans',
|
||||
labelMedicalEn: 'Posterior Transverse',
|
||||
labelVi: 'Góc quét mặt sau ngang',
|
||||
legendPlaneVi: 'Mặt cắt ngang vùng khoeo / mặt sau',
|
||||
segmentLegendType: 'post',
|
||||
supportsThicknessMeasurement: false,
|
||||
},
|
||||
'sup-trans-flex': {
|
||||
class: 'sup-trans-flex',
|
||||
labelMedicalEn: 'Suprapatellar Transverse Flexed',
|
||||
labelVi: 'Góc quét cắt ngang trên xương bánh chè ở tư thế gấp gối',
|
||||
legendPlaneVi: 'Mặt cắt ngang trên bánh chè — tư thế gấp gối',
|
||||
segmentLegendType: 'sup',
|
||||
supportsThicknessMeasurement: false,
|
||||
},
|
||||
'sup-up-long': {
|
||||
class: 'sup-up-long',
|
||||
labelMedicalEn: 'Suprapatellar Upper Longitudinal',
|
||||
labelVi: 'Góc quét cắt dọc phía trên xương bánh chè',
|
||||
legendPlaneVi: 'Mặt cắt dọc phía trên bánh chè',
|
||||
segmentLegendType: 'sup',
|
||||
supportsThicknessMeasurement: true,
|
||||
},
|
||||
};
|
||||
|
||||
export interface AngleClassificationResult {
|
||||
class: AngleClass;
|
||||
/** Primary class probability in 0–1 (legacy compat). */
|
||||
confidence: number;
|
||||
calibration?: CalibratedPrediction;
|
||||
}
|
||||
|
||||
function shannonEntropy(probs: number[]): number {
|
||||
return -probs.reduce((sum, p) => sum + (p > 0 ? p * Math.log(p) : 0), 0);
|
||||
}
|
||||
|
||||
/** Demo calibration until validation-set error bins are wired from data science. */
|
||||
export function buildMockAngleCalibration(
|
||||
winner: AngleClass,
|
||||
confidence: number,
|
||||
): CalibratedPrediction {
|
||||
const others = ANGLE_CLASSES.filter((c) => c !== winner);
|
||||
const remainder = Math.max(0, 1 - confidence);
|
||||
const perOther = remainder / others.length;
|
||||
const rawProbs = ANGLE_CLASSES.map((c) => (c === winner ? confidence : perOther));
|
||||
const normEntropy = shannonEntropy(rawProbs) / Math.log(ANGLE_CLASSES.length);
|
||||
const classProbabilities = Object.fromEntries(
|
||||
ANGLE_CLASSES.map((c, i) => [c, Math.round(rawProbs[i]! * 10000) / 100]),
|
||||
);
|
||||
const ambiguousSet = ANGLE_CLASSES.filter((c) => {
|
||||
const p = rawProbs[ANGLE_CLASSES.indexOf(c)]!;
|
||||
return p >= confidence - 0.05;
|
||||
});
|
||||
let decisionState: DecisionState = 'confident';
|
||||
if (normEntropy >= 0.88) {
|
||||
decisionState = 'ood_warning';
|
||||
} else if (ambiguousSet.length > 1) {
|
||||
decisionState = 'ambiguous';
|
||||
}
|
||||
const errorRate = estimateMisclassificationRate(confidence);
|
||||
const meta = getAngleMeta(winner);
|
||||
const winnerLogit = Math.log(confidence / (1 - confidence + 1e-6));
|
||||
const otherLogit = Math.log((perOther / (1 - perOther + 1e-6)) + 1e-6);
|
||||
return {
|
||||
raw_logits: ANGLE_CLASSES.map((c) => (c === winner ? winnerLogit : otherLogit)),
|
||||
temperature: 1.4,
|
||||
base_temperature: 1,
|
||||
mode: 'standard',
|
||||
clinical_suspicion: 0,
|
||||
alpha_margin: 0.05,
|
||||
class_probabilities: classProbabilities,
|
||||
entropy: shannonEntropy(rawProbs),
|
||||
normalized_entropy: normEntropy,
|
||||
ambiguous_set: ambiguousSet,
|
||||
decision_state: decisionState,
|
||||
predicted_error_rate: errorRate,
|
||||
risk_framing_vi: formatRiskFramingVi(formatAngleClinicalLabel(meta), confidence, decisionState),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Detached mock angle map (prototyping uses /api/test/analyze/batch only) ---
|
||||
// function withMockCalibration(result: Omit<AngleClassificationResult, 'calibration'>): AngleClassificationResult {
|
||||
// return { ...result, calibration: buildMockAngleCalibration(result.class, result.confidence) };
|
||||
// }
|
||||
// export const MOCK_ANGLE_BY_FRAME: Record<string, AngleClassificationResult> = {
|
||||
// 'med-lat-1': withMockCalibration({ class: 'med-lat', confidence: 0.94 }),
|
||||
// 'med-lat-2': withMockCalibration({ class: 'med-lat', confidence: 0.91 }),
|
||||
// 'trans-flex': withMockCalibration({ class: 'sup-trans-flex', confidence: 0.88 }),
|
||||
// };
|
||||
// export function getMockAngleForFrame(frameId: string): AngleClassificationResult | null {
|
||||
// return MOCK_ANGLE_BY_FRAME[frameId] ?? null;
|
||||
// }
|
||||
|
||||
export function isAngleClass(value: string): value is AngleClass {
|
||||
return (ANGLE_CLASSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getAngleMeta(angleClass: AngleClass): AngleClassMeta {
|
||||
return ANGLE_CLASS_META[angleClass];
|
||||
}
|
||||
|
||||
/** UI label: `{English medical name} : {Vietnamese clinical name}` */
|
||||
export function formatAngleClinicalLabel(meta: AngleClassMeta): string {
|
||||
return `${meta.labelMedicalEn} : ${meta.labelVi}`;
|
||||
}
|
||||
|
||||
export function getSegmentLegendTypeForAngle(angleClass: AngleClass): SegmentationAngleType {
|
||||
return getAngleMeta(angleClass).segmentLegendType;
|
||||
}
|
||||
|
||||
export function angleSupportsThicknessMeasurement(angleClass: AngleClass): boolean {
|
||||
return getAngleMeta(angleClass).supportsThicknessMeasurement;
|
||||
}
|
||||
|
||||
export function normalizeBackendAngle(
|
||||
raw:
|
||||
| {
|
||||
class: string;
|
||||
confidence: number | null;
|
||||
calibration?: CalibratedPrediction;
|
||||
}
|
||||
| undefined,
|
||||
): AngleClassificationResult | null {
|
||||
if (raw?.class && isAngleClass(raw.class)) {
|
||||
const confidence =
|
||||
raw.confidence != null
|
||||
? raw.confidence > 1
|
||||
? raw.confidence / 100
|
||||
: raw.confidence
|
||||
: 0;
|
||||
return {
|
||||
class: raw.class,
|
||||
confidence,
|
||||
calibration: raw.calibration,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Clinician-facing help — Feynman narrative + 5 core points + citations. */
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_TRIGGER = 'Giải thích cách tính';
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_NARRATIVE_TITLE = 'Câu chuyện ẩn dụ';
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_NARRATIVE = [
|
||||
'Hãy tưởng tượng mô hình AI giống như một vị bác sĩ nội trú trẻ tuổi, thiên tài nhưng vô cùng ngạo mạn. Vị bác sĩ này đọc sách rất nhiều, ghi nhớ chuẩn xác các triệu chứng viêm nhiễm (độ chính xác cao), nhưng có một nhược điểm lớn: luôn nói quá lên độ tự tin của mình. Khi thấy một ca bệnh hơi giống viêm nhiễm, thay vì bảo “Tôi nghĩ có 70% khả năng viêm”, anh ta luôn dõng dạc tuyên bố “Tôi chắc chắn 99,9%!”. Sự tự tin thái quá này cực kỳ nguy hiểm trong lâm sàng, vì nó khiến chúng ta mất cảnh giác ở những ca ranh giới.',
|
||||
'Để kiểm soát sự kiêu ngạo này, bệnh viện cần một bác sĩ trưởng khoa trưởng thành và điềm đạm — chính là tham số Nhiệt độ (T). Bác sĩ trưởng khoa không thay đổi chẩn đoán hay kiến thức của bác sĩ trẻ, ông chỉ lắng nghe những “lập luận thô” (logits) của anh ta, rồi chủ động “làm dịu” (scale bằng T) sự hung hăng đó lại. Nhờ đó, con số phần trăm cuối cùng trả về cho bạn sẽ phản ánh đúng thực tế lâm sàng: ca nào rõ ràng thì vẫn là 99%, nhưng ca nào mập mờ sẽ bị kéo về đúng mức 70% hoặc rơi vào “Vùng lưỡng lự” để con người nhảy vào hội chẩn.',
|
||||
] as const;
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_KEY_POINTS_TITLE =
|
||||
'Năm điểm cốt lõi — con số trên màn hình đến từ đâu';
|
||||
|
||||
export interface CalibrationKeyPoint {
|
||||
title: string;
|
||||
body: string;
|
||||
subPoints?: readonly string[];
|
||||
}
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_KEY_POINTS: readonly CalibrationKeyPoint[] = [
|
||||
{
|
||||
title: 'Điểm số thô không phải là phần trăm',
|
||||
body:
|
||||
'AI không trực tiếp nghĩ ra con số 90% hay 99%. Đầu tiên, nó chấm điểm toán học thô (logits) cho từng khả năng — ví dụ Viêm được 8 điểm, Không viêm được 2 điểm.',
|
||||
},
|
||||
{
|
||||
title: 'Cơ chế bóp méo của AI hiện đại',
|
||||
body:
|
||||
'Các mạng càng sâu và thông minh thì xu hướng phân tách các điểm thô càng xa (ví dụ 20 điểm và 1 điểm). Khi quy đổi, khoảng cách quá lớn này bị ép thành xác suất cực đoan như 99,9% — hiện tượng “quá tự tin” (over-confidence).',
|
||||
},
|
||||
{
|
||||
title: 'Bác sĩ trưởng khoa hạ hỏa (tham số T)',
|
||||
body:
|
||||
'Trước khi chuyển điểm thô thành phần trăm, ta chia tất cả các điểm thô cho hệ số T (Nhiệt độ). Nếu T > 1, khoảng cách phóng đại giữa các điểm thô sẽ bị thu hẹp lại một cách toán học.',
|
||||
},
|
||||
{
|
||||
title: 'Quy đổi ra xác suất thực (Softmax)',
|
||||
body:
|
||||
'Sau khi được làm dịu bởi T, các điểm thô mới được chuẩn hóa bằng Softmax — quy đổi thành tỷ lệ phần trăm có tổng bằng 100%. Đây mới là con số hiển thị trên màn hình của bạn.',
|
||||
},
|
||||
{
|
||||
title: 'Điều chỉnh theo bối cảnh lâm sàng',
|
||||
body: 'Thanh trượt T và ba bậc hiệu chuẩn phía trên cho phép bạn chọn mức “dịu” hay “sắc” phù hợp bối cảnh:',
|
||||
subPoints: [
|
||||
'T cao (làm mịn): mô hình thận trọng hơn, kéo phần trăm ảo tưởng xuống gần thực tế — phù hợp khi kiểm tra chéo khắt khe.',
|
||||
'T thấp (làm sắc): mô hình nhạy bén, quyết đoán hơn — hữu ích khi sàng lọc diện rộng để không bỏ sót dấu hiệu nghi ngờ.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_FOUNDATION =
|
||||
'Guo và cộng sự (2017), trong bài On Calibration of Modern Neural Networks (ICML), cho thấy mạng sâu hiện đại thường quá tự tin và đề xuất temperature scaling — hiệu chỉnh bằng một tham số T trên logits trước khi chuyển sang xác suất. Khung đánh giá mức khớp giữa xác suất báo cáo và tần suất đúng trên dữ liệu quan sát được trình bày trong tài liệu về phân loại và hiệu chuẩn độ tin cậy của Lei (Đại học Carnegie Mellon).';
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_READING_TITLE = 'Đọc thêm';
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_READING = [
|
||||
{
|
||||
label: 'Guo et al. (2017) — temperature scaling',
|
||||
href: 'https://arxiv.org/abs/1706.04599',
|
||||
},
|
||||
{
|
||||
label: 'Lei — confidence & classification calibration (CMU)',
|
||||
href: 'https://www.stat.cmu.edu/~jinglei/conf_class_R2.pdf',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_REFERENCES_TITLE = 'Nền tảng khoa học & đọc thêm';
|
||||
|
||||
/** One-line: why this section + who should open it (verb/noun phrase, not content summary). */
|
||||
export const CALIBRATION_METRIC_HELP_NARRATIVE_TEASER =
|
||||
'Ẩn dụ để nắm bản chất toán học — cho bác sĩ cần trực giác trước khi đọc số';
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_KEY_POINTS_TEASER =
|
||||
'Lộ trình sinh ra % trên màn hình — cho người chỉnh T và đối chiếu kết quả hàng ngày';
|
||||
|
||||
export const CALIBRATION_METRIC_HELP_REFERENCES_TEASER =
|
||||
'Căn cứ khoa học để tra cứu — cho trưởng khoa / nghiên cứu cần trích dẫn';
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Clinical temperature tiers — T applied directly as softmax(logits ÷ T). */
|
||||
|
||||
export type CalibrationTierId = 'aggressive' | 'standard' | 'conservative';
|
||||
|
||||
/** @deprecated Use CalibrationTierId */
|
||||
export type CalibrationMode = CalibrationTierId;
|
||||
|
||||
export interface CalibrationTier {
|
||||
id: CalibrationTierId;
|
||||
tier: 1 | 2 | 3;
|
||||
labelVi: string;
|
||||
labelEn: string;
|
||||
triggerVi: string;
|
||||
ruleVi: string;
|
||||
recommendedT: number;
|
||||
uiEffectVi: string;
|
||||
}
|
||||
|
||||
export const CALIBRATION_TIERS: CalibrationTier[] = [
|
||||
{
|
||||
id: 'aggressive',
|
||||
tier: 1,
|
||||
labelVi: 'Nhạy cao / Sàng lọc',
|
||||
labelEn: 'Aggressive / Screening',
|
||||
triggerVi: 'Bác sĩ nghi ngờ viêm cao từ triệu chứng, chưa thấy trên ảnh.',
|
||||
ruleVi: 'T = 0.7 (Sharpening)',
|
||||
recommendedT: 0.7,
|
||||
uiEffectVi:
|
||||
'Đẩy xác suất biên lên — hạ ngưỡng cảnh báo viêm để không bỏ sót ca ranh giới.',
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
tier: 2,
|
||||
labelVi: 'Chuẩn / Mặc định',
|
||||
labelEn: 'Standard Baseline',
|
||||
triggerVi: 'Vận hành mặc định — chưa có prior lâm sàng từ người dùng.',
|
||||
ruleVi: 'T = 1.4 (Smoothing)',
|
||||
recommendedT: 1.4,
|
||||
uiEffectVi:
|
||||
'Giảm overconfidence của mạng — phân bố thực tế, cân bằng toán học.',
|
||||
},
|
||||
{
|
||||
id: 'conservative',
|
||||
tier: 3,
|
||||
labelVi: 'Bảo thủ / Hoài nghi',
|
||||
labelEn: 'Conservative / Skeptical',
|
||||
triggerVi: 'Bác sĩ tin bệnh nhân khỏe; dùng AI chỉ để kiểm tra lại.',
|
||||
ruleVi: 'T = 2.2 (Heavy flattening)',
|
||||
recommendedT: 2.2,
|
||||
uiEffectVi:
|
||||
'Làm phẳng phân bố — chỉ báo dương khi tín hiệu mô hình cực kỳ mạnh.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Midpoints between recommended T anchors — used to light up tier while dragging. */
|
||||
export const TIER_TEMPERATURE_BOUNDARIES = {
|
||||
/** T ≤ this → Tier 1 (between 0.7 and 1.4) */
|
||||
aggressiveMax: (0.7 + 1.4) / 2,
|
||||
/** T ≤ this → Tier 2 (between 1.4 and 2.2) */
|
||||
standardMax: (1.4 + 2.2) / 2,
|
||||
} as const;
|
||||
|
||||
export const TEMPERATURE_SLIDER = {
|
||||
min: 0.5,
|
||||
max: 2.5,
|
||||
step: 0.05,
|
||||
default: 1.4,
|
||||
} as const;
|
||||
|
||||
export function resolveTierFromTemperature(temperature: number): CalibrationTierId {
|
||||
if (temperature <= TIER_TEMPERATURE_BOUNDARIES.aggressiveMax) return 'aggressive';
|
||||
if (temperature <= TIER_TEMPERATURE_BOUNDARIES.standardMax) return 'standard';
|
||||
return 'conservative';
|
||||
}
|
||||
|
||||
export function getCalibrationTier(id: CalibrationTierId): CalibrationTier {
|
||||
return CALIBRATION_TIERS.find((t) => t.id === id) ?? CALIBRATION_TIERS[1]!;
|
||||
}
|
||||
|
||||
export function recommendedTemperatureForTier(id: CalibrationTierId): number {
|
||||
return getCalibrationTier(id).recommendedT;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { formatAngleClinicalLabel, getAngleMeta, type AngleClass } from './angleClassification';
|
||||
import { GRADE_LABELS } from './mockData';
|
||||
import type { ChatReasoningLevel } from '../lib/llm/chatReasoningLevel';
|
||||
import { DEFAULT_CHAT_REASONING_LEVEL, getChatReasoningLevelMeta } from '../lib/llm/chatReasoningLevel';
|
||||
import type { InferenceMode } from '../lib/llm/clinicalChatModes';
|
||||
import { getInferenceModeMeta } from '../lib/llm/clinicalChatModes';
|
||||
import type { ClinicalChatMessage } from '../types/clinicalChat';
|
||||
|
||||
let messageCounter = 0;
|
||||
|
||||
export function createChatMessageId(): string {
|
||||
messageCounter += 1;
|
||||
return `chat-${Date.now()}-${messageCounter}`;
|
||||
}
|
||||
|
||||
export function buildInitialAssistantMessage(
|
||||
grade: number | null,
|
||||
angleClass?: AngleClass | null,
|
||||
inferenceMode: InferenceMode = 'chat',
|
||||
severityLoading = false,
|
||||
chatReasoningLevel: ChatReasoningLevel = DEFAULT_CHAT_REASONING_LEVEL,
|
||||
): ClinicalChatMessage {
|
||||
const modeHint = getInferenceModeMeta(inferenceMode);
|
||||
const levelHint =
|
||||
inferenceMode === 'chat' ? getChatReasoningLevelMeta(chatReasoningLevel) : null;
|
||||
const angleLine = angleClass
|
||||
? ` Góc quét hiện tại: ${formatAngleClinicalLabel(getAngleMeta(angleClass))}.`
|
||||
: '';
|
||||
|
||||
let caseLine: string;
|
||||
if (severityLoading) {
|
||||
caseLine =
|
||||
'AI đang phân tích mức viêm trên khung hình hiện tại — tôi chưa có đề xuất cụ thể để thảo luận.';
|
||||
} else if (grade !== null) {
|
||||
const gradeLabel = GRADE_LABELS[grade] ?? `Độ ${grade}`;
|
||||
caseLine = `Khi bạn hỏi về đề xuất AI, tôi có thể thảo luận viêm màng hoạt dịch (mức ${grade} — ${gradeLabel}).${angleLine}`;
|
||||
} else {
|
||||
caseLine = `Chưa có đề xuất mức viêm từ AI trên khung hình này.${angleLine}`;
|
||||
}
|
||||
|
||||
const levelPart =
|
||||
levelHint !== null
|
||||
? ` ${levelHint.welcomeHint} Chạm biểu tượng ${levelHint.emoji} trên thanh công cụ để đổi mức độ.`
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: createChatMessageId(),
|
||||
role: 'assistant',
|
||||
content:
|
||||
`Tôi là trợ lý AI lâm sàng (${modeHint.label} · ${modeHint.llmCallsHint}).${levelPart} ` +
|
||||
`${caseLine} ` +
|
||||
'Bạn có thể hỏi về overlay phân đoạn hoặc ghi nhận chỉnh sửa. Bạn muốn làm rõ điểm nào?',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockClinicalChatReply(
|
||||
userText: string,
|
||||
grade: number | null,
|
||||
angleClass?: AngleClass | null,
|
||||
): string {
|
||||
const lower = userText.toLowerCase();
|
||||
const gradeLabel = grade !== null ? (GRADE_LABELS[grade] ?? `Độ ${grade}`) : null;
|
||||
|
||||
if (/^(xin chào|chào|hello|hi|hey)[\s!.?]*$/i.test(lower.trim())) {
|
||||
return 'Chào bạn. Tôi sẵn sàng hỗ trợ khi bạn muốn thảo luận đề xuất AI, overlay phân đoạn, hoặc ghi nhận chỉnh sửa.';
|
||||
}
|
||||
|
||||
if (/hoài nghi|nghi ngờ|không tin|chưa thuyết phục|skeptic|doubt|lạ|odd|không đồng ý|không phù hợp/.test(lower)) {
|
||||
return (
|
||||
'Đã ghi nhận mối lo ngại của bạn. Hãy nêu cụ thể phần nào chưa phù hợp — ví dụ: mức viêm, vùng overlay, hoặc góc quét. ' +
|
||||
'Tôi sẽ giải thích căn cứ AI và hỗ trợ ghi nhận chỉnh sửa có kiểm soát, không cần tạm dừng quy trình khám.'
|
||||
);
|
||||
}
|
||||
|
||||
if (/sửa|ghi đè|chỉnh|điều chỉnh|override|correct/.test(lower)) {
|
||||
return (
|
||||
'Đã ghi nhận yêu cầu chỉnh sửa. Mô tả mức độ hoặc vùng giải phẫu bạn muốn cập nhật — ví dụ: «Giảm độ viêm xuống 1» hoặc «Bỏ qua dịch ở hố suprapatellar». ' +
|
||||
'Trong bản triển khai đầy đủ, thay đổi sẽ được lưu vào audit trail và đồng bộ EMR.'
|
||||
);
|
||||
}
|
||||
|
||||
if (/góc|angle|overlay|màu|legend|phân đoạn/.test(lower)) {
|
||||
if (angleClass) {
|
||||
const meta = getAngleMeta(angleClass);
|
||||
const appendix = meta.segmentLegendType === 'post' ? 'A.2' : 'A.1';
|
||||
return (
|
||||
`Ở ${meta.labelMedicalEn}, đọc overlay theo Phụ lục ${appendix} (${meta.legendPlaneVi}). ` +
|
||||
'Bạn có thể bật/tắt mask bằng checkbox «Kết quả phân đoạn».'
|
||||
);
|
||||
}
|
||||
return 'Chuyển khung siêu âm để tôi căn cứ góc quét cụ thể khi giải thích mã màu overlay.';
|
||||
}
|
||||
|
||||
if (/viêm|độ|grade|mức|synovitis|severity/.test(lower)) {
|
||||
if (grade === null || gradeLabel === null) {
|
||||
return 'Chưa có đề xuất mức viêm từ AI trên khung hình hiện tại. Hãy đợi phân tích hoàn tất hoặc chuyển khung siêu âm.';
|
||||
}
|
||||
return (
|
||||
`Đề xuất hiện tại: mức ${grade} (${gradeLabel}), dựa trên diện tích dịch khớp và màng hoạt dịch. ` +
|
||||
'Nếu bạn không đồng ý, nêu mức độ lâm sàng mong muốn — tôi sẽ hỗ trợ ghi đè có kiểm soát.'
|
||||
);
|
||||
}
|
||||
|
||||
if (/đo|thickness|mm|độ dày/.test(lower)) {
|
||||
return angleClass === 'sup-up-long'
|
||||
? 'Độ dày (dịch + màng hoạt dịch) chỉ được đo tự động trên Suprapatellar Upper Longitudinal. Kiểm tra HUD góc dưới ảnh khi bật overlay.'
|
||||
: 'Thước đo tự động không áp dụng cho góc quét này. Chỉ Suprapatellar Upper Longitudinal có đường đo trên overlay.';
|
||||
}
|
||||
|
||||
return (
|
||||
'Cảm ơn bạn. Tôi có thể giải thích đề xuất AI, hướng dẫn đọc overlay, hoặc ghi nhận chỉnh sửa phân loại. ' +
|
||||
'Hãy nêu cụ thể vùng giải phẫu hoặc mức độ bạn muốn thảo luận.'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type DiseaseProgression = 'improved' | 'stable' | 'worse';
|
||||
|
||||
export type PowerDopplerScore = 0 | 1 | 2 | 3;
|
||||
|
||||
export type NextProcedure =
|
||||
| 'joint_aspiration'
|
||||
| 'intra_articular_injection'
|
||||
| 'synovial_biopsy'
|
||||
| 'mri_ct'
|
||||
| 'other'
|
||||
| 'none';
|
||||
|
||||
export interface DiagnosticFormDraft {
|
||||
diseaseProgression: DiseaseProgression | null;
|
||||
powerDopplerScore: PowerDopplerScore | null;
|
||||
synovialThicknessMm: string;
|
||||
synovialThicknessUnknown: boolean;
|
||||
anatomyDescriptionRight: string;
|
||||
anatomyDescriptionLeft: string;
|
||||
/** PNG data URL of annotated viewport */
|
||||
diagnosisWorkImageDataUrl: string | null;
|
||||
nextProcedure: NextProcedure | null;
|
||||
procedureReason: string;
|
||||
procedureResponsibleName: string;
|
||||
doctorConclusion: string;
|
||||
}
|
||||
|
||||
export const EMPTY_DIAGNOSTIC_FORM_DRAFT: DiagnosticFormDraft = {
|
||||
diseaseProgression: null,
|
||||
powerDopplerScore: null,
|
||||
synovialThicknessMm: '',
|
||||
synovialThicknessUnknown: false,
|
||||
anatomyDescriptionRight: '',
|
||||
anatomyDescriptionLeft: '',
|
||||
diagnosisWorkImageDataUrl: null,
|
||||
nextProcedure: null,
|
||||
procedureReason: '',
|
||||
procedureResponsibleName: '',
|
||||
doctorConclusion: '',
|
||||
};
|
||||
|
||||
export const DISEASE_PROGRESSION_OPTIONS: {
|
||||
value: DiseaseProgression;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: 'improved', label: 'Tốt lên' },
|
||||
{ value: 'stable', label: 'Ổn định' },
|
||||
{ value: 'worse', label: 'Xấu đi' },
|
||||
];
|
||||
|
||||
export const POWER_DOPPLER_SCORES: PowerDopplerScore[] = [0, 1, 2, 3];
|
||||
|
||||
export const NEXT_PROCEDURE_OPTIONS: { value: NextProcedure; label: string }[] = [
|
||||
{ value: 'joint_aspiration', label: 'Chọc hút dịch khớp (Joint Aspiration)' },
|
||||
{ value: 'intra_articular_injection', label: 'Tiêm nội khớp (Corticosteroid/PRP)' },
|
||||
{ value: 'synovial_biopsy', label: 'Sinh thiết màng hoạt dịch' },
|
||||
{ value: 'mri_ct', label: 'Chụp MRI/CT đối chiếu' },
|
||||
{ value: 'other', label: 'Chỉ định khác' },
|
||||
{ value: 'none', label: 'Không có' },
|
||||
];
|
||||
|
||||
/** Carousel steps: progression → measures → anatomy (R+L) → snapshot → procedure → conclusion */
|
||||
export const DIAGNOSTIC_FORM_STEP_COUNT = 6;
|
||||
|
||||
export const DIAGNOSTIC_FORM_STEP_TITLES = [
|
||||
'Tiến triển bệnh tình so với lần khám gần nhất',
|
||||
'Số liệu đo lường',
|
||||
'Mô tả giải phẫu',
|
||||
'Ảnh siêu âm đã chú thích',
|
||||
'Chỉ định thủ thuật tiếp theo',
|
||||
'Kết luận của bác sĩ',
|
||||
] as const;
|
||||
@@ -0,0 +1,333 @@
|
||||
export type DiagnosticEventKind = 'file' | 'delete' | 'edit' | 'view' | 'ai' | 'form';
|
||||
|
||||
export interface FrameRef {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
frameLabel: string;
|
||||
contentHash?: string;
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticSessionEvent {
|
||||
id: string;
|
||||
elapsedSeconds: number;
|
||||
recordedAt: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
kind: DiagnosticEventKind;
|
||||
cursor?: { x: number; y: number };
|
||||
clickPulse?: boolean;
|
||||
frame?: FrameRef;
|
||||
}
|
||||
|
||||
export interface PointerTraceSample {
|
||||
elapsedSeconds: number;
|
||||
x: number;
|
||||
y: number;
|
||||
frameId: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticSessionLog {
|
||||
patientId: string;
|
||||
totalDurationSeconds: number;
|
||||
events: DiagnosticSessionEvent[];
|
||||
pointerTrace?: PointerTraceSample[];
|
||||
segments?: import('../lib/sessionRecordingTypes').FrameSegment[];
|
||||
overlaysByFrameId?: Record<string, import('../lib/sessionRecordingTypes').FrameOverlaySnapshot>;
|
||||
primaryFrameId?: string;
|
||||
/** Changes when a new recording session starts — used to reset replay playback position. */
|
||||
sessionKey?: string;
|
||||
uixEvents?: import('../lib/uixSessionEvents').UIXSessionEvent[];
|
||||
}
|
||||
|
||||
/** How far back (seconds) the comet tail extends during replay. */
|
||||
export const POINTER_TRACE_TRAIL_SECONDS = 2.5;
|
||||
|
||||
const EVENT_KIND_COLORS: Record<DiagnosticEventKind, string> = {
|
||||
file: '#22c55e',
|
||||
delete: '#ef4444',
|
||||
edit: '#38bdf8',
|
||||
view: '#a78bfa',
|
||||
ai: '#f59e0b',
|
||||
form: '#14b8a6',
|
||||
};
|
||||
|
||||
export function eventKindColor(kind: DiagnosticEventKind): string {
|
||||
return EVENT_KIND_COLORS[kind];
|
||||
}
|
||||
|
||||
export function formatReplayClock(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatRecordedTime(isoLike: string): string {
|
||||
return isoLike;
|
||||
}
|
||||
|
||||
function buildMockEvents(patientName: string, mrn: string): DiagnosticSessionEvent[] {
|
||||
return [
|
||||
{
|
||||
id: 'ev-1',
|
||||
elapsedSeconds: 0,
|
||||
recordedAt: '14:18:02',
|
||||
title: 'Bắt đầu phiên chẩn đoán',
|
||||
detail: `Mở workspace cho bệnh nhân ${patientName}.`,
|
||||
kind: 'view',
|
||||
cursor: { x: 12, y: 18 },
|
||||
},
|
||||
{
|
||||
id: 'ev-2',
|
||||
elapsedSeconds: 42,
|
||||
recordedAt: '14:18:44',
|
||||
title: 'Tải khung siêu âm đầu tiên',
|
||||
detail: 'Chọn khung Sup dọc — viêm (+) · #1.',
|
||||
kind: 'view',
|
||||
cursor: { x: 34, y: 52 },
|
||||
clickPulse: true,
|
||||
},
|
||||
{
|
||||
id: 'ev-3',
|
||||
elapsedSeconds: 98,
|
||||
recordedAt: '14:19:40',
|
||||
title: 'Bật overlay phân đoạn',
|
||||
detail: 'Hiển thị mask màng hoạt dịch trên canvas.',
|
||||
kind: 'view',
|
||||
cursor: { x: 28, y: 74 },
|
||||
clickPulse: true,
|
||||
},
|
||||
{
|
||||
id: 'ev-4',
|
||||
elapsedSeconds: 135,
|
||||
recordedAt: '14:20:17',
|
||||
title: 'Hoàn tất phân tích AI',
|
||||
detail: 'Đề xuất viêm màng hoạt dịch Độ 2 — độ tin cậy 78%.',
|
||||
kind: 'ai',
|
||||
cursor: { x: 72, y: 36 },
|
||||
},
|
||||
{
|
||||
id: 'ev-5',
|
||||
elapsedSeconds: 162,
|
||||
recordedAt: '14:20:44',
|
||||
title: 'Cập nhật MRN',
|
||||
detail: `Sửa Mã BN (MRN) sang '${mrn.replace(/\d{4}$/, '2026')}'.`,
|
||||
kind: 'edit',
|
||||
cursor: { x: 86, y: 22 },
|
||||
clickPulse: true,
|
||||
},
|
||||
{
|
||||
id: 'ev-6',
|
||||
elapsedSeconds: 210,
|
||||
recordedAt: '14:21:32',
|
||||
title: 'Tải lên tệp quét mới',
|
||||
detail: 'Tải lên thành công 3 tệp DICOM mới.',
|
||||
kind: 'file',
|
||||
cursor: { x: 55, y: 48 },
|
||||
clickPulse: true,
|
||||
},
|
||||
{
|
||||
id: 'ev-7',
|
||||
elapsedSeconds: 248,
|
||||
recordedAt: '14:22:10',
|
||||
title: 'Xóa tệp quét cũ',
|
||||
detail: 'Đánh dấu xóa scan_sagittal_02.dcm khỏi hồ sơ.',
|
||||
kind: 'delete',
|
||||
cursor: { x: 88, y: 58 },
|
||||
clickPulse: true,
|
||||
},
|
||||
{
|
||||
id: 'ev-8',
|
||||
elapsedSeconds: 302,
|
||||
recordedAt: '14:23:04',
|
||||
title: 'Lưu nháp phiếu chẩn đoán',
|
||||
detail: 'Ghi nhận synovial thickness 4.2 mm vào phiếu lâm sàng.',
|
||||
kind: 'form',
|
||||
cursor: { x: 78, y: 68 },
|
||||
clickPulse: true,
|
||||
},
|
||||
{
|
||||
id: 'ev-9',
|
||||
elapsedSeconds: 348,
|
||||
recordedAt: '14:23:50',
|
||||
title: 'Thảo luận kết quả AI',
|
||||
detail: 'Trao đổi về vùng tràn dịch suprapatellar với trợ lý lâm sàng.',
|
||||
kind: 'ai',
|
||||
cursor: { x: 82, y: 44 },
|
||||
},
|
||||
{
|
||||
id: 'ev-10',
|
||||
elapsedSeconds: 390,
|
||||
recordedAt: '14:24:32',
|
||||
title: 'Kết thúc phiên xem xét',
|
||||
detail: 'Hoàn tất rà soát trước ký xác nhận.',
|
||||
kind: 'view',
|
||||
cursor: { x: 92, y: 12 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Stable id for the current recording timeline (resets replay when this changes). */
|
||||
export function sessionReplayKey(patientId: string, events: DiagnosticSessionEvent[]): string {
|
||||
return `${patientId}:${events[0]?.id ?? 'empty'}`;
|
||||
}
|
||||
|
||||
/** Empty log shown before the clinician records a real session. */
|
||||
export function createEmptySessionLog(patientId: string): DiagnosticSessionLog {
|
||||
return {
|
||||
patientId,
|
||||
events: [],
|
||||
totalDurationSeconds: 0,
|
||||
pointerTrace: [],
|
||||
segments: [],
|
||||
overlaysByFrameId: {},
|
||||
sessionKey: sessionReplayKey(patientId, []),
|
||||
};
|
||||
}
|
||||
|
||||
/** Demo timeline for docs / manual QA — not used in live workspace replay. */
|
||||
export function getDiagnosticSessionLog(patientId: string, patientName: string, mrn: string): DiagnosticSessionLog {
|
||||
const events = buildMockEvents(patientName, mrn);
|
||||
const totalDurationSeconds = events[events.length - 1]?.elapsedSeconds ?? 390;
|
||||
const pointerTrace = buildSyntheticTraceFromEvents(events);
|
||||
return { patientId, totalDurationSeconds, events, pointerTrace };
|
||||
}
|
||||
|
||||
export function buildSyntheticTraceFromEvents(
|
||||
events: DiagnosticSessionEvent[],
|
||||
stepSeconds = 0.12,
|
||||
): PointerTraceSample[] {
|
||||
const withCursor = events.filter((event) => event.cursor);
|
||||
if (withCursor.length === 0) return [];
|
||||
|
||||
const samples: PointerTraceSample[] = [];
|
||||
for (let index = 0; index < withCursor.length; index += 1) {
|
||||
const start = withCursor[index];
|
||||
const end = withCursor[index + 1];
|
||||
const t0 = start.elapsedSeconds;
|
||||
const t1 = end?.elapsedSeconds ?? t0 + stepSeconds;
|
||||
const c0 = start.cursor!;
|
||||
const c1 = end?.cursor ?? c0;
|
||||
const span = Math.max(t1 - t0, stepSeconds);
|
||||
const steps = Math.max(1, Math.ceil(span / stepSeconds));
|
||||
|
||||
for (let step = 0; step <= steps; step += 1) {
|
||||
const elapsedSeconds = Math.round((t0 + (step / steps) * span) * 10) / 10;
|
||||
const ratio = span > 0 ? (elapsedSeconds - t0) / span : 0;
|
||||
const frameId = start.frame?.frameId ?? end?.frame?.frameId ?? '';
|
||||
samples.push({
|
||||
elapsedSeconds,
|
||||
x: c0.x + (c1.x - c0.x) * ratio,
|
||||
y: c0.y + (c1.y - c0.y) * ratio,
|
||||
frameId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
export function interpolatePositionAtTime(
|
||||
trace: PointerTraceSample[],
|
||||
elapsedSeconds: number,
|
||||
): { x: number; y: number } | null {
|
||||
if (trace.length === 0) return null;
|
||||
|
||||
let before = trace[0];
|
||||
let after = trace[trace.length - 1];
|
||||
|
||||
for (const sample of trace) {
|
||||
if (sample.elapsedSeconds <= elapsedSeconds) before = sample;
|
||||
if (sample.elapsedSeconds >= elapsedSeconds) {
|
||||
after = sample;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (before.elapsedSeconds === after.elapsedSeconds) {
|
||||
return { x: before.x, y: before.y };
|
||||
}
|
||||
|
||||
const ratio =
|
||||
(elapsedSeconds - before.elapsedSeconds) / (after.elapsedSeconds - before.elapsedSeconds);
|
||||
return {
|
||||
x: before.x + (after.x - before.x) * ratio,
|
||||
y: before.y + (after.y - before.y) * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
export function traceWindowAtTime(
|
||||
trace: PointerTraceSample[],
|
||||
elapsedSeconds: number,
|
||||
windowSeconds = POINTER_TRACE_TRAIL_SECONDS,
|
||||
activeFrameId?: string,
|
||||
): PointerTraceSample[] {
|
||||
const windowStart = Math.max(0, elapsedSeconds - windowSeconds);
|
||||
return trace.filter((sample) => {
|
||||
if (sample.elapsedSeconds < windowStart || sample.elapsedSeconds > elapsedSeconds) {
|
||||
return false;
|
||||
}
|
||||
if (activeFrameId && sample.frameId && sample.frameId !== activeFrameId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function eventAtTime(events: DiagnosticSessionEvent[], elapsedSeconds: number): DiagnosticSessionEvent {
|
||||
let active = events[0];
|
||||
for (const event of events) {
|
||||
if (event.elapsedSeconds <= elapsedSeconds) active = event;
|
||||
else break;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function tooltipForEvent(event: DiagnosticSessionEvent): string {
|
||||
return `${event.title} — ${formatReplayClock(event.elapsedSeconds)}`;
|
||||
}
|
||||
|
||||
export function formatNowClock(date = new Date()): string {
|
||||
return date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
export function computeTotalDuration(events: DiagnosticSessionEvent[]): number {
|
||||
if (events.length === 0) return 0;
|
||||
return events[events.length - 1].elapsedSeconds;
|
||||
}
|
||||
|
||||
export function computePlaybackDuration(
|
||||
events: DiagnosticSessionEvent[],
|
||||
pointerTrace?: PointerTraceSample[],
|
||||
): number {
|
||||
const eventEnd = computeTotalDuration(events);
|
||||
const traceEnd =
|
||||
pointerTrace && pointerTrace.length > 0
|
||||
? pointerTrace[pointerTrace.length - 1].elapsedSeconds
|
||||
: 0;
|
||||
return Math.max(eventEnd, traceEnd, 1);
|
||||
}
|
||||
|
||||
export function createSessionStartEvent(patientName: string): DiagnosticSessionEvent {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
elapsedSeconds: 0,
|
||||
recordedAt: formatNowClock(),
|
||||
title: 'Bắt đầu phiên chẩn đoán',
|
||||
detail: `Mở workspace cho bệnh nhân ${patientName}.`,
|
||||
kind: 'view',
|
||||
cursor: { x: 12, y: 18 },
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionEndEvent(elapsedSeconds: number): DiagnosticSessionEvent {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
elapsedSeconds: Math.round(elapsedSeconds * 10) / 10,
|
||||
recordedAt: formatNowClock(),
|
||||
title: 'Kết thúc ghi phiên',
|
||||
detail: 'Phiên ghi đã hoàn tất — xem lại danh sách hành động bên dưới.',
|
||||
kind: 'view',
|
||||
cursor: { x: 50, y: 50 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { CalibratedPrediction, DecisionState } from '../types/calibratedPrediction';
|
||||
import {
|
||||
estimateMisclassificationRate,
|
||||
formatRiskFramingVi,
|
||||
} from '../types/calibratedPrediction';
|
||||
|
||||
export const INFLAMMATION_CLASSES = ['no_inflammation', 'inflammation'] as const;
|
||||
export type InflammationClass = (typeof INFLAMMATION_CLASSES)[number];
|
||||
|
||||
export const INFLAMMATION_LABELS: Record<InflammationClass, string> = {
|
||||
no_inflammation: 'Không viêm',
|
||||
inflammation: 'Có viêm',
|
||||
};
|
||||
|
||||
export interface InflammationClassificationResult {
|
||||
detected: boolean;
|
||||
/** P(inflammation) in 0–1 */
|
||||
confidence: number;
|
||||
calibration?: CalibratedPrediction;
|
||||
}
|
||||
|
||||
function shannonEntropy(probs: number[]): number {
|
||||
return -probs.reduce((sum, p) => sum + (p > 0 ? p * Math.log(p) : 0), 0);
|
||||
}
|
||||
|
||||
export function buildMockInflammationCalibration(
|
||||
inflammationProb: number,
|
||||
): CalibratedPrediction {
|
||||
const pInflam = Math.min(0.99, Math.max(0.01, inflammationProb));
|
||||
const pNo = 1 - pInflam;
|
||||
const rawProbs = [pNo, pInflam];
|
||||
const normEntropy = shannonEntropy(rawProbs) / Math.log(2);
|
||||
const ambiguousSet = INFLAMMATION_CLASSES.filter((_, i) => rawProbs[i]! >= Math.max(...rawProbs) - 0.05);
|
||||
let decisionState: DecisionState = 'confident';
|
||||
if (normEntropy >= 0.88) decisionState = 'ood_warning';
|
||||
else if (ambiguousSet.length > 1) decisionState = 'ambiguous';
|
||||
|
||||
const predictedClass: InflammationClass = pInflam >= pNo ? 'inflammation' : 'no_inflammation';
|
||||
const errorRate = estimateMisclassificationRate(Math.max(pInflam, pNo));
|
||||
const noLogit = Math.log(pNo / (pInflam + 1e-6));
|
||||
const inflamLogit = Math.log(pInflam / (pNo + 1e-6));
|
||||
|
||||
return {
|
||||
raw_logits: [noLogit, inflamLogit],
|
||||
temperature: 1,
|
||||
base_temperature: 1,
|
||||
mode: 'standard',
|
||||
clinical_suspicion: 0,
|
||||
alpha_margin: 0.05,
|
||||
class_probabilities: {
|
||||
no_inflammation: Math.round(pNo * 10000) / 100,
|
||||
inflammation: Math.round(pInflam * 10000) / 100,
|
||||
},
|
||||
entropy: shannonEntropy(rawProbs),
|
||||
normalized_entropy: normEntropy,
|
||||
ambiguous_set: ambiguousSet,
|
||||
decision_state: decisionState,
|
||||
predicted_error_rate: errorRate,
|
||||
risk_framing_vi: formatRiskFramingVi(
|
||||
INFLAMMATION_LABELS[predictedClass],
|
||||
Math.max(pInflam, pNo),
|
||||
decisionState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInflammationFromConfidence(confidence: number): InflammationClassificationResult {
|
||||
const inflammationProb = confidence > 1 ? confidence / 100 : confidence;
|
||||
const detected = inflammationProb >= 0.5;
|
||||
return {
|
||||
detected,
|
||||
confidence: inflammationProb,
|
||||
calibration: buildMockInflammationCalibration(inflammationProb),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBackendInflammation(
|
||||
raw:
|
||||
| {
|
||||
detected?: boolean;
|
||||
confidence: number | null;
|
||||
calibration?: CalibratedPrediction;
|
||||
}
|
||||
| undefined,
|
||||
fallbackConfidence?: number,
|
||||
): InflammationClassificationResult | null {
|
||||
if (raw) {
|
||||
const confidence =
|
||||
raw.confidence != null
|
||||
? raw.confidence > 1
|
||||
? raw.confidence / 100
|
||||
: raw.confidence
|
||||
: 0.5;
|
||||
return {
|
||||
detected: raw.detected ?? confidence >= 0.5,
|
||||
confidence,
|
||||
calibration: raw.calibration,
|
||||
};
|
||||
}
|
||||
if (fallbackConfidence !== undefined) {
|
||||
return buildInflammationFromConfidence(fallbackConfidence);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
export type PatientStatus = 'urgent' | 'processing' | 'followup' | 'ready';
|
||||
|
||||
export interface PatientCase {
|
||||
id: string;
|
||||
name: string;
|
||||
mrn: string;
|
||||
joint: string;
|
||||
status: PatientStatus;
|
||||
statusLabel: string;
|
||||
lastScan: string;
|
||||
// UNUSED — was intended as mock ML fallback; app uses live inference + IndexedDB cache instead.
|
||||
// synovitisGrade?: number;
|
||||
// confidence?: number;
|
||||
}
|
||||
|
||||
// ─── UNUSED: planned NFR dashboard (Profile / Bento) — no imports in src/ ───
|
||||
// export interface NFRMetrics {
|
||||
// bundleSizeMb: number;
|
||||
// inferenceLatencyMs: number;
|
||||
// memoryMb: number;
|
||||
// offlineReady: boolean;
|
||||
// }
|
||||
|
||||
// ─── UNUSED: planned EMR sync status widget — no imports in src/ ───
|
||||
// export interface EMRStatus {
|
||||
// synced: boolean;
|
||||
// lastSync: string;
|
||||
// pendingCount: number;
|
||||
// }
|
||||
|
||||
export const GRADE_LABELS: Record<number, string> = {
|
||||
0: 'Rất nhẹ',
|
||||
1: 'Nhẹ',
|
||||
2: 'Trung bình',
|
||||
3: 'Nặng',
|
||||
};
|
||||
|
||||
/** Placeholder attending physician for sign-off audit labels */
|
||||
export const MOCK_ATTENDING_PHYSICIAN = 'BS. Trần Minh Khoa';
|
||||
|
||||
export const MOCK_PATIENTS: PatientCase[] = [
|
||||
{
|
||||
id: 'p-001',
|
||||
name: 'Nguyễn Văn An',
|
||||
mrn: 'BN-2024-1847',
|
||||
joint: 'Khớp gối phải',
|
||||
status: 'urgent',
|
||||
statusLabel: 'Khẩn cấp',
|
||||
lastScan: '09:42',
|
||||
// synovitisGrade: 3,
|
||||
// confidence: 0.62,
|
||||
},
|
||||
{
|
||||
id: 'p-002',
|
||||
name: 'Trần Thị Bích',
|
||||
mrn: 'BN-2024-1923',
|
||||
joint: 'Khớp gối trái',
|
||||
status: 'processing',
|
||||
statusLabel: 'Đang xử lý',
|
||||
lastScan: '10:15',
|
||||
// synovitisGrade: 2,
|
||||
// confidence: 0.84,
|
||||
},
|
||||
{
|
||||
id: 'p-003',
|
||||
name: 'Lê Hoàng Minh',
|
||||
mrn: 'BN-2024-2011',
|
||||
joint: 'Khớp gối phải',
|
||||
status: 'followup',
|
||||
statusLabel: 'Theo dõi',
|
||||
lastScan: 'Hôm qua',
|
||||
// synovitisGrade: 1,
|
||||
// confidence: 0.91,
|
||||
},
|
||||
{
|
||||
id: 'p-004',
|
||||
name: 'Phạm Thu Hà',
|
||||
mrn: 'BN-2024-2088',
|
||||
joint: 'Khớp gối trái',
|
||||
status: 'ready', // used for card chip only; no dedicated worklist filter tab
|
||||
statusLabel: 'Sẵn sàng',
|
||||
lastScan: '08:30',
|
||||
// synovitisGrade: 0,
|
||||
// confidence: 0.96,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── UNUSED: NFR metrics mock — no imports in src/ ───
|
||||
// export const MOCK_NFR: NFRMetrics = {
|
||||
// bundleSizeMb: 142,
|
||||
// inferenceLatencyMs: 1240,
|
||||
// memoryMb: 118,
|
||||
// offlineReady: true,
|
||||
// };
|
||||
|
||||
// ─── UNUSED: EMR sync mock — no imports in src/ ───
|
||||
// export const MOCK_EMR: EMRStatus = {
|
||||
// synced: true,
|
||||
// lastSync: '10:28',
|
||||
// pendingCount: 0,
|
||||
// };
|
||||
|
||||
// ─── UNUSED: planned AI reasoning panel — no imports in src/ ───
|
||||
// export const CLINICAL_REASONING = [
|
||||
// {
|
||||
// zone: 'Hố suprapatellar',
|
||||
// focus: 'Tăng tín hiệu echo-free dày 4.2 mm',
|
||||
// weight: 'Cao',
|
||||
// },
|
||||
// {
|
||||
// zone: 'Màng hoạt dịch viền',
|
||||
// focus: 'Tăng sinh mạch (Doppler grade 2)',
|
||||
// weight: 'Trung bình',
|
||||
// },
|
||||
// {
|
||||
// zone: 'Dây chằng bánh chè',
|
||||
// focus: 'Không có dấu hiệu viêm',
|
||||
// weight: 'Thấp',
|
||||
// },
|
||||
// ];
|
||||
|
||||
// ─── UNUSED: SafetyEscalationOverlay exists but is not mounted in App routes ───
|
||||
// export const SOCRATIC_PROMPTS = [
|
||||
// 'Vùng dày echo-free ở hố suprapatellar — bạn xác nhận đây là tràn dịch nhẹ hay mô pannus?',
|
||||
// 'Tăng sinh mạch Doppler grade 2 có phù hợp với viêm màng hoạt dịch độ 2 không?',
|
||||
// 'Có cấu trúc giả âm (acoustic shadow) nào cần loại trừ khỏi chấm điểm không?',
|
||||
// ];
|
||||
//
|
||||
// export const MORPHOLOGY_TEMPLATE = [
|
||||
// { id: 'effusion', label: 'Tràn dịch khớp', checked: false },
|
||||
// { id: 'synovium', label: 'Dày màng hoạt dịch', checked: true },
|
||||
// { id: 'pannus', label: 'Mô pannus', checked: false },
|
||||
// { id: 'erosion', label: 'Xói mòn xương', checked: false },
|
||||
// { id: 'shadow', label: 'Bóng âm thanh giả', checked: false },
|
||||
// { id: 'difference', label: 'Khác', checked: false },
|
||||
// ];
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { MockMaskPolygon } from '../types/segmentationAnalysis';
|
||||
|
||||
/**
|
||||
* Organic mask contours (normalized 0–1) simulating backend pixel-mask boundaries.
|
||||
* Dense vertices along wavy tissue layers — not ellipses or axis-aligned boxes.
|
||||
*/
|
||||
export const ORGANIC_MASK_MED_LAT_1: MockMaskPolygon[] = [
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'fat',
|
||||
points: [
|
||||
[0.06, 0.08], [0.14, 0.06], [0.22, 0.09], [0.31, 0.07], [0.40, 0.10],
|
||||
[0.50, 0.07], [0.60, 0.09], [0.70, 0.08], [0.80, 0.10], [0.90, 0.08],
|
||||
[0.94, 0.14], [0.88, 0.19], [0.78, 0.17], [0.68, 0.20], [0.58, 0.18],
|
||||
[0.48, 0.21], [0.38, 0.19], [0.28, 0.20], [0.18, 0.18], [0.08, 0.16],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'tendon',
|
||||
points: [
|
||||
[0.05, 0.19], [0.12, 0.21], [0.20, 0.20], [0.28, 0.23], [0.36, 0.21],
|
||||
[0.44, 0.24], [0.52, 0.22], [0.60, 0.25], [0.68, 0.23], [0.76, 0.26],
|
||||
[0.84, 0.24], [0.92, 0.27], [0.95, 0.33], [0.88, 0.36], [0.80, 0.34],
|
||||
[0.72, 0.37], [0.64, 0.35], [0.56, 0.38], [0.48, 0.36], [0.40, 0.39],
|
||||
[0.32, 0.37], [0.24, 0.38], [0.16, 0.36], [0.08, 0.37], [0.04, 0.32],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'effusion',
|
||||
points: [
|
||||
[0.10, 0.36], [0.18, 0.38], [0.26, 0.37], [0.34, 0.40], [0.42, 0.39],
|
||||
[0.50, 0.41], [0.58, 0.40], [0.66, 0.42], [0.74, 0.41], [0.82, 0.43],
|
||||
[0.90, 0.42], [0.93, 0.48], [0.86, 0.52], [0.78, 0.50], [0.70, 0.53],
|
||||
[0.62, 0.51], [0.54, 0.54], [0.46, 0.52], [0.38, 0.55], [0.30, 0.53],
|
||||
[0.22, 0.54], [0.14, 0.52], [0.08, 0.50], [0.06, 0.44],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'synovium',
|
||||
points: [
|
||||
[0.08, 0.50], [0.16, 0.52], [0.24, 0.51], [0.32, 0.54], [0.40, 0.53],
|
||||
[0.48, 0.55], [0.56, 0.54], [0.64, 0.56], [0.72, 0.55], [0.80, 0.57],
|
||||
[0.88, 0.56], [0.92, 0.62], [0.85, 0.66], [0.77, 0.64], [0.69, 0.67],
|
||||
[0.61, 0.65], [0.53, 0.68], [0.45, 0.66], [0.37, 0.69], [0.29, 0.67],
|
||||
[0.21, 0.68], [0.13, 0.66], [0.07, 0.63],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'fat-pat',
|
||||
points: [
|
||||
[0.06, 0.64], [0.14, 0.66], [0.22, 0.65], [0.30, 0.68], [0.38, 0.67],
|
||||
[0.46, 0.70], [0.54, 0.69], [0.62, 0.71], [0.70, 0.70], [0.78, 0.72],
|
||||
[0.86, 0.71], [0.93, 0.75], [0.90, 0.81], [0.82, 0.83], [0.74, 0.81],
|
||||
[0.66, 0.84], [0.58, 0.82], [0.50, 0.85], [0.42, 0.83], [0.34, 0.84],
|
||||
[0.26, 0.82], [0.18, 0.83], [0.10, 0.81], [0.05, 0.76],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'femur',
|
||||
points: [
|
||||
[0.04, 0.80], [0.12, 0.82], [0.20, 0.81], [0.28, 0.84], [0.36, 0.83],
|
||||
[0.44, 0.86], [0.52, 0.85], [0.60, 0.87], [0.68, 0.86], [0.76, 0.88],
|
||||
[0.84, 0.87], [0.92, 0.89], [0.96, 0.94], [0.90, 0.98], [0.82, 0.97],
|
||||
[0.74, 0.99], [0.66, 0.97], [0.58, 0.99], [0.50, 0.97], [0.42, 0.99],
|
||||
[0.34, 0.97], [0.26, 0.98], [0.18, 0.96], [0.10, 0.97], [0.04, 0.93],
|
||||
],
|
||||
},
|
||||
// Small detached fragment (like reference blue speck on left)
|
||||
{
|
||||
type: 'polygon',
|
||||
classKey: 'tendon',
|
||||
points: [
|
||||
[0.02, 0.24], [0.05, 0.23], [0.06, 0.27], [0.04, 0.29], [0.01, 0.28],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function shiftPolygon(poly: MockMaskPolygon, dx: number, dy: number): MockMaskPolygon {
|
||||
return {
|
||||
...poly,
|
||||
points: poly.points.map(([x, y]) => [x + dx, y + dy] as [number, number]),
|
||||
};
|
||||
}
|
||||
|
||||
function scalePolygon(poly: MockMaskPolygon, sx: number, sy: number): MockMaskPolygon {
|
||||
return {
|
||||
...poly,
|
||||
points: poly.points.map(([x, y]) => [x * sx, y * sy] as [number, number]),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrganicMasksForFrame(frameId: string): MockMaskPolygon[] {
|
||||
switch (frameId) {
|
||||
case 'med-lat-1':
|
||||
return ORGANIC_MASK_MED_LAT_1;
|
||||
case 'med-lat-2':
|
||||
return ORGANIC_MASK_MED_LAT_1.map((poly) => shiftPolygon(poly, 0.02, 0.02));
|
||||
case 'trans-flex':
|
||||
return ORGANIC_MASK_MED_LAT_1.map((poly) => scalePolygon(shiftPolygon(poly, -0.01, 0.03), 0.96, 0.94));
|
||||
default:
|
||||
return ORGANIC_MASK_MED_LAT_1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SegmentationAnalysisResult } from '../types/segmentationAnalysis';
|
||||
|
||||
// --- Detached mock segmentation (prototyping uses /api/test/analyze/batch only) ---
|
||||
// import {
|
||||
// angleSupportsThicknessMeasurement,
|
||||
// getMockAngleForFrame,
|
||||
// getSegmentLegendTypeForAngle,
|
||||
// type AngleClass,
|
||||
// } from './angleClassification';
|
||||
// import { getOrganicMasksForFrame } from './mockOrganicMasks';
|
||||
// import { SEGMENTATION_LEGEND_POST, SEGMENTATION_LEGEND_SUP } from './segmentationLegend';
|
||||
// ... MOCK_SEGMENTATION_BY_FRAME built from canvas masks ...
|
||||
|
||||
/** @deprecated Mock path detached — always returns null. */
|
||||
export function getMockSegmentationForFrame(_frameId: string): SegmentationAnalysisResult | null {
|
||||
return null;
|
||||
}
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,76 @@
|
||||
import type { PatientStatus } from './mockData';
|
||||
|
||||
export interface PatientStatusOption {
|
||||
value: PatientStatus;
|
||||
label: string;
|
||||
chipClass: string;
|
||||
}
|
||||
|
||||
export const PATIENT_STATUS_OPTIONS: PatientStatusOption[] = [
|
||||
{ value: 'urgent', label: 'Khẩn cấp', chipClass: 'chip-urgent' },
|
||||
{ value: 'processing', label: 'Đang xử lý', chipClass: 'chip-processing' },
|
||||
{ value: 'followup', label: 'Theo dõi', chipClass: 'chip-followup' },
|
||||
{ value: 'ready', label: 'Sẵn sàng', chipClass: 'chip-success' },
|
||||
];
|
||||
|
||||
export const JOINT_OPTIONS = [
|
||||
'Khớp gối trái',
|
||||
'Khớp gối phải',
|
||||
'Khớp vai trái',
|
||||
'Khớp vai phải',
|
||||
'Khớp khuỷu trái',
|
||||
'Khớp khuỷu phải',
|
||||
'Khớp cổ tay trái',
|
||||
'Khớp cổ tay phải',
|
||||
] as const;
|
||||
|
||||
export function statusLabelFor(status: PatientStatus): string {
|
||||
return PATIENT_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
|
||||
}
|
||||
|
||||
export function formatLastScanFromDate(date: Date): string {
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
|
||||
if (sameDay) {
|
||||
return date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
const isYesterday =
|
||||
date.getFullYear() === yesterday.getFullYear() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getDate() === yesterday.getDate();
|
||||
|
||||
if (isYesterday) return 'Hôm qua';
|
||||
|
||||
return date.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function deriveLastScanFromFiles(files: File[]): string {
|
||||
if (files.length === 0) return '—';
|
||||
const latest = Math.max(...files.map((file) => file.lastModified));
|
||||
return formatLastScanFromDate(new Date(latest));
|
||||
}
|
||||
|
||||
const ACCEPTED_SCAN_EXTENSIONS = ['.dcm', '.dicom', '.png', '.jpg', '.jpeg'] as const;
|
||||
|
||||
const ACCEPTED_SCAN_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'application/dicom',
|
||||
]);
|
||||
|
||||
export const ACCEPTED_SCAN_FILE_INPUT =
|
||||
'.dcm,.dicom,.png,.jpg,.jpeg,image/png,image/jpeg,application/dicom';
|
||||
|
||||
export function isAcceptedScanFile(file: File): boolean {
|
||||
const lower = file.name.toLowerCase();
|
||||
const byExtension = ACCEPTED_SCAN_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
const byMime = file.type.length > 0 && ACCEPTED_SCAN_MIME_TYPES.has(file.type);
|
||||
return byExtension || byMime;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Auto-generated by backend/tests/sample_patient_profiles.py — do not edit. */
|
||||
import type { ScanFrame } from './scanFrames';
|
||||
|
||||
export interface PatientScanProfileFrame extends ScanFrame {
|
||||
stratum: string;
|
||||
expectedAngleClass?: string;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export const PATIENT_SCAN_PROFILES: Record<string, PatientScanProfileFrame[]> = {
|
||||
"p-001": [
|
||||
{ id: "p-001-sup-long-pos-0", src: "/assets/patient-profiles/p-001/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png", },
|
||||
{ id: "p-001-sup-long-neg-0", src: "/assets/patient-profiles/p-001/sup-long-neg-0.png", label: "Sup dọc — không viêm (−) · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png", },
|
||||
{ id: "p-001-post-trans-pos-0", src: "/assets/patient-profiles/p-001/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-001-post-trans-neg-0", src: "/assets/patient-profiles/p-001/post-trans-neg-0.png", label: "Sau ngang — không viêm (−) · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-001-other-0", src: "/assets/patient-profiles/p-001/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "sup-trans-flex", sourcePath: "backend/tests/test_images/other_angle/trans_flex.png", },
|
||||
],
|
||||
"p-002": [
|
||||
{ id: "p-002-sup-long-pos-0", src: "/assets/patient-profiles/p-002/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png", },
|
||||
{ id: "p-002-sup-long-neg-0", src: "/assets/patient-profiles/p-002/sup-long-neg-0.png", label: "Sup dọc — không viêm (−) · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png", },
|
||||
{ id: "p-002-post-trans-pos-0", src: "/assets/patient-profiles/p-002/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-002-post-trans-neg-0", src: "/assets/patient-profiles/p-002/post-trans-neg-0.png", label: "Sau ngang — không viêm (−) · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb322d-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-002-other-0", src: "/assets/patient-profiles/p-002/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "sup-trans-flex", sourcePath: "backend/tests/test_images/other_angle/trans_flex.png", },
|
||||
],
|
||||
"p-003": [
|
||||
{ id: "p-003-sup-long-pos-0", src: "/assets/patient-profiles/p-003/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png", },
|
||||
{ id: "p-003-sup-long-neg-0", src: "/assets/patient-profiles/p-003/sup-long-neg-0.png", label: "Sup dọc — không viêm (−) · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png", },
|
||||
{ id: "p-003-post-trans-pos-0", src: "/assets/patient-profiles/p-003/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-003-post-trans-neg-0", src: "/assets/patient-profiles/p-003/post-trans-neg-0.png", label: "Sau ngang — không viêm (−) · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-003-other-0", src: "/assets/patient-profiles/p-003/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "med-lat", sourcePath: "backend/tests/test_images/other_angle/med-lat_1.png", },
|
||||
],
|
||||
"p-004": [
|
||||
{ id: "p-004-sup-long-pos-0", src: "/assets/patient-profiles/p-004/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png", },
|
||||
{ id: "p-004-sup-long-neg-0", src: "/assets/patient-profiles/p-004/sup-long-neg-0.png", label: "Sup dọc — không viêm (−) · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png", },
|
||||
{ id: "p-004-post-trans-pos-0", src: "/assets/patient-profiles/p-004/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-004-post-trans-neg-0", src: "/assets/patient-profiles/p-004/post-trans-neg-0.png", label: "Sau ngang — không viêm (−) · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png", },
|
||||
{ id: "p-004-other-0", src: "/assets/patient-profiles/p-004/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "sup-trans-flex", sourcePath: "backend/tests/test_images/other_angle/trans_flex.png", },
|
||||
],
|
||||
};
|
||||
|
||||
export function getScanFramesForPatient(patientId: string): PatientScanProfileFrame[] {
|
||||
return PATIENT_SCAN_PROFILES[patientId] ?? PATIENT_SCAN_PROFILES['p-001'] ?? [];
|
||||
}
|
||||
|
||||
export const FRAMES_PER_PATIENT = 5;
|
||||
export const IMAGES_PER_STRATUM = 1;
|
||||
@@ -0,0 +1,15 @@
|
||||
import medLat1 from './other_angle/med-lat_1.png';
|
||||
import medLat2 from './other_angle/med_lat_2.png';
|
||||
import transFlex from './other_angle/trans_flex.png';
|
||||
|
||||
export interface ScanFrame {
|
||||
id: string;
|
||||
src: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SCAN_FRAMES: ScanFrame[] = [
|
||||
{ id: 'med-lat-1', src: medLat1, label: 'Thành trong — góc 1' },
|
||||
{ id: 'med-lat-2', src: medLat2, label: 'Thành trong — góc 2' },
|
||||
{ id: 'trans-flex', src: transFlex, label: 'Ngang — gấp' },
|
||||
];
|
||||