diff --git a/FLATFOOT_TOOLS_GUIDE.md b/FLATFOOT_TOOLS_GUIDE.md new file mode 100644 index 00000000000..cd98e90f683 --- /dev/null +++ b/FLATFOOT_TOOLS_GUIDE.md @@ -0,0 +1,463 @@ +# Flatfoot Measurement — Tool Reference Guide + +## Overview + +The Flatfoot Measurement module analyzes foot X-rays (lateral view) to detect and classify +flat foot (Pes Planus), high arch (Pes Cavus), and normal arch conditions. +All measurements are performed by clicking on anatomical landmarks directly on the uploaded X-ray image. + +**All placed points are draggable** — after placing a measurement you can click and drag any dot +to reposition it. Angles and distances update live as you drag. + +--- + +## How to Start + +1. Open the app → click **Flatfoot Analysis** button on the WorkList +2. Click **Upload X-Ray** → select a lateral foot X-ray (PNG / JPG / BMP) +3. (Optional) Run **Calibrate** first for accurate mm measurements +4. Select a tool → click points on the image → results appear in the right panel +5. To move a point: hover over any dot (cursor changes to hand) → click-drag to new position + +--- + +## Tools Reference + +--- + +### 1. Cursor +**Button:** `Cursor` +**Color:** Blue + +**Purpose:** Default inactive mode. No measurements are placed. +Use this to inspect the image without accidentally adding points. + +**How to use:** +- Select Cursor → freely move mouse over the image +- No clicks register any measurement + +--- + +### 2. ⬡ Area (Polygon) +**Button:** `⬡ Area` +**Color:** Yellow + +**Purpose:** Measure the enclosed area of any region on the X-ray — e.g., the contact area of the foot, heel pad area, or any anatomical zone of interest. + +**Formula — Shoelace (Gauss Area) Formula:** +``` +Area = |Σ (xᵢ · yᵢ₊₁ − xᵢ₊₁ · yᵢ)| / 2 + +Then convert: + Area (mm²) = Area (px²) / (px_per_mm)² +``` + +**How to use:** +1. Select `⬡ Area` tool +2. Click anywhere on the image → numbered dot appears (●1) +3. Click again → dot ●2 appears, connected to ●1 with a line +4. Keep clicking → each dot connects to the previous one +5. After 3+ dots, the polygon auto-fills with semi-transparent color +6. Live area in mm² shows in the hint bar as you build the polygon +7. Click **✓ Finish & Save Area** to save the result + +**Reading the result:** +- `245.3 mm²` → area enclosed by your polygon in square millimetres + +**Clinical use:** Measure plantar contact area, heel pad, or any region of interest. + +--- + +### 3. Distance +**Button:** `Distance` +**Color:** Green + +**Purpose:** Measure straight-line distance between any two points on the image. + +**Formula — Euclidean Distance:** +``` +Distance (px) = √[(x₂ − x₁)² + (y₂ − y₁)²] + +Distance (mm) = Distance (px) / px_per_mm +``` + +**How to use:** +1. Select `Distance` +2. Click **Point 1** (start) +3. Click **Point 2** (end) +4. Result shows in mm in the right panel + +**Example:** Measure foot length, heel width, or any bone segment. + +--- + +### 4. Calcaneal Pitch +**Button:** `Calcaneal Pitch` +**Color:** Amber/Orange + +**Purpose:** Measures the angle of the calcaneus (heel bone) relative to the ground (horizontal). +Indicates the inclination of the heel — reduced angle = flat foot tendency. + +**Formula — Angle from Horizontal:** +``` +Δy = y₂ − y₁ +Δx = x₂ − x₁ + +Calcaneal Pitch (°) = atan2(Δy, Δx) × (180 / π) +``` + +**Normal Range:** 17° – 32° + +| Result | Interpretation | +|---|---| +| < 17° | Decreased pitch — possible Pes Planus (flat foot) | +| 17° – 32° | Normal calcaneal pitch | +| > 32° | Increased pitch — possible Pes Cavus (high arch) | + +**How to use:** +1. Select `Calcaneal Pitch` +2. Click **Point 1** → Posterior-inferior border of calcaneus (back bottom of heel bone) +3. Click **Point 2** → Anterior-inferior border of calcaneus (front bottom of heel bone) +4. Draw the line along the inferior surface of the calcaneus + +``` + Point 2 (anterior) + / + / ← calcaneal line + / + Point 1 (posterior) + ━━━━━━━━━━━━━━━━━━━━ (ground line / horizontal) +``` + +--- + +### 5. Clarke's Angle +**Button:** `Clarke's Angle` +**Color:** Purple + +**Purpose:** Measures the angle of the medial longitudinal arch. +Uses 3 points to describe how raised or collapsed the arch is. + +**Formula — Cosine Rule (angle at middle point):** +``` +Given 3 points: A (heel), B (arch apex), C (ball of foot) + +Vector BA = A − B +Vector BC = C − B + +cos(θ) = (BA · BC) / (|BA| × |BC|) + +Clarke's Angle (°) = acos(cos(θ)) × (180 / π) +``` + +**Normal Range:** ≥ 42° + +| Result | Interpretation | +|---|---| +| < 42° | Flat arch — Pes Planus | +| 42° – 54° | Normal arch | +| > 54° | High arch — Pes Cavus | + +**How to use:** +1. Select `Clarke's Angle` +2. Click **Point 1** → Heel (most posterior-inferior point of calcaneus) +3. Click **Point 2** → Arch Apex (highest point of medial longitudinal arch / navicular area) +4. Click **Point 3** → 1st Metatarsal Head (ball of foot) + +``` + ●2 (arch apex) + / \ + / \ + / \ + ●1 ──────────── ●3 + (heel) (ball) + ↑ angle measured at ●2 +``` + +--- + +### 6. Arch Index +**Button:** `Arch Index` +**Color:** Orange + +**Purpose:** Quantifies flat foot severity using foot contact area proportions. +Based on the **Cavanagh & Rodgers (1987)** method. + +**Formula — Arch Index (AI):** +``` +The foot is divided into 3 equal thirds (anterior, middle, posterior): + +AI = Mid-foot contact area / Total foot contact area + +Simplified from 2 click points: + Total foot length (px) = Distance(Point 1 → Point 2) + Mid-foot length (px) = Total / 3 (middle third) + +AI = Mid-foot px / Total px +``` + +**Classification (Cavanagh & Rodgers):** + +| Arch Index | Classification | +|---|---| +| < 0.21 | Pes Cavus (high arch) | +| 0.21 – 0.26 | Normal arch | +| > 0.26 | Pes Planus (flat foot) | + +**How to use:** +1. Select `Arch Index` +2. Click **Point 1** → Posterior edge of heel (back of heel) +3. Click **Point 2** → Tip of the longest toe (front of foot) +4. The system divides the foot into 3 equal thirds and calculates AI + +``` + |←── Total Foot Length ──────────────────→| + |← Posterior →|←── Mid-foot ──→|← Ant. →| + ●1 (heel) ●2 (toe) + + AI = middle third / total length +``` + +> **Note:** For clinical accuracy, the Arch Index should ideally be measured +> from a pressure plate footprint, not an X-ray. This tool gives a linear approximation. + +--- + +### 7. Meary's Angle (Talo-First Metatarsal Angle) +**Button:** `Meary's Angle` +**Color:** Pink + +**Purpose:** Measures the angle between the long axis of the talus and the long axis +of the first metatarsal. One of the most reliable indicators of flat foot on lateral X-ray. + +**Formula — Angle Between Two Lines:** +``` +Line 1: Talus axis → defined by Points 1 & 2 +Line 2: 1st Metatarsal axis → defined by Points 3 & 4 + +Vector V1 = P2 − P1 (talus direction) +Vector V2 = P4 − P3 (metatarsal direction) + +dot = V1.x·V2.x + V1.y·V2.y +|V1| = √(V1.x² + V1.y²) +|V2| = √(V2.x² + V2.y²) + +Meary's Angle (°) = acos(dot / (|V1| × |V2|)) × (180 / π) +``` + +**Normal Range:** < 4° + +| Result | Interpretation | +|---|---| +| < 4° | Normal alignment | +| 4° – 15° | Mild Pes Planus | +| 15° – 30° | Moderate Pes Planus | +| > 30° | Severe Pes Planus | + +**How to use:** +1. Select `Meary's Angle` +2. Click **Point 1** → Center of talus head (anterior) +3. Click **Point 2** → Center of talus body (posterior) +4. Click **Point 3** → Base of 1st metatarsal +5. Click **Point 4** → Head of 1st metatarsal + +``` + ●1────●2 ← Talus axis + \ + \ ← angle here + ●3────────●4 + (metatarsal axis) +``` + +--- + +### 8. △ Triangle +**Button:** `△ Triangle` +**Color:** Cyan + +**Purpose:** Drop 3 corner points to form a triangle. Calculates all 3 interior angles, +marks the midpoint of each side, and shows the enclosed area. Useful for measuring +any triangular bone or joint region. + +**Formula — Law of Cosines (all 3 angles):** +``` +Given 3 corners: A, B, C +Side lengths: + ab = |B − A|, bc = |C − B|, ca = |A − C| + +Angle at A = acos((ab² + ca² − bc²) / (2 · ab · ca)) +Angle at B = acos((ab² + bc² − ca²) / (2 · ab · bc)) +Angle at C = 180° − ∠A − ∠B + +Area (px²) = |( (B.x−A.x)·(C.y−A.y) ) − ( (C.x−A.x)·(B.y−A.y) )| / 2 +Area (mm²) = Area (px²) / (px_per_mm)² +``` + +**How to use:** +1. Select `△ Triangle` +2. Click **Point 1** (A) → first corner +3. Click **Point 2** (B) → second corner +4. Click **Point 3** (C) → third corner +5. Triangle draws automatically with: + - Angle labels at each corner (∠1, ∠2, ∠3) + - White circle markers at each side midpoint + - Area displayed at the centroid + +**Reading the result:** +- `∠1=45° ∠2=90° ∠3=45° | 312.4 mm²` +- All 3 angles sum to exactly 180° + +**Dragging:** All 3 corner points can be dragged — angles and area update live. + +``` + ●A + / \ + /∠A \ + / \ + ●B─────●C + ∠B ∠C + mid-points shown as ○ on each side +``` + +--- + +### 9. ⊿⊿ Split Triangle +**Button:** `⊿⊿ Split △` +**Color:** Orange + +**Purpose:** Draw a baseline (like Arch Index) and then drop an apex point. +A perpendicular is dropped from the apex to the baseline, splitting it into +two sub-triangles. All 6 angles (3 per triangle) are calculated. + +**Formula:** +``` +Points: A (line start), B (line end), C (apex) + +Foot of perpendicular D on line AB: + t = ((C − A) · (B − A)) / |B − A|² + D = A + t · (B − A) ← clamped to [0,1] + +Triangle 1: A, D, C → 3 angles via law of cosines +Triangle 2: D, B, C → 3 angles via law of cosines + +Area of each triangle = cross-product / 2 +``` + +**How to use:** +1. Select `⊿⊿ Split △` +2. Click **Point 1** (A) → start of baseline +3. Click **Point 2** (B) → end of baseline +4. Click **Point 3** (C) → apex above the line +5. Point D (foot of perpendicular) appears automatically on the baseline +6. Two filled triangles draw (orange = T1, purple = T2) +7. A dashed line shows the C→D perpendicular; right-angle mark appears at D + +**Reading the result:** +- `T1: ∠A=60° ∠D=90° ∠C=30° (145mm²) | T2: ∠D=90° ∠B=45° ∠C=45° (145mm²)` + +**Dragging:** All 4 points (A, B, C, D) can be dragged independently. +- Dragging **A or B** moves the baseline endpoints +- Dragging **C** moves the apex (D recomputes to stay on the A-B line... unless you also drag D) +- Dragging **D** slides the split point freely along (or off) the baseline + +``` + ●C (apex) + /|\ + / | \ + / | \ + / ∟ | \ + ●A───────●D────●B + T1 (orange) T2 (purple) +``` + +--- + +### 10. Calibrate +**Button:** `Calibrate` +**Color:** Yellow (when active) + +**Purpose:** Set the scale so measurements are accurate in real-world millimetres. +Without calibration, the tool uses a default estimate (96 DPI screen assumption). + +**Formula:** +``` +px_per_mm = pixel_distance_of_known_object / known_length_in_mm +``` + +**How to use:** +1. Find a known reference object in the X-ray — e.g., a ruler, implant, or standard marker +2. Click **Calibrate** +3. Enter the known real-world length (mm) in the input box +4. Click **Point 1** → one end of the known object +5. Click **Point 2** → other end of the known object +6. Calibration is saved — all subsequent measurements use this scale + +**Example:** If a 10mm marker spans 38 pixels → px/mm = 38/10 = 3.8 + +> Always calibrate before taking clinical measurements for accurate results. + +--- + +## Summary Table + +| Tool | Points | Formula | Normal Range | Key Use | +|---|---|---|---|---| +| Cursor | 0 | — | — | Inspect only | +| ⬡ Area | 3+ | Shoelace | — | Contact / region area (mm²) | +| Distance | 2 | Euclidean | — | Any length (mm) | +| Calcaneal Pitch | 2 | atan2 | 17°–32° | Heel bone inclination | +| Clarke's Angle | 3 | Cosine rule | ≥ 42° | Medial arch angle | +| Arch Index | 2 | AI = mid/total | 0.21–0.26 | Arch area ratio | +| Meary's Angle | 4 | Vector dot product | < 4° | Talo-metatarsal alignment | +| △ Triangle | 3 | Law of cosines + cross-product | — | Triangle angles + area | +| ⊿⊿ Split △ | 3 | Perpendicular foot + law of cosines | — | Split baseline into 2 triangles | +| Calibrate | 2 | px/mm ratio | — | Set real-world scale | + +--- + +## Dragging Points + +Every measurement point placed on the canvas is draggable: + +- **Hover** over any dot → cursor changes to a hand (↕) +- **Click and drag** → point moves, measurement updates live +- **Release** → final value saved + +This is especially useful for the Triangle and Split Triangle tools where small adjustments +to corner positions significantly affect the calculated angles. + +--- + +## Recommended Workflow + +``` +1. Upload lateral foot X-ray + ↓ +2. Calibrate (using a known marker on the X-ray) + ↓ +3. Calcaneal Pitch ← quick overall arch check + ↓ +4. Meary's Angle ← most clinically reliable flat foot indicator + ↓ +5. Clarke's Angle ← medial arch assessment + ↓ +6. Arch Index ← load distribution estimate + ↓ +7. Triangle / Split △ ← detailed angular analysis of any bone region + ↓ +8. Distance / Area ← any additional measurements needed + ↓ +9. Review results panel → compare against Normal Ranges +``` + +--- + +## Classification Summary + +| Condition | Calcaneal Pitch | Clarke's | Arch Index | Meary's | +|---|---|---|---|---| +| Pes Cavus (high arch) | > 32° | > 54° | < 0.21 | < 4° | +| Normal | 17°–32° | 42°–54° | 0.21–0.26 | < 4° | +| Pes Planus (flat foot) | < 17° | < 42° | > 0.26 | > 4° | + +--- diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md new file mode 100644 index 00000000000..af9de9ae42b --- /dev/null +++ b/PROJECT_GUIDE.md @@ -0,0 +1,336 @@ +# Clinical Viewer — Complete Project Guide + +This is the full guide for the three clinical tools we built on top of the OHIF medical viewer. +Written in plain language so anyone on the team can understand what was built, how it works, and how to use it. + +--- + +## What Did We Build? + +We added three new clinical analysis tools to the existing OHIF medical image viewer: + +1. **ECG Viewer** — analyze heart ECG strips, measure QT intervals, calculate heart rate +2. **Smart Paint** — paint/highlight regions on any medical image, measure the painted area +3. **Flatfoot Analysis** — measure foot arch angles on X-rays to detect flat foot or high arch + +All three tools open as their own pages inside the app. +You reach them from the main patient list by clicking the colored buttons at the top. + +**No new software libraries were installed.** Everything was built using tools already in the project (React, TypeScript, Tailwind CSS, and the browser's built-in Canvas drawing). + +--- + +## How to Run the App + +``` +1. Open a terminal in the project folder: + cd /home/artem/Desktop/project/frontend/Viewers + +2. Install dependencies (only needed first time): + yarn install + +3. Start the development server: + yarn dev + +4. Open your browser: + http://localhost:3000 +``` + +If you see a patient/study list on screen, everything is working correctly. + +--- + +## The Three Tools + +--- + +### 1. ECG Viewer + +**What it's for:** Analyzing ECG (electrocardiogram) heart tracing images. +You upload a photo or screenshot of an ECG strip, then click on it to measure things like how long the QT interval is, what the heart rate is, and what the electrical axis of the heart is. + +**How to open it:** Click the green **ECG Viewer** button on the patient list page. + +**What you can measure:** + +| What | How many clicks | What you get | +|---|---|---| +| Time interval | 2 clicks | Duration in milliseconds | +| Voltage (amplitude) | 2 clicks | Height in millivolts | +| R-to-R interval | 2 clicks | Heart rate in BPM | +| QT interval + QTc | 2 clicks | QT duration + Bazett-corrected QTc | +| QRS axis | Enter 2 numbers | Heart electrical axis in degrees | +| Side-by-side compare | Upload 2 images | Two ECGs shown together | + +**Before measuring:** Use the Calibrate tools first. Calibrate H sets how many pixels = 1 millisecond. Calibrate V sets how many pixels = 1 millivolt. Without calibration the measurements will be off. + +**Normal values to know:** +- QTc: normal is under 440ms for men, under 460ms for women +- Heart rate: normal resting is 60–100 BPM +- QRS axis: normal is between -30° and +90° + +**Recent improvements:** +- Measurement labels are now smaller and less cluttered (font reduced to ~12px) +- ECG images display sharper and clearer (high-quality image smoothing enabled) +- Supports higher-resolution images (canvas now up to 1800×1200 pixels) + +--- + +### 2. Smart Paint + +**What it's for:** Painting / highlighting a region on any medical image. +Imagine using a digital brush to color over a specific area — the tool then tells you exactly how big that painted area is in pixels. + +**How to open it:** Click the cyan **Smart Paint ROI** button on the patient list page. + +**How to use it:** +1. Click **Upload Image** — select any PNG, JPG, or BMP medical image +2. Choose **Paint** or **Erase** mode +3. Adjust **Brush size** (how big your brush is) and **Sensitivity** (how selective the brush is — higher sensitivity paints only similar-looking pixels) +4. Click and drag over the image to paint +5. The **painted area** shows automatically below the canvas +6. Press **Undo** / **Redo** to fix mistakes +7. Press **Extract Contour** to trace the outline of the painted region + +**Area measurement:** +As you paint, a badge appears below the image showing: +``` +Painted Area: 12,345 px² +``` +This updates live with every brush stroke. + +**Segment colors:** +On the right side panel you can create named segments (like "Region A", "Region B") each with its own color. Selecting a segment changes the brush color. This is useful when you need to mark multiple different regions on the same image. + +**Recent improvements:** +- Live area display added — shows painted pixel count below the canvas and in the status bar +- Panel icon changed to a segmentation icon to better represent what the tool does + +--- + +### 3. Flatfoot Analysis + +**What it's for:** Measuring the foot arch from a lateral (side-view) X-ray. +Doctors use this to determine if a patient has flat feet (Pes Planus), a normal arch, or a high arch (Pes Cavus). + +**How to open it:** Click the amber **Flatfoot Analysis** button on the patient list page. + +**Important:** Before measuring, click **Calibrate** and mark a known distance on the X-ray (like a ruler or implant). This tells the tool how many pixels equal 1 millimeter, making all measurements accurate. + +**All available tools:** + +--- + +#### Cursor +Just for looking at the image. No measurements are placed when this is active. + +--- + +#### Distance +Click 2 points → get the straight-line distance in millimeters. +Use this to measure foot length, bone lengths, or any segment. + +--- + +#### Calcaneal Pitch +Click 2 points along the bottom of the heel bone → get the heel angle. + +| Angle | Meaning | +|---|---| +| Less than 17° | Possibly flat foot | +| 17° to 32° | Normal | +| More than 32° | Possibly high arch | + +--- + +#### Clarke's Angle +Click 3 points: heel → arch top → ball of foot → get the arch angle. + +| Angle | Meaning | +|---|---| +| Less than 42° | Flat arch | +| 42° to 54° | Normal | +| More than 54° | High arch | + +--- + +#### Arch Index +Click 2 points: back of heel → tip of longest toe → get the Arch Index ratio. + +| Index | Meaning | +|---|---| +| Less than 0.21 | High arch | +| 0.21 to 0.26 | Normal | +| More than 0.26 | Flat foot | + +--- + +#### Meary's Angle +Click 4 points: 2 along the talus bone → 2 along the first metatarsal → get the angle between them. +This is one of the most reliable measurements for diagnosing flat foot. + +| Angle | Meaning | +|---|---| +| Less than 4° | Normal alignment | +| 4° to 15° | Mild flat foot | +| 15° to 30° | Moderate flat foot | +| More than 30° | Severe flat foot | + +--- + +#### Triangle (NEW) +Click 3 corner points to form a triangle. +The tool automatically calculates: +- All 3 interior angles (they always add up to 180°) +- A midpoint marker on each side of the triangle +- The area of the triangle in mm² + +This is useful for measuring any triangular bone region or joint space. + +After placing the 3 corners you can **drag any point** to fine-tune the triangle — all angles and area update instantly. + +Example result: `∠1=45° ∠2=90° ∠3=45° | 312.4 mm²` + +--- + +#### Split Triangle (NEW) +This works like a combination of Arch Index (a baseline) and a triangle. + +1. Click **Point 1** → start of your baseline +2. Click **Point 2** → end of your baseline +3. Click **Point 3** → the apex (the top point above the line) + +The tool drops a perpendicular line from the apex down to the baseline. This creates two smaller triangles side by side. All 6 angles (3 per triangle) are shown. + +A right-angle symbol (∟) appears where the perpendicular meets the baseline. + +**Why this is useful:** You can see exactly how a triangular region is divided by a vertical reference line — useful for analyzing how weight or force is distributed across a bone region. + +After placing the 3 points, **all 4 dots are draggable**: +- Drag A or B to move the baseline +- Drag C to move the apex +- Drag D (the perpendicular foot) to shift the split point independently + +Example result: +`T1: ∠A=60° ∠D=90° ∠C=30° (145mm²) | T2: ∠D=90° ∠B=45° ∠C=45° (145mm²)` + +--- + +#### Dragging Points (ALL tools) + +Every dot placed on the canvas — for any tool — can be dragged after placing it. + +- **Hover** over a dot → your cursor changes to a hand symbol +- **Click and hold** → drag to the new position +- **Release** → the measurement recalculates instantly + +This means you don't have to redo a measurement if you placed a point slightly off. Just drag it to the right spot. + +--- + +### Summary of Flatfoot Normal Ranges + +| Measurement | Flat Foot | Normal | High Arch | +|---|---|---|---| +| Calcaneal Pitch | < 17° | 17°–32° | > 32° | +| Clarke's Angle | < 42° | 42°–54° | > 54° | +| Arch Index | > 0.26 | 0.21–0.26 | < 0.21 | +| Meary's Angle | > 4° | < 4° | < 4° | + +--- + +## How Each Tool Page is Structured + +Each of the three tools opens as its own page with: +- A **Back to Worklist** button at the top left to return to the patient list +- A **toolbar** across the top with all the tool buttons +- A **canvas area** in the middle where you work on the image +- A **results panel** on the right (for ECG and Flatfoot) showing all measurements + +--- + +## How the App Was Extended (For Developers) + +### The Three New Extensions + +Each tool is built as a separate "extension" — a self-contained module that plugs into the OHIF viewer. + +``` +extensions/ecg-tools/ ← ECG Viewer extension +extensions/smart-paint/ ← Smart Paint extension +extensions/flatfoot/ ← Flatfoot Analysis extension +``` + +Each extension has: +- A panel component (the main UI you see) +- A utility/tool file (the math and logic) +- An `index.tsx` that registers the extension with OHIF + +### How Pages Are Wired Up + +Three new pages (routes) were added to the app: + +| Web address | What it shows | +|---|---| +| `/ecg-viewer` | ECG Viewer page | +| `/smart-paint` | Smart Paint page | +| `/flatfoot` | Flatfoot Analysis page | + +### How Measurements Work on Canvas + +All three tools draw on an HTML Canvas element. The key technical point: + +When a canvas is displayed smaller than its actual resolution (because of CSS), click positions need to be converted. If you click at CSS position (100, 50) but the canvas is twice as big internally, the actual canvas position is (200, 100). All three tools apply this correction. + +### How Smart Paint's Brush Works + +The brush stores a "mask" — a grid of 1s and 0s the same size as the image. When you paint, pixels within the brush radius get set to 1. When you erase, they go back to 0. The `countPaintedPixels` function simply counts all the 1s in this grid to give you the area. + +The tool keeps a history of up to 50 mask snapshots so Undo and Redo work reliably. + +### How the Triangle Math Works + +**Regular Triangle:** Uses the Law of Cosines to find each angle. +Given sides a, b, c opposite to corners A, B, C: +- Angle at A = arccos((b² + c² − a²) / (2bc)) +- Repeat for B and C +- Area = half the cross-product of two edge vectors + +**Split Triangle:** Finds the point D on line AB that is closest to C (the foot of the perpendicular). This is a standard dot-product projection: move along AB by exactly the amount that brings you directly below C. +- D = A + t × (B − A), where t = dot(C−A, B−A) / dot(B−A, B−A) +- D is then stored as a fourth point so it can be dragged freely + +### How Dragging Works + +Each measurement stores its points in a list. When the user holds the mouse button near a point (within 12 pixels), the app records which measurement and which point index is being dragged. As the mouse moves, that point's position updates and the measurement value recalculates. When the mouse is released, dragging stops. + +A separate flag (`hasDraggedRef`) prevents the app from accidentally placing a new point when the user releases after a drag. + +--- + +## Bug That Was Fixed at the Start + +When the app first ran, it crashed with this error: + +``` +TypeError: isWhitelisted is not a function +``` + +This happened because two language-related libraries were out of sync — one had removed a function the other still expected. The fix was to inject that missing function back in, right before it was needed. This is called a "monkey patch" — a temporary bridge between two incompatible versions. + +--- + +## Quick Reference — What Goes Where + +| I want to... | Open this page | +|---|---| +| Measure QT interval / heart rate | ECG Viewer (`/ecg-viewer`) | +| Paint a region and measure its size | Smart Paint (`/smart-paint`) | +| Check for flat foot on an X-ray | Flatfoot Analysis (`/flatfoot`) | +| Measure a triangle of bone | Flatfoot → Triangle tool | +| Split a region with a perpendicular | Flatfoot → Split Triangle tool | +| Move a measurement point I placed | Hover over it and drag | + +--- + +*Built on OHIF Viewer v3 · Branch: release/3.12 · No new npm packages required* diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md new file mode 100644 index 00000000000..bbd9e8bacaa --- /dev/null +++ b/TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,336 @@ +# Technical Documentation — Custom Clinical Extensions + +> **Project:** OHIF Viewer v3 (monorepo) +> **Branch:** `release/3.12` +> **Last updated:** 2026-03-21 + +--- + +## 1. Libraries & Technologies Used + +### No New npm Packages Were Installed + +All 3 extensions were built **exclusively with libraries already present** in the OHIF monorepo. +No `npm install` or `yarn add` was run. + +| Technology | Version (from existing repo) | Used For | +|---|---|---| +| **React** | ^18 | All UI components | +| **TypeScript** | ^5 | All `.tsx` / `.ts` files | +| **Tailwind CSS** | ^3 | All styling (utility classes) | +| **React Router v6** | `useNavigate`, `Link` | Route navigation between pages | +| **HTML5 Canvas 2D API** | Browser built-in | ECG waveform rendering, flatfoot measurement overlays, Smart Paint mask | +| **`@ohif/ui`** | (workspace) | `Button`, `ButtonEnums`, `StudyListExpandedRow` etc. | +| **`@ohif/ui-next`** | (workspace) | `Header`, `Icons`, `ScrollArea`, `Tooltip`, `Onboarding` | +| **`@ohif/core`** | (workspace) | Peer dependency declared in extension package.json | +| **React.lazy + Suspense** | React 18 built-in | Lazy-loading each extension panel into its route | + +--- + +## 2. Folder Structure — New Files Created + +``` +Viewers/ +├── extensions/ +│ ├── ecg-tools/ ← NEW extension +│ │ ├── package.json +│ │ └── src/ +│ │ ├── index.tsx +│ │ ├── panels/ +│ │ │ └── PanelEcgViewer.tsx ← Main ECG panel +│ │ └── utils/ +│ │ └── ecgCalculations.ts ← ECG math functions +│ │ +│ ├── smart-paint/ ← NEW extension +│ │ ├── package.json +│ │ └── src/ +│ │ ├── index.tsx +│ │ ├── panels/ +│ │ │ └── PanelSmartPaint.tsx ← Smart Paint panel +│ │ └── tools/ +│ │ └── SmartPaintTool.ts ← Brush engine + mask store +│ │ +│ └── flatfoot/ ← NEW extension +│ ├── package.json +│ └── src/ +│ ├── index.tsx +│ ├── panels/ +│ │ └── PanelFlatfoot.tsx ← Flatfoot measurement panel +│ └── utils/ +│ └── flatfootCalculations.ts ← Clinical math functions +│ +├── platform/app/src/routes/ +│ ├── EcgViewer/ ← NEW route folder +│ │ ├── index.js ← Re-export +│ │ └── EcgViewer.tsx ← Route wrapper + Back button +│ │ +│ ├── SmartPaint/ ← NEW route folder +│ │ ├── index.js +│ │ └── SmartPaintRoute.tsx ← Route wrapper + Back button +│ │ +│ └── Flatfoot/ ← NEW route folder +│ ├── index.js +│ └── FlatfootRoute.tsx ← Route wrapper + Back button +│ +├── HACKATHON_CHANGES.md +├── FLATFOOT_TOOLS_GUIDE.md +└── TECHNICAL_DOCUMENTATION.md ← This file +``` + +--- + +## 3. Existing Files Modified + +| File | What Changed | +|---|---| +| `platform/app/src/routes/index.tsx` | Imported 3 new route components; added 3 entries to `bakedInRoutes` array | +| `platform/app/src/routes/WorkList/WorkList.tsx` | Added 3 clinical tool buttons above the study list | +| `platform/i18n/src/index.js` | Bug fix: monkey-patch for missing `isWhitelisted` method | +| `extensions/ecg-tools/src/panels/PanelEcgViewer.tsx` | Smaller annotation font, higher image quality, larger canvas cap | +| `extensions/smart-paint/src/index.tsx` | Panel icon changed from `tool-freehand-roi` to `tab-segmentation` | +| `extensions/smart-paint/src/tools/SmartPaintTool.ts` | Added `countPaintedPixels()` export | +| `extensions/smart-paint/src/panels/PanelSmartPaint.tsx` | Imports `countPaintedPixels`; shows live area in px² below canvas | +| `extensions/flatfoot/src/panels/PanelFlatfoot.tsx` | Added Triangle tool, Split Triangle tool, drag-to-reposition for all points | + +--- + +## 4. Extension Detail + +--- + +### 4.1 ECG Viewer (`extensions/ecg-tools/`) + +**Route:** `/ecg-viewer` +**Panel file:** `extensions/ecg-tools/src/panels/PanelEcgViewer.tsx` +**Math file:** `extensions/ecg-tools/src/utils/ecgCalculations.ts` + +#### What it does +Full-featured ECG analysis tool that loads ECG strip images (PNG/JPG) and lets the user place measurement points directly on the canvas. + +#### Tools available +| Tool | Color | Formula | Output | +|---|---|---|---| +| Cursor | Blue | — | Inspect only | +| Calibrate H | Amber | `px/ms = pixel_dist / known_ms` | Sets time scale | +| Calibrate V | Green | `px/mV = pixel_dist / known_mV` | Sets amplitude scale | +| Time | Blue | Euclidean distance → ms | Duration in ms | +| Amplitude | Purple | Euclidean distance → mV | Voltage in mV | +| RR Interval | Green | `HR = 60000 / RR_ms` | Heart rate (BPM) | +| QT Interval | Red | distance → ms | QT duration | +| QTc | Orange | Bazett: `QTc = QT / √(RR/1000)` | Corrected QT | +| QRS Axis | Pink | `atan2(aVF_amp, LeadI_amp)` | Axis in degrees | +| Compare | Blue | — | Side-by-side two ECGs | + +#### Post-initial improvements +- **Font size:** annotation labels reduced from `14 * √zoom` to `11 * √zoom` (~12px at zoom=1) +- **Image quality:** `ctx.imageSmoothingQuality = 'high'` added before `drawImage` for sharper waveforms +- **Canvas max size:** raised from `MAX_W=1100, MAX_H=700` to `MAX_W=1800, MAX_H=1200` + +#### Key technical decisions +- **Canvas coordinate scaling:** `getBoundingClientRect()` returns CSS display size; click coords multiplied by `canvas.width / rect.width` to get actual pixel position. +- State managed with `useReducer` for complex tool/measurement state. + +--- + +### 4.2 Smart Paint (`extensions/smart-paint/`) + +**Route:** `/smart-paint` +**Panel file:** `extensions/smart-paint/src/panels/PanelSmartPaint.tsx` +**Engine file:** `extensions/smart-paint/src/tools/SmartPaintTool.ts` + +#### What it does +Freehand brush ROI drawing tool. User paints a mask over an uploaded image. Supports undo/redo, sensitivity control, erase mode, contour extraction, and live area measurement. + +#### Architecture +``` +PanelSmartPaint (React) + ├── canvasRef ← displays the loaded image + ├── overlayRef ← transparent canvas on top for mask/contour + └── SmartPaintTool ← stateful engine (singleton per imageKey) + ├── paintBrush() ← fills pixels within brush radius + ├── commitHistory() ← saves snapshot to undo stack + ├── undo() / redo() ← restores from history stack + ├── clearMask() ← resets all painted pixels + ├── renderMaskOverlay() ← draws mask pixels onto overlay canvas + ├── maskToContour() ← traces boundary of painted region + └── countPaintedPixels() ← counts 1-bits in mask for area display +``` + +#### Area Measurement +After every paint stroke, undo, redo, or clear, `countPaintedPixels(state)` is called inside `renderOverlay`. The pixel count is stored in `paintedPx` state and shown: +- **Below the canvas:** a cyan badge `Painted Area: 12,345 px²` with a color swatch +- **In the status bar:** appended as `Area: 12,345 px²` in cyan + +#### Icon +Changed from `tool-freehand-roi` to `tab-segmentation` in `extensions/smart-paint/src/index.tsx`. + +--- + +### 4.3 Flatfoot Measurement (`extensions/flatfoot/`) + +**Route:** `/flatfoot` +**Panel file:** `extensions/flatfoot/src/panels/PanelFlatfoot.tsx` +**Math file:** `extensions/flatfoot/src/utils/flatfootCalculations.ts` + +#### What it does +Clinical foot X-ray measurement tool for diagnosing flat foot (Pes Planus), high arch (Pes Cavus), and normal arch conditions. + +#### Tools & Formulas +| Tool | Points | Formula | Normal Range | +|---|---|---|---| +| ⬡ Area Polygon | 3+ | Shoelace: `A = |Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ)| / 2` | — | +| Distance | 2 | Euclidean: `√((x₂−x₁)² + (y₂−y₁)²)` | — | +| Calcaneal Pitch | 2 | `atan2(Δy, Δx) × 180/π` | 17°–32° | +| Clarke's Angle | 3 | Cosine rule at middle point | ≥ 42° | +| Arch Index | 2 | `AI = mid_third / total_length` | 0.21–0.26 | +| Meary's Angle | 4 | `acos(V1·V2 / (|V1||V2|))` | < 4° | +| Triangle | 3 | Law of cosines (all 3 angles) + cross-product area | — | +| Split Triangle | 3+D | Perpendicular foot projection + law of cosines | — | + +#### Triangle Tool (new) +``` +Points: A, B, C (3 corners) +ab = |B−A|, bc = |C−B|, ca = |A−C| +∠A = acos((ab²+ca²−bc²)/(2·ab·ca)) +∠B = acos((ab²+bc²−ca²)/(2·ab·bc)) +∠C = 180 − ∠A − ∠B +area = |cross(B−A, C−A)| / 2 +``` +Draws: filled triangle, midpoint markers on each side, angle labels pushed outward from centroid, area at centroid. + +#### Split Triangle Tool (new) +``` +Points: A (line start), B (line end), C (apex) +t = ((C−A)·(B−A)) / |B−A|² ← clamped [0,1] +D = A + t·(B−A) ← foot of perpendicular + +Triangle 1 = A, D, C → 3 angles +Triangle 2 = D, B, C → 3 angles +``` +D is stored as `pts[3]` so it can be dragged independently. +Draws: two colored triangles (orange T1, purple T2), dashed C→D line, right-angle mark at D. + +#### Drag-to-reposition (new) +All placed points on all tools are draggable: +``` +dragRef = useRef(null) // { measurementId, pointIdx } while dragging +hasDraggedRef = useRef(false) // prevents click firing after drag +HIT_RADIUS = 12 // canvas-px radius for hit detection + +onMouseDown → find nearest point within HIT_RADIUS → set dragRef +onMouseMove → if dragRef set: update point coords, call recomputeValue() +onMouseUp → clear dragRef +onClick → skip if hasDraggedRef.current is true +``` + +`recomputeValue(tool, points)` re-runs the formula for that tool and returns a new value string without creating a new measurement. + +#### Classification +| Condition | Calcaneal Pitch | Clarke's | Arch Index | Meary's | +|---|---|---|---|---| +| Pes Cavus (high arch) | > 32° | > 54° | < 0.21 | < 4° | +| Normal | 17°–32° | 42°–54° | 0.21–0.26 | < 4° | +| Pes Planus (flat foot) | < 17° | < 42° | > 0.26 | > 4° | + +--- + +## 5. Routing Architecture + +Routes registered in `platform/app/src/routes/index.tsx`: + +```typescript +const bakedInRoutes = [ + // ... existing routes ... + { path: '/ecg-viewer', children: EcgViewer }, + { path: '/smart-paint', children: SmartPaint }, + { path: '/flatfoot', children: Flatfoot }, +]; +``` + +Each route wrapper pattern: +```tsx +export default function XRoute() { + const navigate = useNavigate(); + return ( +
+
+ +
+
+ + + +
+
+ ); +} +``` + +--- + +## 6. WorkList Integration + +The 3 tool buttons appear in the toolbar above the study list. + +``` +[ ▶ ECG Viewer ] [ ▶ Smart Paint ] [ ▶ Flatfoot Analysis ] │ study list below +``` + +--- + +## 7. Key Bug Fixes Applied + +### Bug 1 — Webpack `Module not found` (relative path depth) +**Fix:** Changed relative import depth from `../../../../` to `../../../../../` to correctly reach the repo root. + +### Bug 2 — Canvas click coordinate offset (ECG + Flatfoot) +**Fix:** Scale click coordinates by `canvas.width / rect.width` and `canvas.height / rect.height`. + +### Bug 3 — Linter reverting file edits +**Fix:** Used full-file Write instead of incremental Edit when the formatter reverted partial changes. + +### Bug 4 — D point not draggable in Split Triangle +**Problem:** D was computed each frame from A/B/C, so there was no stored point to drag. +**Fix:** During measurement commit, store 4 points: `[A, B, C, D]`. Both `recomputeValue` and `drawMeasure` check `pts.length === 4` to use stored D; otherwise compute it fresh. + +### Bug 5 — Click fires after drag +**Problem:** `onClick` fired after a drag-release, placing a new measurement point. +**Fix:** `hasDraggedRef.current` is set on first `mousemove` during drag; `onClick` skips if this is true, then resets it. + +--- + +## 8. Verification Checklist + +``` +Start the dev server: + yarn dev (from repo root) + +Open: http://localhost:3000 + +✓ WorkList loads +✓ See: [ECG Viewer] [Smart Paint] [Flatfoot Analysis] buttons +✓ ECG Viewer → upload PNG, calibrate, measure QT → QTc shown +✓ Smart Paint → upload image, paint, area px² updates live, undo/redo works +✓ Flatfoot → upload X-ray, use Triangle tool → 3 angles + area shown +✓ Flatfoot → use Split Triangle → 2 triangles with all angles; drag D point +✓ Flatfoot → drag any measurement point → values recalculate live +``` + +--- + +## 9. Summary Table + +| Item | Count | +|---|---| +| New extensions created | 3 | +| New route folders created | 3 | +| New `.tsx` panel files | 6 (3 panels + 3 route wrappers) | +| New utility/tool `.ts` files | 3 | +| New `package.json` files | 3 | +| Existing files modified | 8 | +| New documentation files | 3 | +| npm packages installed | **0** | + +--- + +*All extensions use only React, TypeScript, Tailwind CSS, HTML5 Canvas, and libraries already present in the OHIF v3 monorepo.* diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx index 229bb9ce223..3a99df19948 100644 --- a/extensions/cornerstone/src/getToolbarModule.tsx +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -429,6 +429,23 @@ export default function getToolbarModule({ servicesManager, extensionManager }: }; }, }, + { + name: 'evaluate.cornerstoneTool.allViewports', + evaluate: ({ viewportId, button, toolNames }) => { + const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); + const toolName = toolbarService.getToolNameForButton(button); + + if (toolGroup && toolGroup.hasTool(toolName)) { + const isPrimaryActive = toolNames + ? toolNames.includes(toolGroup.getActivePrimaryMouseButtonTool()) + : toolGroup.getActivePrimaryMouseButtonTool() === toolName; + return { disabled: false, isActive: isPrimaryActive }; + } + + // Tool not in this viewport's group — still show as enabled (never gray out) + return { disabled: false, isActive: false }; + }, + }, { name: 'evaluate.action', evaluate: () => { diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 27bbca70c95..75be2211fc6 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -51,6 +51,7 @@ import * as polySeg from '@cornerstonejs/polymorphic-segmentation'; import CalibrationLineTool from './tools/CalibrationLineTool'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; +import ABCAngleTool from './tools/ABCAngleTool'; export default function initCornerstoneTools(configuration = {}) { CrosshairsTool.isAnnotation = false; @@ -117,6 +118,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(SculptorTool); addTool(SplineContourSegmentationTool); addTool(LabelMapEditWithContourTool); + addTool(ABCAngleTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { textBoxFontSize: '15px', @@ -180,6 +182,7 @@ const toolNames = { SculptorTool: SculptorTool.toolName, SplineContourSegmentation: SplineContourSegmentationTool.toolName, LabelMapEditWithContourTool: LabelMapEditWithContourTool.toolName, + ABCAngle: ABCAngleTool.toolName, }; export { toolNames }; diff --git a/extensions/cornerstone/src/tools/ABCAngleTool.ts b/extensions/cornerstone/src/tools/ABCAngleTool.ts new file mode 100644 index 00000000000..649e2c191c3 --- /dev/null +++ b/extensions/cornerstone/src/tools/ABCAngleTool.ts @@ -0,0 +1,229 @@ +import { AngleTool, drawing, Enums, annotation, utilities } from '@cornerstonejs/tools'; +import { eventTarget } from '@cornerstonejs/core'; + +const { Events } = Enums; +const { triggerAnnotationRenderForToolGroupIds } = utilities; + +type P2 = [number, number]; +type P3 = [number, number, number]; + +const POINT_LABELS = ['A', 'B', 'C', 'D']; + +/** + * ABCAngleTool + * + * Usage: + * 1. Click → place A + * 2. Click → place B (angle vertex) + * 3. Click → place C (reference anchor — shown as gray dashed reference line) + * 4. D is auto-provided on the B→C extension; drag D to adjust the active arm. + * + * Displayed angle: ∠ABD (between ray B→A and ray B→D). + */ +class ABCAngleTool extends AngleTool { + static toolName = 'ABCAngle'; + + constructor(toolProps = {}, defaultToolProps = {}) { + super(toolProps, defaultToolProps); + eventTarget.addEventListener( + Events.ANNOTATION_COMPLETED, + this._onAnnotationCompleted as EventListener + ); + } + + // ─── Auto-place D after A, B, C are committed ───────────────────────────── + + private _onAnnotationCompleted = (evt: CustomEvent) => { + const ann = (evt.detail as any)?.annotation; + if (ann?.metadata?.toolName !== ABCAngleTool.toolName) return; + + const points: P3[] = ann.data.handles.points; + if (points.length !== 3) return; + + const [_A, B, C] = points; + + // D = C + (C − B) → one BC-length beyond C along B→C + const D: P3 = [ + C[0] + (C[0] - B[0]), + C[1] + (C[1] - B[1]), + C[2] + (C[2] - B[2]), + ]; + + points.push(D); + ann.invalidated = true; + + // Trigger render via tool group IDs (reliable fallback when element metadata is absent) + triggerAnnotationRenderForToolGroupIds(['default', 'mpr']); + }; + + // ─── Angle stats: ∠ABD when D exists ───────────────────────────────────── + + _calculateCachedStats(ann: any, renderingEngine: any, enabledElement: any) { + const { points } = ann.data.handles; + + if (points.length < 4) { + return super._calculateCachedStats(ann, renderingEngine, enabledElement); + } + + const A = points[0] as P3; + const B = points[1] as P3; + const D = points[3] as P3; + + const BAx = A[0] - B[0], BAy = A[1] - B[1], BAz = A[2] - B[2]; + const BDx = D[0] - B[0], BDy = D[1] - B[1], BDz = D[2] - B[2]; + const magBA = Math.sqrt(BAx ** 2 + BAy ** 2 + BAz ** 2); + const magBD = Math.sqrt(BDx ** 2 + BDy ** 2 + BDz ** 2); + + let angle: number | string = 'Incomplete Angle'; + if (magBA > 0 && magBD > 0) { + const cos = Math.max(-1, Math.min(1, (BAx * BDx + BAy * BDy + BAz * BDz) / (magBA * magBD))); + angle = Math.round(Math.acos(cos) * (180 / Math.PI) * 100) / 100; + } + + const targetId = this.getTargetId(enabledElement.viewport); + ann.data.cachedStats[targetId] = { angle }; + ann.invalidated = false; + return ann.data.cachedStats; + } + + // ─── Rendering ──────────────────────────────────────────────────────────── + + renderAnnotation = (enabledElement: any, svgDrawingHelper: any): boolean => { + let renderStatus = false; + const { viewport } = enabledElement; + const { element } = viewport; + + let annotations = annotation.state.getAnnotations(ABCAngleTool.toolName, element); + if (!annotations?.length) return renderStatus; + + annotations = this.filterInteractableAnnotationsForElement(element, annotations); + if (!annotations?.length) return renderStatus; + + const targetId = this.getTargetId(viewport); + const renderingEngine = viewport.getRenderingEngine(); + + const styleSpecifier: any = { + toolGroupId: this.toolGroupId, + toolName: this.getToolName(), + viewportId: viewport.id, + }; + + for (const ann of annotations) { + const { annotationUID, data } = ann; + const { points, activeHandleIndex } = data.handles; + + if (!annotation.visibility.isAnnotationVisible(annotationUID)) continue; + + styleSpecifier.annotationUID = annotationUID; + const { color, lineWidth, lineDash } = this.getAnnotationStyle({ annotation: ann, styleSpecifier }); + + const canvasCoords = points.map((p: P3) => viewport.worldToCanvas(p) as P2); + + // ── Update stats ────────────────────────────────────────────────── + const cached = data.cachedStats[targetId] as { angle?: number | string } | undefined; + if (!cached || cached.angle == null) { + data.cachedStats[targetId] = { angle: null }; + this._calculateCachedStats(ann, renderingEngine, enabledElement); + } else if (ann.invalidated) { + this._calculateCachedStats(ann, renderingEngine, enabledElement); + } + + // ── Handles ─────────────────────────────────────────────────────── + const isLocked = annotation.locking.isAnnotationLocked(annotationUID); + const hasActive = !isLocked && !this.editData && activeHandleIndex !== null; + if (hasActive) { + drawing.drawHandles(svgDrawingHelper, annotationUID, '0', canvasCoords, { + color, lineDash, lineWidth, + }); + } + + // ── Line A–B ────────────────────────────────────────────────────── + if (canvasCoords.length >= 2) { + drawing.drawLine(svgDrawingHelper, annotationUID, 'line-AB', canvasCoords[0], canvasCoords[1], { + color, width: lineWidth, lineDash, + }); + renderStatus = true; + } + + if (canvasCoords.length >= 3) { + const hasD = canvasCoords.length >= 4; + const activeArmEnd: P2 = hasD ? canvasCoords[3] : canvasCoords[2]; + const refEnd: P2 | null = hasD ? canvasCoords[2] : null; + + // B → D (active arm) + drawing.drawLine(svgDrawingHelper, annotationUID, 'line-BD', canvasCoords[1], activeArmEnd, { + color, width: lineWidth, lineDash, + }); + + // B → C gray dashed reference (only after D exists) + if (refEnd) { + drawing.drawLine(svgDrawingHelper, annotationUID, 'line-BC-ref', canvasCoords[1], refEnd, { + color: '#888888', width: '1', lineDash: '4,4', + }); + } + } + + // ── Labels A, B, C, D ──────────────────────────────────────────── + canvasCoords.forEach((cp, i) => { + const label = POINT_LABELS[i]; + if (!label) return; + drawing.drawTextBox( + svgDrawingHelper, + annotationUID, + `pt-label-${label}`, + [label], + [cp[0] + 10, cp[1] - 10] as P2, + { + fontFamily: 'Arial, sans-serif', + fontSize: '14px', + color: '#FFD700', + background: 'rgba(0,0,0,0.5)', + padding: 2, + } + ); + }); + + // ── Angle text box ──────────────────────────────────────────────── + const cachedAngle = (data.cachedStats[targetId] as { angle?: number | string })?.angle; + if (cachedAngle == null) continue; + + const options = this.getLinkedTextBoxStyle(styleSpecifier, ann); + if (!options.visibility) { + data.handles.textBox = { + hasMoved: false, + worldPosition: [0, 0, 0] as P3, + worldBoundingBox: { + topLeft: [0, 0, 0], topRight: [0, 0, 0], + bottomLeft: [0, 0, 0], bottomRight: [0, 0, 0], + }, + }; + continue; + } + + const textLines = + typeof cachedAngle === 'number' + ? [`\u2220ABD: ${cachedAngle}\u00B0`] + : [String(cachedAngle)]; + + if (!data.handles.textBox.hasMoved) { + data.handles.textBox.worldPosition = viewport.canvasToWorld(canvasCoords[1]); + } + const textBoxPos = viewport.worldToCanvas(data.handles.textBox.worldPosition) as P2; + + drawing.drawLinkedTextBox( + svgDrawingHelper, + annotationUID, + 'textbox-1', + textLines, + textBoxPos, + canvasCoords, + {}, + options + ); + } + + return renderStatus; + }; +} + +export default ABCAngleTool; diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index c1bbb42cfe1..2053ee89da5 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; + +const EMBED_VIEWER_EVENT = 'ohif:embedViewer'; import PropTypes from 'prop-types'; import { InvestigationalUseDialog } from '@ohif/ui-next'; @@ -147,6 +149,22 @@ function ViewerLayout({ }; }, [panelService, hasPanels]); + const [embeddedViewerUrl, setEmbeddedViewerUrl] = useState(null); + + useEffect(() => { + const handler = (e: Event) => { + const url = (e as CustomEvent<{ url: string | null }>).detail?.url; + setEmbeddedViewerUrl(url ?? null); + }; + window.addEventListener(EMBED_VIEWER_EVENT, handler); + return () => window.removeEventListener(EMBED_VIEWER_EVENT, handler); + }, []); + + const closeEmbedded = useCallback(() => { + (window as any)._ohifEmbeddedViewer = null; + window.dispatchEvent(new CustomEvent(EMBED_VIEWER_EVENT, { detail: { url: null } })); + }, []); + const viewportComponents = viewports.map(getViewportComponentData); return ( @@ -163,58 +181,79 @@ function ViewerLayout({ > {showLoadingIndicator && } - - {/* LEFT SIDEPANELS */} - {hasLeftPanels ? ( - <> - - - - - - ) : null} - {/* TOOLBAR + GRID */} - -
-
+
+ Embedded Viewer +
+