Deploy Pathora Viewer: tile server, viewer components, and root app.py

#3
backend/tile_server/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Tile Server (OpenSlide + FastAPI)
2
+
3
+ Run:
4
+ pip install -r requirements.txt
5
+ uvicorn app.main:app --reload --port 8001
6
+
7
+ Endpoints:
8
+ POST /slides/{slide_id}/load
9
+ Body: {"path": "C:/path/to/slide.svs"}
10
+
11
+ POST /slides/{slide_id}/upload
12
+ Form-data: file=<your slide.svs>
13
+
14
+ GET /slides/{slide_id}/metadata
15
+
16
+ GET /tiles/{slide_id}/{level}/{x}/{y}.jpg?tile_size=256
backend/tile_server/app/__init__.py ADDED
File without changes
backend/tile_server/app/api/__init__.py ADDED
File without changes
backend/tile_server/app/api/routes.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, HTTPException, Query, UploadFile, File
6
+ from fastapi.responses import Response
7
+ import openslide
8
+
9
+ from app.models.schemas import SlideLoadRequest, SlideMetadata
10
+ from app.services.slide_cache import get_slide, load_slide
11
+ from app.services.tile_service import get_tile_jpeg, get_thumbnail_jpeg
12
+
13
+ router = APIRouter()
14
+
15
+ UPLOAD_DIR = Path(
16
+ os.getenv(
17
+ "TILE_SERVER_UPLOAD_DIR",
18
+ Path(__file__).resolve().parents[2] / "data" / "uploads",
19
+ )
20
+ )
21
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+
24
+ @router.post("/slides/{slide_id}/load")
25
+ def load(slide_id: str, payload: SlideLoadRequest):
26
+ try:
27
+ load_slide(slide_id, payload.path)
28
+ except Exception as exc:
29
+ raise HTTPException(status_code=400, detail=str(exc))
30
+ return {"status": "loaded", "slide_id": slide_id}
31
+
32
+
33
+ @router.post("/slides/{slide_id}/upload")
34
+ def upload(slide_id: str, file: UploadFile = File(...)):
35
+ if not slide_id:
36
+ raise HTTPException(status_code=400, detail="Slide id is required")
37
+
38
+ filename = Path(file.filename or "")
39
+ suffix = filename.suffix.lower()
40
+ if suffix not in {".svs", ".tif", ".tiff"}:
41
+ suffix = ".svs"
42
+
43
+ dest_path = UPLOAD_DIR / f"{slide_id}{suffix}"
44
+
45
+ try:
46
+ with dest_path.open("wb") as buffer:
47
+ shutil.copyfileobj(file.file, buffer)
48
+ except Exception as exc:
49
+ raise HTTPException(status_code=500, detail=f"Upload failed: {exc}")
50
+ finally:
51
+ try:
52
+ file.file.close()
53
+ except Exception:
54
+ pass
55
+
56
+ try:
57
+ load_slide(slide_id, str(dest_path))
58
+ except Exception as exc:
59
+ raise HTTPException(status_code=400, detail=str(exc))
60
+
61
+ return {"status": "uploaded", "slide_id": slide_id}
62
+
63
+
64
+ @router.post("/slides/{slide_id}/reload")
65
+ def reload_uploaded(slide_id: str):
66
+ if not slide_id:
67
+ raise HTTPException(status_code=400, detail="Slide id is required")
68
+
69
+ matches = list(UPLOAD_DIR.glob(f"{slide_id}.*"))
70
+ if not matches:
71
+ raise HTTPException(status_code=404, detail="Uploaded slide not found")
72
+
73
+ try:
74
+ load_slide(slide_id, str(matches[0]))
75
+ except Exception as exc:
76
+ raise HTTPException(status_code=400, detail=str(exc))
77
+
78
+ return {"status": "reloaded", "slide_id": slide_id}
79
+
80
+
81
+ @router.get("/slides/{slide_id}/metadata", response_model=SlideMetadata)
82
+ def metadata(slide_id: str):
83
+ slide = get_slide(slide_id)
84
+ if slide is None:
85
+ raise HTTPException(status_code=404, detail="Slide not loaded")
86
+
87
+ mpp_x = slide.properties.get(openslide.PROPERTY_NAME_MPP_X)
88
+ mpp_y = slide.properties.get(openslide.PROPERTY_NAME_MPP_Y)
89
+
90
+ def _to_float(value):
91
+ try:
92
+ return float(value)
93
+ except (TypeError, ValueError):
94
+ return None
95
+
96
+ return SlideMetadata(
97
+ width=slide.dimensions[0],
98
+ height=slide.dimensions[1],
99
+ level_count=slide.level_count,
100
+ level_dimensions=[[int(w), int(h)] for w, h in slide.level_dimensions],
101
+ level_downsamples=[float(d) for d in slide.level_downsamples],
102
+ mpp_x=_to_float(mpp_x),
103
+ mpp_y=_to_float(mpp_y),
104
+ )
105
+
106
+
107
+ @router.get("/tiles/{slide_id}/{level}/{x}/{y}.jpg")
108
+ def tile(
109
+ slide_id: str,
110
+ level: int,
111
+ x: int,
112
+ y: int,
113
+ tile_size: int = Query(256, ge=64, le=2048),
114
+ channel: str = Query("original"),
115
+ ):
116
+ slide = get_slide(slide_id)
117
+ if slide is None:
118
+ raise HTTPException(status_code=404, detail="Slide not loaded")
119
+
120
+ try:
121
+ jpeg_bytes = get_tile_jpeg(slide, level, x, y, tile_size, channel)
122
+ except Exception as exc:
123
+ raise HTTPException(status_code=400, detail=str(exc))
124
+
125
+ return Response(content=jpeg_bytes, media_type="image/jpeg")
126
+
127
+
128
+ @router.get("/slides/{slide_id}/thumbnail")
129
+ def thumbnail(
130
+ slide_id: str,
131
+ size: int = Query(256, ge=64, le=1024),
132
+ channel: str = Query("original"),
133
+ ):
134
+ slide = get_slide(slide_id)
135
+ if slide is None:
136
+ raise HTTPException(status_code=404, detail="Slide not loaded")
137
+
138
+ try:
139
+ jpeg_bytes = get_thumbnail_jpeg(slide, size, channel)
140
+ except Exception as exc:
141
+ raise HTTPException(status_code=400, detail=str(exc))
142
+
143
+ return Response(content=jpeg_bytes, media_type="image/jpeg")
backend/tile_server/app/main.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ from app.api.routes import router
5
+
6
+ app = FastAPI(title="Pathora Tile Server")
7
+
8
+ app.add_middleware(
9
+ CORSMiddleware,
10
+ allow_origins=["*"],
11
+ allow_credentials=True,
12
+ allow_methods=["*"],
13
+ allow_headers=["*"],
14
+ )
15
+
16
+ app.include_router(router)
backend/tile_server/app/models/__init__.py ADDED
File without changes
backend/tile_server/app/models/schemas.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class SlideLoadRequest(BaseModel):
6
+ path: str
7
+
8
+
9
+ class SlideMetadata(BaseModel):
10
+ width: int
11
+ height: int
12
+ level_count: int
13
+ level_dimensions: List[List[int]]
14
+ level_downsamples: List[float]
15
+ mpp_x: Optional[float] = None
16
+ mpp_y: Optional[float] = None
backend/tile_server/app/services/__init__.py ADDED
File without changes
backend/tile_server/app/services/slide_cache.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import OrderedDict
2
+ from threading import Lock
3
+ from typing import Dict, Optional
4
+
5
+ import openslide
6
+
7
+
8
+ class SlideCache:
9
+ def __init__(self, max_items: int = 8) -> None:
10
+ self._max_items = max_items
11
+ self._slides: Dict[str, openslide.OpenSlide] = OrderedDict()
12
+ self._lock = Lock()
13
+
14
+ def get(self, slide_id: str) -> Optional[openslide.OpenSlide]:
15
+ with self._lock:
16
+ slide = self._slides.get(slide_id)
17
+ if slide is None:
18
+ return None
19
+ self._slides.move_to_end(slide_id)
20
+ return slide
21
+
22
+ def put(self, slide_id: str, path: str) -> openslide.OpenSlide:
23
+ with self._lock:
24
+ if slide_id in self._slides:
25
+ self._slides.move_to_end(slide_id)
26
+ return self._slides[slide_id]
27
+
28
+ slide = openslide.OpenSlide(path)
29
+ self._slides[slide_id] = slide
30
+ self._slides.move_to_end(slide_id)
31
+
32
+ while len(self._slides) > self._max_items:
33
+ _, oldest = self._slides.popitem(last=False)
34
+ try:
35
+ oldest.close()
36
+ except Exception:
37
+ pass
38
+
39
+ return slide
40
+
41
+
42
+ cache = SlideCache()
43
+
44
+
45
+ def load_slide(slide_id: str, path: str) -> openslide.OpenSlide:
46
+ return cache.put(slide_id, path)
47
+
48
+
49
+ def get_slide(slide_id: str) -> Optional[openslide.OpenSlide]:
50
+ return cache.get(slide_id)
backend/tile_server/app/services/tile_service.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from io import BytesIO
2
+ from typing import Tuple
3
+
4
+ import numpy as np
5
+ from PIL import Image
6
+ import openslide
7
+ from skimage import color
8
+
9
+
10
+ def _level_downsample(slide: openslide.OpenSlide, level: int) -> float:
11
+ return float(slide.level_downsamples[level])
12
+
13
+
14
+ def _level_dims(slide: openslide.OpenSlide, level: int) -> Tuple[int, int]:
15
+ return slide.level_dimensions[level]
16
+
17
+
18
+ def _blank_tile(tile_size: int) -> bytes:
19
+ img = Image.new("RGB", (tile_size, tile_size), (255, 255, 255))
20
+ buf = BytesIO()
21
+ img.save(buf, format="JPEG", quality=85)
22
+ return buf.getvalue()
23
+
24
+
25
+ def _channel_from_rgb(rgb: Image.Image, channel: str) -> Image.Image:
26
+ if channel == "original":
27
+ return rgb
28
+
29
+ arr = np.asarray(rgb).astype(np.float32) / 255.0
30
+ hed = color.rgb2hed(arr)
31
+
32
+ if channel == "hematoxylin":
33
+ hed_only = np.zeros_like(hed)
34
+ hed_only[..., 0] = hed[..., 0]
35
+ elif channel == "eosin":
36
+ hed_only = np.zeros_like(hed)
37
+ hed_only[..., 1] = hed[..., 1]
38
+ else:
39
+ return rgb
40
+
41
+ rgb_stain = color.hed2rgb(hed_only)
42
+ rgb_stain = np.clip(rgb_stain, 0.0, 1.0)
43
+
44
+ # Normalize for better contrast
45
+ min_val = rgb_stain.min(axis=(0, 1), keepdims=True)
46
+ max_val = rgb_stain.max(axis=(0, 1), keepdims=True)
47
+ denom = np.maximum(max_val - min_val, 1e-6)
48
+ rgb_stain = (rgb_stain - min_val) / denom
49
+
50
+ out = (rgb_stain * 255.0).clip(0, 255).astype(np.uint8)
51
+ return Image.fromarray(out, mode="RGB")
52
+
53
+
54
+ def get_tile_jpeg(
55
+ slide: openslide.OpenSlide,
56
+ level: int,
57
+ x: int,
58
+ y: int,
59
+ tile_size: int,
60
+ channel: str = "original",
61
+ ) -> bytes:
62
+ if level < 0 or level >= slide.level_count:
63
+ raise ValueError("Invalid level")
64
+
65
+ level_w, level_h = _level_dims(slide, level)
66
+
67
+ px = x * tile_size
68
+ py = y * tile_size
69
+
70
+ if px >= level_w or py >= level_h:
71
+ return _blank_tile(tile_size)
72
+
73
+ downsample = _level_downsample(slide, level)
74
+ x0 = int(px * downsample)
75
+ y0 = int(py * downsample)
76
+
77
+ region = slide.read_region((x0, y0), level, (tile_size, tile_size))
78
+ rgb = region.convert("RGB")
79
+ rgb = _channel_from_rgb(rgb, channel)
80
+
81
+ buf = BytesIO()
82
+ rgb.save(buf, format="JPEG", quality=85)
83
+ return buf.getvalue()
84
+
85
+
86
+ def get_thumbnail_jpeg(
87
+ slide: openslide.OpenSlide,
88
+ size: int = 256,
89
+ channel: str = "original",
90
+ ) -> bytes:
91
+ level = max(slide.level_count - 1, 0)
92
+ level_w, level_h = _level_dims(slide, level)
93
+
94
+ # Read the full lowest-resolution level, then downscale to a fixed thumbnail.
95
+ region = slide.read_region((0, 0), level, (level_w, level_h))
96
+ rgb = region.convert("RGB")
97
+ rgb = _channel_from_rgb(rgb, channel)
98
+ # Preserve aspect ratio and pad to square so the whole slide is visible.
99
+ scale = min(size / max(level_w, 1), size / max(level_h, 1))
100
+ new_w = max(int(level_w * scale), 1)
101
+ new_h = max(int(level_h * scale), 1)
102
+ rgb = rgb.resize((new_w, new_h), resample=Image.BILINEAR)
103
+
104
+ canvas = Image.new("RGB", (size, size), (255, 255, 255))
105
+ offset_x = (size - new_w) // 2
106
+ offset_y = (size - new_h) // 2
107
+ canvas.paste(rgb, (offset_x, offset_y))
108
+ rgb = canvas
109
+
110
+ buf = BytesIO()
111
+ rgb.save(buf, format="JPEG", quality=85)
112
+ return buf.getvalue()
backend/tile_server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ openslide-python
4
+ pillow
5
+ numpy
6
+ scikit-image
frontend/src/components/viewer/AnnotationCanvas.tsx ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import OpenSeadragon from "openseadragon";
3
+
4
+ export interface Annotation {
5
+ id: string;
6
+ type: "rectangle" | "polygon" | "ellipse" | "brush";
7
+ points: Array<{ x: number; y: number }>;
8
+ color: string;
9
+ label: string;
10
+ completed: boolean;
11
+ }
12
+
13
+ interface AnnotationCanvasProps {
14
+ viewer: OpenSeadragon.Viewer | null;
15
+ tool: "rectangle" | "polygon" | "ellipse" | "brush" | "select" | "none";
16
+ onAnnotationComplete: (annotation: Annotation) => void;
17
+ activeLabel: string;
18
+ onAnnotationSelected?: (annotationId: string | null) => void;
19
+ annotations: Annotation[];
20
+ selectedAnnotationId?: string | null;
21
+ showAnnotations: boolean;
22
+ }
23
+
24
+ export function AnnotationCanvas({
25
+ viewer,
26
+ tool,
27
+ onAnnotationComplete,
28
+ activeLabel,
29
+ onAnnotationSelected,
30
+ annotations,
31
+ selectedAnnotationId,
32
+ showAnnotations,
33
+ }: AnnotationCanvasProps) {
34
+ const canvasRef = useRef<HTMLCanvasElement>(null);
35
+ const [isDrawing, setIsDrawing] = useState(false);
36
+ const [currentPoints, setCurrentPoints] = useState<Array<{ x: number; y: number }>>([]);
37
+ const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null);
38
+ const [drawingTool, setDrawingTool] = useState<"rectangle" | "polygon" | "ellipse" | "brush" | null>(null);
39
+ const [isPanning, setIsPanning] = useState(false);
40
+ const [lastPanPoint, setLastPanPoint] = useState<{ x: number; y: number } | null>(null);
41
+
42
+ const labelColor = (label: string) => {
43
+ switch (label) {
44
+ case "Tumor":
45
+ return "#EF4444";
46
+ case "Benign":
47
+ return "#FACC15";
48
+ case "Stroma":
49
+ return "#EC4899";
50
+ case "Necrosis":
51
+ return "#22C55E";
52
+ case "DCIS":
53
+ return "#3B82F6";
54
+ case "Invasive":
55
+ return "#8B5CF6";
56
+ default:
57
+ return "#9CA3AF";
58
+ }
59
+ };
60
+
61
+ const toImagePoint = (point: { x: number; y: number }) => {
62
+ if (!viewer) return point;
63
+ const viewportPoint = viewer.viewport.pointFromPixel(
64
+ new OpenSeadragon.Point(point.x, point.y)
65
+ );
66
+ const imagePoint = viewer.viewport.viewportToImageCoordinates(viewportPoint);
67
+ return { x: imagePoint.x, y: imagePoint.y };
68
+ };
69
+
70
+ const toScreenPoint = (point: { x: number; y: number }) => {
71
+ if (!viewer) return point;
72
+ const viewportPoint = viewer.viewport.imageToViewportCoordinates(
73
+ new OpenSeadragon.Point(point.x, point.y)
74
+ );
75
+ const pixelPoint = viewer.viewport.pixelFromPoint(viewportPoint, true);
76
+ return { x: pixelPoint.x, y: pixelPoint.y };
77
+ };
78
+
79
+ // Setup canvas
80
+ useEffect(() => {
81
+ const canvas = canvasRef.current;
82
+ if (!canvas || !viewer) return;
83
+
84
+ const updateCanvasSize = () => {
85
+ canvas.width = viewer.container.clientWidth;
86
+ canvas.height = viewer.container.clientHeight;
87
+ redrawAnnotations();
88
+ };
89
+
90
+ updateCanvasSize();
91
+
92
+ // Update canvas size when viewer resizes
93
+ const resizeObserver = new ResizeObserver(updateCanvasSize);
94
+ resizeObserver.observe(viewer.container);
95
+
96
+ return () => resizeObserver.disconnect();
97
+ }, [viewer]);
98
+
99
+ // Helper function to check if point is inside annotation
100
+ const isPointInAnnotation = (point: { x: number; y: number }, annotation: Annotation): boolean => {
101
+ const tolerance = 10; // pixels
102
+ const screenPoints = annotation.points.map(toScreenPoint);
103
+
104
+ if (annotation.type === "rectangle" && screenPoints.length === 2) {
105
+ const [p1, p2] = screenPoints;
106
+ const minX = Math.min(p1.x, p2.x);
107
+ const maxX = Math.max(p1.x, p2.x);
108
+ const minY = Math.min(p1.y, p2.y);
109
+ const maxY = Math.max(p1.y, p2.y);
110
+
111
+ return point.x >= minX - tolerance && point.x <= maxX + tolerance &&
112
+ point.y >= minY - tolerance && point.y <= maxY + tolerance;
113
+ } else if (annotation.type === "ellipse" && screenPoints.length === 2) {
114
+ const [p1, p2] = screenPoints;
115
+ const cx = (p1.x + p2.x) / 2;
116
+ const cy = (p1.y + p2.y) / 2;
117
+ const rx = Math.max(1, Math.abs(p2.x - p1.x) / 2);
118
+ const ry = Math.max(1, Math.abs(p2.y - p1.y) / 2);
119
+ const dx = (point.x - cx) / rx;
120
+ const dy = (point.y - cy) / ry;
121
+ const dist = Math.abs(dx * dx + dy * dy - 1);
122
+ const tol = tolerance / Math.max(rx, ry);
123
+ return dist <= tol;
124
+ } else if (annotation.type === "polygon" && screenPoints.length > 2) {
125
+ // Check if point is near any line segment of the polygon
126
+ const points = screenPoints;
127
+ for (let i = 0; i < points.length; i++) {
128
+ const p1 = points[i];
129
+ const p2 = points[(i + 1) % points.length];
130
+
131
+ const dist = distanceToLineSegment(point, p1, p2);
132
+ if (dist < tolerance) return true;
133
+ }
134
+ // Also check if near any vertex
135
+ return screenPoints.some(p =>
136
+ Math.hypot(p.x - point.x, p.y - point.y) < tolerance
137
+ );
138
+ } else if (annotation.type === "brush" && screenPoints.length > 1) {
139
+ for (let i = 0; i < screenPoints.length - 1; i++) {
140
+ const dist = distanceToLineSegment(point, screenPoints[i], screenPoints[i + 1]);
141
+ if (dist < tolerance) return true;
142
+ }
143
+ }
144
+ return false;
145
+ };
146
+
147
+ // Helper function to calculate distance from point to line segment
148
+ const distanceToLineSegment = (
149
+ point: { x: number; y: number },
150
+ p1: { x: number; y: number },
151
+ p2: { x: number; y: number }
152
+ ): number => {
153
+ const dx = p2.x - p1.x;
154
+ const dy = p2.y - p1.y;
155
+ const t = Math.max(0, Math.min(1, ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / (dx * dx + dy * dy)));
156
+ const closestX = p1.x + t * dx;
157
+ const closestY = p1.y + t * dy;
158
+ return Math.hypot(point.x - closestX, point.y - closestY);
159
+ };
160
+
161
+ // Handle mouse events for drawing
162
+ useEffect(() => {
163
+ const canvas = canvasRef.current;
164
+ if (!canvas || !viewer) return;
165
+
166
+ const handleMouseMove = (e: MouseEvent) => {
167
+ const rect = canvas.getBoundingClientRect();
168
+ const x = e.clientX - rect.left;
169
+ const y = e.clientY - rect.top;
170
+ const point = { x, y };
171
+
172
+ // Update hover state based on tool
173
+ if (tool === "select") {
174
+ const hoveredAnnotation = annotations.find(ann => isPointInAnnotation(point, ann));
175
+ setHoveredAnnotationId(hoveredAnnotation?.id || null);
176
+
177
+ // Change cursor when hovering over annotation
178
+ if (hoveredAnnotation) {
179
+ canvas.style.cursor = "pointer";
180
+ } else {
181
+ canvas.style.cursor = "default";
182
+ }
183
+ } else {
184
+ setHoveredAnnotationId(null);
185
+ }
186
+
187
+ // Handle select tool panning
188
+ if (tool === "select" && isPanning && lastPanPoint && viewer) {
189
+ const dx = x - lastPanPoint.x;
190
+ const dy = y - lastPanPoint.y;
191
+ const delta = viewer.viewport.deltaPointsFromPixels(
192
+ new OpenSeadragon.Point(-dx, -dy),
193
+ true
194
+ );
195
+ viewer.viewport.panBy(delta);
196
+ viewer.viewport.applyConstraints();
197
+ setLastPanPoint({ x, y });
198
+ return;
199
+ }
200
+
201
+ // Handle drawing preview for rectangle/ellipse tool
202
+ if (isDrawing && (tool === "rectangle" || tool === "ellipse") && currentPoints.length > 0) {
203
+ const imagePoint = toImagePoint(point);
204
+ setCurrentPoints([currentPoints[0], imagePoint]);
205
+ }
206
+
207
+ if (isDrawing && tool === "brush" && currentPoints.length > 0) {
208
+ const imagePoint = toImagePoint(point);
209
+ const lastPoint = currentPoints[currentPoints.length - 1];
210
+ if (Math.hypot(imagePoint.x - lastPoint.x, imagePoint.y - lastPoint.y) > 0.5) {
211
+ setCurrentPoints([...currentPoints, imagePoint]);
212
+ }
213
+ }
214
+ };
215
+
216
+ const handleMouseDown = (e: MouseEvent) => {
217
+ const rect = canvas.getBoundingClientRect();
218
+ const x = e.clientX - rect.left;
219
+ const y = e.clientY - rect.top;
220
+ const point = { x, y };
221
+
222
+ // Allow selection with Ctrl+Click on any annotation (works with any tool)
223
+ if (e.ctrlKey || e.metaKey) {
224
+ const selectedAnnotation = annotations.find(ann => isPointInAnnotation(point, ann));
225
+ if (onAnnotationSelected) {
226
+ onAnnotationSelected(selectedAnnotation?.id || null);
227
+ }
228
+ return;
229
+ }
230
+
231
+ // Handle select tool - click to select annotation, drag to pan
232
+ if (tool === "select") {
233
+ const selectedAnnotation = annotations.find(ann => isPointInAnnotation(point, ann));
234
+ if (selectedAnnotation) {
235
+ if (onAnnotationSelected) {
236
+ onAnnotationSelected(selectedAnnotation.id);
237
+ }
238
+ return;
239
+ }
240
+
241
+ if (viewer) {
242
+ setIsPanning(true);
243
+ setLastPanPoint({ x, y });
244
+ }
245
+ return;
246
+ }
247
+
248
+ // Handle drawing tools
249
+ if (tool === "rectangle") {
250
+ setIsDrawing(true);
251
+ setDrawingTool("rectangle");
252
+ setCurrentPoints([toImagePoint(point)]);
253
+ } else if (tool === "ellipse") {
254
+ setIsDrawing(true);
255
+ setDrawingTool("ellipse");
256
+ setCurrentPoints([toImagePoint(point)]);
257
+ } else if (tool === "polygon") {
258
+ // For polygon, add point on click
259
+ const imagePoint = toImagePoint(point);
260
+ const newPoints = [...currentPoints, imagePoint];
261
+ setCurrentPoints(newPoints);
262
+ if (currentPoints.length === 0) {
263
+ setIsDrawing(true);
264
+ setDrawingTool("polygon");
265
+ }
266
+ } else if (tool === "brush") {
267
+ setIsDrawing(true);
268
+ setDrawingTool("brush");
269
+ setCurrentPoints([toImagePoint(point)]);
270
+ }
271
+ };
272
+
273
+ const handleMouseUp = (e: MouseEvent) => {
274
+ if (tool === "select" && isPanning) {
275
+ setIsPanning(false);
276
+ setLastPanPoint(null);
277
+ return;
278
+ }
279
+
280
+ if (!isDrawing || (tool !== "rectangle" && tool !== "ellipse" && tool !== "brush")) return;
281
+
282
+ const rect = canvas.getBoundingClientRect();
283
+ const x = e.clientX - rect.left;
284
+ const y = e.clientY - rect.top;
285
+
286
+ const imagePoint = toImagePoint({ x, y });
287
+ if (tool === "brush") {
288
+ const newPoints = [...currentPoints, imagePoint];
289
+ setCurrentPoints(newPoints);
290
+ completeAnnotation(undefined, newPoints);
291
+ return;
292
+ }
293
+
294
+ setCurrentPoints([currentPoints[0], imagePoint]);
295
+ completeAnnotation();
296
+ };
297
+
298
+ const handleDblClick = () => {
299
+ if (tool === "polygon" && isDrawing && currentPoints.length >= 3) {
300
+ completeAnnotation();
301
+ }
302
+ };
303
+
304
+ const handleWheel = (e: WheelEvent) => {
305
+ if (!viewer) return;
306
+ e.preventDefault();
307
+
308
+ const rect = canvas.getBoundingClientRect();
309
+ const x = e.clientX - rect.left;
310
+ const y = e.clientY - rect.top;
311
+ const zoomPoint = viewer.viewport.pointFromPixel(
312
+ new OpenSeadragon.Point(x, y)
313
+ );
314
+
315
+ const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
316
+ viewer.viewport.zoomBy(zoomFactor, zoomPoint);
317
+ viewer.viewport.applyConstraints();
318
+ };
319
+
320
+ const handleKeyDown = (e: KeyboardEvent) => {
321
+ if (e.key === "Escape" && isDrawing) {
322
+ setIsDrawing(false);
323
+ setCurrentPoints([]);
324
+ }
325
+ };
326
+
327
+ canvas.addEventListener("mousemove", handleMouseMove);
328
+ canvas.addEventListener("mousedown", handleMouseDown);
329
+ canvas.addEventListener("mouseup", handleMouseUp);
330
+ canvas.addEventListener("dblclick", handleDblClick);
331
+ canvas.addEventListener("wheel", handleWheel, { passive: false });
332
+ document.addEventListener("keydown", handleKeyDown);
333
+
334
+ return () => {
335
+ canvas.removeEventListener("mousemove", handleMouseMove);
336
+ canvas.removeEventListener("mousedown", handleMouseDown);
337
+ canvas.removeEventListener("mouseup", handleMouseUp);
338
+ canvas.removeEventListener("dblclick", handleDblClick);
339
+ canvas.removeEventListener("wheel", handleWheel);
340
+ document.removeEventListener("keydown", handleKeyDown);
341
+ };
342
+ }, [isDrawing, currentPoints, tool, viewer, annotations, selectedAnnotationId, onAnnotationSelected, hoveredAnnotationId, isPanning, lastPanPoint]);
343
+
344
+ const completeAnnotation = (
345
+ forcedTool?: "rectangle" | "polygon" | "ellipse" | "brush",
346
+ forcedPoints?: Array<{ x: number; y: number }>
347
+ ) => {
348
+ const points = forcedPoints ?? currentPoints;
349
+ if (points.length < 2) {
350
+ setCurrentPoints([]);
351
+ setIsDrawing(false);
352
+ setDrawingTool(null);
353
+ return;
354
+ }
355
+
356
+ const finalTool = forcedTool ?? drawingTool ?? (tool as "rectangle" | "polygon" | "ellipse" | "brush");
357
+ const annotation: Annotation = {
358
+ id: `annotation-${Date.now()}`,
359
+ type: finalTool,
360
+ points,
361
+ color: labelColor(activeLabel),
362
+ label: activeLabel,
363
+ completed: true,
364
+ };
365
+
366
+ onAnnotationComplete(annotation);
367
+ setCurrentPoints([]);
368
+ setIsDrawing(false);
369
+ setDrawingTool(null);
370
+ redrawAnnotations();
371
+ };
372
+
373
+ const redrawAnnotations = useCallback(() => {
374
+ const canvas = canvasRef.current;
375
+ if (!canvas) return;
376
+
377
+ const ctx = canvas.getContext("2d");
378
+ if (!ctx) return;
379
+
380
+ // Clear canvas
381
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
382
+
383
+ if (!showAnnotations) return;
384
+
385
+ // Draw completed annotations
386
+ annotations.forEach((annotation) => {
387
+ const isSelected = selectedAnnotationId === annotation.id;
388
+ const isHovered = hoveredAnnotationId === annotation.id;
389
+ const screenPoints = annotation.points.map(toScreenPoint);
390
+
391
+ if (annotation.type === "rectangle" && screenPoints.length === 2) {
392
+ const [p1, p2] = screenPoints;
393
+ const x = Math.min(p1.x, p2.x);
394
+ const y = Math.min(p1.y, p2.y);
395
+ const width = Math.abs(p2.x - p1.x);
396
+ const height = Math.abs(p2.y - p1.y);
397
+
398
+ // Draw only outline, no fill
399
+ if (isSelected) {
400
+ ctx.strokeStyle = "#FCD34D";
401
+ ctx.lineWidth = 4;
402
+ } else if (isHovered) {
403
+ ctx.strokeStyle = "#60A5FA"; // Light blue for hover
404
+ ctx.lineWidth = 3;
405
+ } else {
406
+ ctx.strokeStyle = annotation.color;
407
+ ctx.lineWidth = 2;
408
+ }
409
+ ctx.strokeRect(x, y, width, height);
410
+
411
+ // Add hover indicator
412
+ if (isHovered) {
413
+ // Add hover indicator
414
+ ctx.fillStyle = "#60A5FA";
415
+ ctx.fillRect(x, y - 22, 65, 18);
416
+ ctx.fillStyle = "#FFF";
417
+ ctx.font = "11px Arial";
418
+ ctx.textAlign = "left";
419
+ ctx.textBaseline = "top";
420
+ ctx.fillText("Click to select", x + 4, y - 18);
421
+ }
422
+ } else if (annotation.type === "ellipse" && screenPoints.length === 2) {
423
+ const [p1, p2] = screenPoints;
424
+ const cx = (p1.x + p2.x) / 2;
425
+ const cy = (p1.y + p2.y) / 2;
426
+ const rx = Math.abs(p2.x - p1.x) / 2;
427
+ const ry = Math.abs(p2.y - p1.y) / 2;
428
+
429
+ if (isSelected) {
430
+ ctx.strokeStyle = "#FCD34D";
431
+ ctx.lineWidth = 4;
432
+ } else if (isHovered) {
433
+ ctx.strokeStyle = "#60A5FA";
434
+ ctx.lineWidth = 3;
435
+ } else {
436
+ ctx.strokeStyle = annotation.color;
437
+ ctx.lineWidth = 2;
438
+ }
439
+
440
+ ctx.beginPath();
441
+ ctx.ellipse(cx, cy, Math.max(1, rx), Math.max(1, ry), 0, 0, Math.PI * 2);
442
+ ctx.stroke();
443
+
444
+ if (isHovered) {
445
+ ctx.fillStyle = "#60A5FA";
446
+ ctx.fillRect(cx - 35, cy - ry - 26, 70, 18);
447
+ ctx.fillStyle = "#FFF";
448
+ ctx.font = "11px Arial";
449
+ ctx.textAlign = "center";
450
+ ctx.textBaseline = "top";
451
+ ctx.fillText("Click to select", cx, cy - ry - 22);
452
+ }
453
+ } else if (annotation.type === "polygon" && screenPoints.length > 1) {
454
+ // Determine colors based on state
455
+ let strokeColor, pointColor;
456
+ let lineWidth = 2;
457
+
458
+ if (isSelected) {
459
+ strokeColor = "#FCD34D";
460
+ pointColor = "#FCD34D";
461
+ lineWidth = 4;
462
+ } else if (isHovered) {
463
+ strokeColor = "#60A5FA";
464
+ pointColor = "#60A5FA";
465
+ lineWidth = 3;
466
+ } else {
467
+ strokeColor = annotation.color;
468
+ pointColor = annotation.color;
469
+ }
470
+
471
+ ctx.strokeStyle = strokeColor;
472
+ ctx.lineWidth = lineWidth;
473
+
474
+ // Draw main polygon
475
+ ctx.strokeStyle = strokeColor;
476
+ ctx.beginPath();
477
+ ctx.moveTo(screenPoints[0].x, screenPoints[0].y);
478
+ screenPoints.slice(1).forEach((p) => {
479
+ ctx.lineTo(p.x, p.y);
480
+ });
481
+ ctx.closePath();
482
+ ctx.stroke();
483
+
484
+ // Draw points as circles
485
+ ctx.fillStyle = pointColor;
486
+ screenPoints.forEach((p, index) => {
487
+ ctx.beginPath();
488
+ const radius = isSelected ? 7 : isHovered ? 6 : 5;
489
+ ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
490
+ ctx.fill();
491
+
492
+ // Draw point numbers
493
+ ctx.fillStyle = "#FFFFFF";
494
+ ctx.font = "bold 11px Arial";
495
+ ctx.textAlign = "center";
496
+ ctx.textBaseline = "middle";
497
+ ctx.fillText((index + 1).toString(), p.x, p.y);
498
+ ctx.fillStyle = pointColor;
499
+ });
500
+
501
+ // Add hover indicator
502
+ if (isHovered && screenPoints.length > 0) {
503
+ const firstPoint = screenPoints[0];
504
+ ctx.fillStyle = "#60A5FA";
505
+ ctx.fillRect(firstPoint.x + 10, firstPoint.y - 22, 65, 18);
506
+ ctx.fillStyle = "#FFF";
507
+ ctx.font = "11px Arial";
508
+ ctx.textAlign = "left";
509
+ ctx.textBaseline = "top";
510
+ ctx.fillText("Click to select", firstPoint.x + 14, firstPoint.y - 18);
511
+ }
512
+ } else if (annotation.type === "brush" && screenPoints.length > 1) {
513
+ if (isSelected) {
514
+ ctx.strokeStyle = "#FCD34D";
515
+ ctx.lineWidth = 4;
516
+ } else if (isHovered) {
517
+ ctx.strokeStyle = "#60A5FA";
518
+ ctx.lineWidth = 3;
519
+ } else {
520
+ ctx.strokeStyle = annotation.color;
521
+ ctx.lineWidth = 2;
522
+ }
523
+
524
+ ctx.beginPath();
525
+ ctx.moveTo(screenPoints[0].x, screenPoints[0].y);
526
+ screenPoints.slice(1).forEach((p) => {
527
+ ctx.lineTo(p.x, p.y);
528
+ });
529
+ ctx.stroke();
530
+
531
+ if (isHovered) {
532
+ const firstPoint = screenPoints[0];
533
+ ctx.fillStyle = "#60A5FA";
534
+ ctx.fillRect(firstPoint.x + 10, firstPoint.y - 22, 65, 18);
535
+ ctx.fillStyle = "#FFF";
536
+ ctx.font = "11px Arial";
537
+ ctx.textAlign = "left";
538
+ ctx.textBaseline = "top";
539
+ ctx.fillText("Click to select", firstPoint.x + 14, firstPoint.y - 18);
540
+ }
541
+ }
542
+ });
543
+
544
+ // Draw current drawing (preview)
545
+ if (isDrawing && currentPoints.length > 0) {
546
+ const color = tool === "rectangle" ? "#EF4444" : "#3B82F6";
547
+ const previewPoints = currentPoints.map(toScreenPoint);
548
+
549
+ if ((tool === "rectangle" || tool === "ellipse") && previewPoints.length === 2) {
550
+ const [p1, p2] = previewPoints;
551
+ const x = Math.min(p1.x, p2.x);
552
+ const y = Math.min(p1.y, p2.y);
553
+ const width = Math.abs(p2.x - p1.x);
554
+ const height = Math.abs(p2.y - p1.y);
555
+
556
+ // Draw only outline with dashed style, no fill
557
+ ctx.strokeStyle = color;
558
+ ctx.lineWidth = 2;
559
+ ctx.setLineDash([5, 5]);
560
+ if (tool === "rectangle") {
561
+ ctx.strokeRect(x, y, width, height);
562
+ } else {
563
+ const cx = x + width / 2;
564
+ const cy = y + height / 2;
565
+ ctx.beginPath();
566
+ ctx.ellipse(cx, cy, Math.max(1, width / 2), Math.max(1, height / 2), 0, 0, Math.PI * 2);
567
+ ctx.stroke();
568
+ }
569
+ ctx.setLineDash([]);
570
+ } else if (tool === "polygon" && previewPoints.length > 1) {
571
+ ctx.strokeStyle = color;
572
+ ctx.lineWidth = 2;
573
+ ctx.setLineDash([5, 5]);
574
+
575
+ ctx.beginPath();
576
+ ctx.moveTo(previewPoints[0].x, previewPoints[0].y);
577
+ previewPoints.slice(1).forEach((p) => {
578
+ ctx.lineTo(p.x, p.y);
579
+ });
580
+
581
+ // Close the path only if we have 3+ points
582
+ if (previewPoints.length >= 3) {
583
+ ctx.closePath();
584
+ }
585
+
586
+ ctx.stroke();
587
+ ctx.setLineDash([]);
588
+
589
+ // Draw points as solid circles with numbers
590
+ ctx.fillStyle = color;
591
+ previewPoints.forEach((p, index) => {
592
+ ctx.beginPath();
593
+ ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
594
+ ctx.fill();
595
+
596
+ // Draw point numbers
597
+ ctx.fillStyle = "#FFFFFF";
598
+ ctx.font = "bold 11px Arial";
599
+ ctx.textAlign = "center";
600
+ ctx.textBaseline = "middle";
601
+ ctx.fillText((index + 1).toString(), p.x, p.y);
602
+ ctx.fillStyle = color;
603
+ });
604
+ } else if (tool === "brush" && previewPoints.length > 1) {
605
+ ctx.strokeStyle = "#EF4444";
606
+ ctx.lineWidth = 2;
607
+ ctx.setLineDash([5, 5]);
608
+ ctx.beginPath();
609
+ ctx.moveTo(previewPoints[0].x, previewPoints[0].y);
610
+ previewPoints.slice(1).forEach((p) => {
611
+ ctx.lineTo(p.x, p.y);
612
+ });
613
+ ctx.stroke();
614
+ ctx.setLineDash([]);
615
+ }
616
+ }
617
+ }, [annotations, currentPoints, hoveredAnnotationId, isDrawing, selectedAnnotationId, showAnnotations, tool, viewer]);
618
+
619
+ useEffect(() => {
620
+ redrawAnnotations();
621
+ }, [redrawAnnotations]);
622
+
623
+ useEffect(() => {
624
+ if (!isDrawing || !drawingTool) return;
625
+ if (tool === drawingTool) return;
626
+
627
+ if (drawingTool === "polygon" && currentPoints.length >= 3) {
628
+ completeAnnotation(drawingTool);
629
+ return;
630
+ }
631
+
632
+ if ((drawingTool === "rectangle" || drawingTool === "ellipse") && currentPoints.length === 2) {
633
+ completeAnnotation(drawingTool);
634
+ return;
635
+ }
636
+
637
+ if (drawingTool === "brush" && currentPoints.length >= 2) {
638
+ completeAnnotation(drawingTool);
639
+ return;
640
+ }
641
+
642
+ setIsDrawing(false);
643
+ setCurrentPoints([]);
644
+ setDrawingTool(null);
645
+ }, [tool, drawingTool, currentPoints, isDrawing]);
646
+
647
+ useEffect(() => {
648
+ if (!viewer) return;
649
+ const handleViewportChange = () => redrawAnnotations();
650
+ viewer.addHandler("zoom", handleViewportChange);
651
+ viewer.addHandler("pan", handleViewportChange);
652
+ viewer.addHandler("animation", handleViewportChange);
653
+ viewer.addHandler("open", handleViewportChange);
654
+
655
+ return () => {
656
+ viewer.removeHandler("zoom", handleViewportChange);
657
+ viewer.removeHandler("pan", handleViewportChange);
658
+ viewer.removeHandler("animation", handleViewportChange);
659
+ viewer.removeHandler("open", handleViewportChange);
660
+ };
661
+ }, [redrawAnnotations, viewer]);
662
+
663
+ return (
664
+ <canvas
665
+ ref={canvasRef}
666
+ className={`absolute inset-0 z-40 ${
667
+ tool === "none" ? "pointer-events-none cursor-grab" : ""
668
+ } ${tool === "select" ? "cursor-default" : ""} ${
669
+ tool === "rectangle" || tool === "polygon" || tool === "ellipse" || tool === "brush"
670
+ ? "cursor-crosshair"
671
+ : ""
672
+ }`}
673
+ style={{ width: "100%", height: "100%" }}
674
+ />
675
+ );
676
+ }
frontend/src/components/viewer/PathoraViewer.tsx ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import OpenSeadragon from "openseadragon";
3
+ import { ToolsSidebar } from "./ToolsSidebar";
4
+ import { TopToolbar } from "./TopToolbar";
5
+ import { AnnotationCanvas, type Annotation } from "./AnnotationCanvas";
6
+ import { ArrowLeft } from "lucide-react";
7
+ import { useNavigate } from "react-router-dom";
8
+ import "./viewer.css";
9
+
10
+ interface PathoraViewerProps {
11
+ imageUrl?: string;
12
+ slideName?: string;
13
+ }
14
+
15
+ export type Tool = "none" | "select" | "rectangle" | "polygon" | "ellipse" | "brush";
16
+ type UploadedSlide = {
17
+ id: string;
18
+ name: string;
19
+ uploadedAt: string;
20
+ levelCount: number;
21
+ levelDimensions: number[][];
22
+ };
23
+
24
+ export function PathoraViewer({
25
+ imageUrl = "",
26
+ slideName = "Pathora Viewer"
27
+ }: PathoraViewerProps) {
28
+ console.log("PathoraViewer component rendering with:", { imageUrl, slideName });
29
+
30
+ const viewerRef = useRef<HTMLDivElement>(null);
31
+ const osdViewerRef = useRef<OpenSeadragon.Viewer | null>(null);
32
+ const navigate = useNavigate();
33
+ const [selectedTool, setSelectedTool] = useState<Tool>("none");
34
+ const [zoomLevel, setZoomLevel] = useState<number>(1);
35
+ const [showAnnotations, setShowAnnotations] = useState(true);
36
+ const [showHeatmap, setShowHeatmap] = useState(false);
37
+ const [annotations, setAnnotations] = useState<Annotation[]>([]);
38
+ const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
39
+ const [activeLabel, setActiveLabel] = useState("Tumor");
40
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
41
+ const [uploadedSlides, setUploadedSlides] = useState<UploadedSlide[]>([]);
42
+ const [showOriginal, setShowOriginal] = useState(true);
43
+ const [showHematoxylin, setShowHematoxylin] = useState(false);
44
+ const [showEosin, setShowEosin] = useState(false);
45
+ const [isLoading, setIsLoading] = useState(true);
46
+ const [loadError, setLoadError] = useState<string | null>(null);
47
+ const [tileServerUrl, setTileServerUrl] = useState("http://localhost:8001");
48
+ const [slideId, setSlideId] = useState("slide-1");
49
+ const [tileSize, setTileSize] = useState(256);
50
+ const [tileMode, setTileMode] = useState<"none" | "image" | "tiles">("none");
51
+ const [slideFile, setSlideFile] = useState<File | null>(null);
52
+ const [autoSlideId, setAutoSlideId] = useState(true);
53
+ const [tileMeta, setTileMeta] = useState<{
54
+ width: number;
55
+ height: number;
56
+ level_count: number;
57
+ level_dimensions: number[][];
58
+ level_downsamples: number[];
59
+ mpp_x?: number | null;
60
+ mpp_y?: number | null;
61
+ } | null>(null);
62
+ const [tileLoadError, setTileLoadError] = useState<string | null>(null);
63
+ const [isTileLoading, setIsTileLoading] = useState(false);
64
+ const channelItemsRef = useRef<{
65
+ original?: OpenSeadragon.TiledImage;
66
+ hematoxylin?: OpenSeadragon.TiledImage;
67
+ eosin?: OpenSeadragon.TiledImage;
68
+ }>({});
69
+
70
+ const normalizeBaseUrl = (value: string) => {
71
+ return value.replace(/\/$/, "");
72
+ };
73
+
74
+ const autoSlideIdLabel = autoSlideId ? "Auto ID enabled" : "Manual ID";
75
+
76
+ const generateSlideId = () => {
77
+ const now = new Date();
78
+ const stamp = now
79
+ .toISOString()
80
+ .replace(/[-:]/g, "")
81
+ .replace("T", "-")
82
+ .slice(0, 15);
83
+ const rand = Math.random().toString(36).slice(2, 6);
84
+ return `slide-${stamp}-${rand}`;
85
+ };
86
+
87
+ const buildTileSource = (meta: {
88
+ width: number;
89
+ height: number;
90
+ level_count: number;
91
+ level_downsamples: number[];
92
+ }, channel: "original" | "hematoxylin" | "eosin" = "original") => {
93
+ const baseUrl = normalizeBaseUrl(tileServerUrl);
94
+ const maxLevel = Math.max(0, meta.level_count - 1);
95
+
96
+ const tileSource = new OpenSeadragon.TileSource({
97
+ width: meta.width,
98
+ height: meta.height,
99
+ tileSize,
100
+ minLevel: 0,
101
+ maxLevel,
102
+ });
103
+
104
+ tileSource.getLevelScale = (level: number) => {
105
+ const slideLevel = Math.max(0, meta.level_count - 1 - level);
106
+ const downsample = meta.level_downsamples[slideLevel] || 1;
107
+ return 1 / downsample;
108
+ };
109
+
110
+ tileSource.getTileUrl = (level: number, x: number, y: number) => {
111
+ const slideLevel = Math.max(0, meta.level_count - 1 - level);
112
+ return `${baseUrl}/tiles/${slideId}/${slideLevel}/${x}/${y}.jpg?tile_size=${tileSize}&channel=${channel}`;
113
+ };
114
+
115
+ return tileSource;
116
+ };
117
+
118
+ const addUploadedSlide = (
119
+ id: string,
120
+ name: string,
121
+ levelCount: number,
122
+ levelDimensions: number[][]
123
+ ) => {
124
+ const uploadedAt = new Date().toLocaleString();
125
+ setUploadedSlides((prev) => {
126
+ const filtered = prev.filter((slide) => slide.id !== id);
127
+ return [{ id, name, uploadedAt, levelCount, levelDimensions }, ...filtered].slice(0, 20);
128
+ });
129
+ };
130
+
131
+ const handleLoadSlide = async () => {
132
+ if (!slideId.trim()) {
133
+ setTileLoadError("Slide id is required.");
134
+ return;
135
+ }
136
+
137
+ if (!slideFile) {
138
+ setTileLoadError("Please choose a WSI file to upload.");
139
+ return;
140
+ }
141
+
142
+ const baseUrl = normalizeBaseUrl(tileServerUrl);
143
+ setIsTileLoading(true);
144
+ setTileLoadError(null);
145
+
146
+ try {
147
+ const formData = new FormData();
148
+ formData.append("file", slideFile);
149
+
150
+ const uploadRes = await fetch(`${baseUrl}/slides/${slideId}/upload`, {
151
+ method: "POST",
152
+ body: formData,
153
+ });
154
+
155
+ if (!uploadRes.ok) {
156
+ const text = await uploadRes.text();
157
+ throw new Error(text || "Failed to upload slide");
158
+ }
159
+
160
+ const metaRes = await fetch(`${baseUrl}/slides/${slideId}/metadata`);
161
+ if (!metaRes.ok) {
162
+ const text = await metaRes.text();
163
+ throw new Error(text || "Failed to read metadata");
164
+ }
165
+
166
+ const meta = await metaRes.json();
167
+ setTileMeta(meta);
168
+ setTileMode("tiles");
169
+ addUploadedSlide(
170
+ slideId,
171
+ slideFile?.name || "Untitled slide",
172
+ meta.level_count || 1,
173
+ meta.level_dimensions || []
174
+ );
175
+ } catch (error: any) {
176
+ setTileLoadError(error?.message || "Failed to upload slide");
177
+ } finally {
178
+ setIsTileLoading(false);
179
+ }
180
+ };
181
+
182
+ const handleSelectUploadedSlide = async (id: string) => {
183
+ const baseUrl = normalizeBaseUrl(tileServerUrl);
184
+ setSlideId(id);
185
+ setTileLoadError(null);
186
+ setIsTileLoading(true);
187
+
188
+ const fetchMeta = async () => {
189
+ const metaRes = await fetch(`${baseUrl}/slides/${id}/metadata`);
190
+ if (!metaRes.ok) {
191
+ const text = await metaRes.text();
192
+ const error = new Error(text || "Failed to read metadata") as Error & {
193
+ status?: number;
194
+ };
195
+ error.status = metaRes.status;
196
+ throw error;
197
+ }
198
+ return metaRes.json();
199
+ };
200
+
201
+ try {
202
+ let meta: any;
203
+ try {
204
+ meta = await fetchMeta();
205
+ } catch (error: any) {
206
+ if (error?.status === 404) {
207
+ const reloadRes = await fetch(`${baseUrl}/slides/${id}/reload`, { method: "POST" });
208
+ if (!reloadRes.ok) {
209
+ const text = await reloadRes.text();
210
+ throw new Error(text || "Failed to reload slide");
211
+ }
212
+ meta = await fetchMeta();
213
+ } else {
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ setTileMeta(meta);
219
+ setTileMode("tiles");
220
+ } catch (error: any) {
221
+ setTileLoadError(error?.message || "Failed to load slide");
222
+ } finally {
223
+ setIsTileLoading(false);
224
+ }
225
+ };
226
+
227
+ // Initialize OpenSeadragon viewer
228
+ useEffect(() => {
229
+ if (!viewerRef.current || osdViewerRef.current) return;
230
+
231
+ console.log("Initializing OpenSeadragon with image:", imageUrl);
232
+ console.log("Viewer ref:", viewerRef.current);
233
+
234
+ try {
235
+ const viewer = OpenSeadragon({
236
+ element: viewerRef.current,
237
+ prefixUrl: "https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.0/images/",
238
+ crossOriginPolicy: "Anonymous",
239
+ showNavigator: true,
240
+ navigatorPosition: "BOTTOM_RIGHT",
241
+ navigatorSizeRatio: 0.15,
242
+ showNavigationControl: false,
243
+ minZoomImageRatio: 0.5,
244
+ maxZoomPixelRatio: 3,
245
+ visibilityRatio: 0.5,
246
+ constrainDuringPan: true,
247
+ animationTime: 0.5,
248
+ gestureSettingsMouse: {
249
+ clickToZoom: false,
250
+ dblClickToZoom: true,
251
+ },
252
+ });
253
+
254
+ console.log("OpenSeadragon viewer created:", viewer);
255
+
256
+ // Add error handler
257
+ viewer.addHandler("open-failed", (event: any) => {
258
+ console.error("Failed to open image:", event);
259
+ setLoadError("Failed to load image. Please check the image path.");
260
+ setIsLoading(false);
261
+ });
262
+
263
+ // Add success handler
264
+ viewer.addHandler("open", () => {
265
+ console.log("Image loaded successfully");
266
+ setIsLoading(false);
267
+ setLoadError(null);
268
+ });
269
+
270
+ // Update zoom level on zoom
271
+ viewer.addHandler("zoom", () => {
272
+ const zoom = viewer.viewport.getZoom();
273
+ setZoomLevel(zoom);
274
+ });
275
+
276
+ osdViewerRef.current = viewer;
277
+
278
+ return () => {
279
+ if (osdViewerRef.current) {
280
+ osdViewerRef.current.destroy();
281
+ osdViewerRef.current = null;
282
+ }
283
+ };
284
+ } catch (error) {
285
+ console.error("Error initializing OpenSeadragon:", error);
286
+ }
287
+ }, [imageUrl]);
288
+
289
+ useEffect(() => {
290
+ if (!osdViewerRef.current) return;
291
+
292
+ const viewer = osdViewerRef.current;
293
+
294
+ if (tileMode === "tiles" && tileMeta) {
295
+ setIsLoading(true);
296
+ setLoadError(null);
297
+ viewer.open(buildTileSource(tileMeta, "original"));
298
+ return;
299
+ }
300
+
301
+ if (tileMode === "image" && imageUrl) {
302
+ setIsLoading(true);
303
+ setLoadError(null);
304
+ viewer.open({
305
+ type: "image",
306
+ url: imageUrl,
307
+ });
308
+ return;
309
+ }
310
+
311
+ if (tileMode === "none") {
312
+ setIsLoading(false);
313
+ setLoadError(null);
314
+ viewer.close();
315
+ }
316
+ }, [imageUrl, tileMeta, tileMode, tileServerUrl, tileSize, slideId]);
317
+
318
+ useEffect(() => {
319
+ if (!osdViewerRef.current || !tileMeta || tileMode !== "tiles") return;
320
+ const viewer = osdViewerRef.current;
321
+
322
+ const originalItem = viewer.world.getItemAt(0);
323
+ if (originalItem) {
324
+ channelItemsRef.current.original = originalItem;
325
+ originalItem.setOpacity(showOriginal ? 1 : 0);
326
+ }
327
+
328
+ const ensureChannel = (
329
+ key: "hematoxylin" | "eosin",
330
+ enabled: boolean,
331
+ channel: "hematoxylin" | "eosin"
332
+ ) => {
333
+ const existing = channelItemsRef.current[key];
334
+ if (existing) {
335
+ existing.setOpacity(enabled ? 1 : 0);
336
+ return;
337
+ }
338
+ if (!enabled) return;
339
+
340
+ viewer.addTiledImage({
341
+ tileSource: buildTileSource(tileMeta, channel),
342
+ opacity: 1,
343
+ success: (event: any) => {
344
+ channelItemsRef.current[key] = event.item as OpenSeadragon.TiledImage;
345
+ },
346
+ });
347
+ };
348
+
349
+ ensureChannel("hematoxylin", showHematoxylin, "hematoxylin");
350
+ ensureChannel("eosin", showEosin, "eosin");
351
+ }, [tileMeta, tileMode, showOriginal, showHematoxylin, showEosin, tileServerUrl, tileSize, slideId]);
352
+
353
+ const handleAnnotationComplete = (annotation: Annotation) => {
354
+ console.log("Annotation completed:", annotation);
355
+ setAnnotations((prev) => [...prev, annotation]);
356
+ // Auto-select the newly created annotation
357
+ setSelectedAnnotationId(annotation.id);
358
+ };
359
+
360
+ const handleAnnotationSelected = (annotationId: string | null) => {
361
+ setSelectedAnnotationId(annotationId);
362
+ };
363
+
364
+ const handleUndo = () => {
365
+ if (annotations.length > 0) {
366
+ const newAnnotations = annotations.slice(0, -1);
367
+ setAnnotations(newAnnotations);
368
+ setSelectedAnnotationId(null);
369
+ }
370
+ };
371
+
372
+ const handleDelete = () => {
373
+ if (selectedAnnotationId) {
374
+ const newAnnotations = annotations.filter(
375
+ (ann) => ann.id !== selectedAnnotationId
376
+ );
377
+ setAnnotations(newAnnotations);
378
+ setSelectedAnnotationId(null);
379
+ }
380
+ };
381
+
382
+ const handleZoomIn = () => {
383
+ if (osdViewerRef.current) {
384
+ const currentZoom = osdViewerRef.current.viewport.getZoom();
385
+ osdViewerRef.current.viewport.zoomTo(currentZoom * 1.2);
386
+ }
387
+ };
388
+
389
+ const handleZoomOut = () => {
390
+ if (osdViewerRef.current) {
391
+ const currentZoom = osdViewerRef.current.viewport.getZoom();
392
+ osdViewerRef.current.viewport.zoomTo(currentZoom / 1.2);
393
+ }
394
+ };
395
+
396
+ const zoomPresets = [1, 5, 10, 20, 40];
397
+ const micronsPerPixel = tileMeta?.mpp_x ?? tileMeta?.mpp_y ?? null;
398
+ const imageMeta = {
399
+ stain: "H&E",
400
+ width: tileMeta?.width ?? null,
401
+ height: tileMeta?.height ?? null,
402
+ levelCount: tileMeta?.level_count ?? null,
403
+ mpp: micronsPerPixel,
404
+ slideId,
405
+ };
406
+ const isTileLoaded = tileMode === "tiles" && !!tileMeta;
407
+
408
+ const handleZoomPreset = (level: number) => {
409
+ if (osdViewerRef.current) {
410
+ osdViewerRef.current.viewport.zoomTo(level);
411
+ }
412
+ };
413
+
414
+ return (
415
+ <div className="flex flex-col h-screen bg-gray-100">
416
+ {/* Header with back button */}
417
+ <header className="h-16 bg-gradient-to-r from-teal-700 to-teal-600 text-white flex items-center px-6 shadow-md">
418
+ <button
419
+ onClick={() => navigate("/")}
420
+ className="flex items-center space-x-2 hover:bg-teal-800/50 px-3 py-2 rounded-lg transition-colors"
421
+ >
422
+ <ArrowLeft className="w-5 h-5" />
423
+ <span className="font-medium">Back to Analysis</span>
424
+ </button>
425
+ <div className="flex-1 text-center">
426
+ <h1 className="text-2xl font-bold">Pathora Viewer</h1>
427
+ <p className="text-xs text-teal-100">Advanced Whole Slide Imaging Platform</p>
428
+ </div>
429
+ <div className="w-40"></div> {/* Spacer for centering */}
430
+ </header>
431
+
432
+ <div className="flex flex-1 overflow-hidden">
433
+ {/* Tools Sidebar */}
434
+ <ToolsSidebar
435
+ selectedTool={selectedTool}
436
+ onToolChange={setSelectedTool}
437
+ annotations={annotations}
438
+ selectedAnnotationId={selectedAnnotationId}
439
+ onSelectAnnotation={handleAnnotationSelected}
440
+ uploadedSlides={uploadedSlides}
441
+ tileServerUrl={tileServerUrl}
442
+ onTileServerUrlChange={setTileServerUrl}
443
+ onSlideFileChange={(file) => {
444
+ setSlideFile(file);
445
+ if (file && autoSlideId) {
446
+ setSlideId(generateSlideId());
447
+ }
448
+ }}
449
+ slideFileName={slideFile?.name ?? null}
450
+ onUploadSlide={handleLoadSlide}
451
+ isTileLoading={isTileLoading}
452
+ tileLoadError={tileLoadError}
453
+ onSelectUploadedSlide={handleSelectUploadedSlide}
454
+ activeLabel={activeLabel}
455
+ onLabelChange={setActiveLabel}
456
+ imageMeta={imageMeta}
457
+ channelVisibility={{
458
+ original: showOriginal,
459
+ hematoxylin: showHematoxylin,
460
+ eosin: showEosin,
461
+ }}
462
+ onChannelToggle={(channel, value) => {
463
+ if (channel === "original") setShowOriginal(value);
464
+ if (channel === "hematoxylin") setShowHematoxylin(value);
465
+ if (channel === "eosin") setShowEosin(value);
466
+ }}
467
+ isTileLoaded={isTileLoaded}
468
+ isCollapsed={isSidebarCollapsed}
469
+ onToggleCollapsed={() => setIsSidebarCollapsed((prev) => !prev)}
470
+ />
471
+
472
+ {/* Main Viewer Area */}
473
+ <div className="flex-1 flex flex-col">{/* Top Toolbar */}
474
+ <TopToolbar
475
+ slideName={slideName}
476
+ zoomLevel={zoomLevel}
477
+ zoomPresets={zoomPresets}
478
+ onZoomPreset={handleZoomPreset}
479
+ micronsPerPixel={micronsPerPixel}
480
+ showAnnotations={showAnnotations}
481
+ showHeatmap={showHeatmap}
482
+ onUndo={handleUndo}
483
+ onDelete={handleDelete}
484
+ onZoomIn={handleZoomIn}
485
+ onZoomOut={handleZoomOut}
486
+ onToggleAnnotations={() => setShowAnnotations(!showAnnotations)}
487
+ onToggleHeatmap={() => setShowHeatmap(!showHeatmap)}
488
+ canUndo={annotations.length > 0}
489
+ canDelete={selectedAnnotationId !== null}
490
+ />
491
+
492
+ <div className="flex-1 flex overflow-hidden">
493
+ {/* Viewer Container */}
494
+ <div className="flex-1 relative">
495
+ <div
496
+ ref={viewerRef}
497
+ className="absolute inset-0 bg-black"
498
+ style={{ width: "100%", height: "100%" }}
499
+ />
500
+
501
+ {/* Loading State */}
502
+ {isLoading && (
503
+ <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-50">
504
+ <div className="text-center">
505
+ <div className="inline-block animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-teal-500 mb-4"></div>
506
+ <p className="text-white text-lg font-semibold">Loading slide viewer...</p>
507
+ <p className="text-gray-300 text-sm mt-2">Initializing OpenSeadragon</p>
508
+ </div>
509
+ </div>
510
+ )}
511
+
512
+ {/* Error State */}
513
+ {loadError && (
514
+ <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-50">
515
+ <div className="bg-white rounded-lg p-6 max-w-md text-center">
516
+ <div className="text-red-500 text-5xl mb-4">⚠️</div>
517
+ <h3 className="text-xl font-bold text-gray-800 mb-2">Failed to Load Image</h3>
518
+ <p className="text-gray-600 mb-4">{loadError}</p>
519
+ <button
520
+ onClick={() => window.location.reload()}
521
+ className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
522
+ >
523
+ Retry
524
+ </button>
525
+ </div>
526
+ </div>
527
+ )}
528
+
529
+ {/* Annotation Canvas */}
530
+ <AnnotationCanvas
531
+ viewer={osdViewerRef.current}
532
+ tool={selectedTool}
533
+ onAnnotationComplete={handleAnnotationComplete}
534
+ activeLabel={activeLabel}
535
+ onAnnotationSelected={handleAnnotationSelected}
536
+ annotations={annotations}
537
+ selectedAnnotationId={selectedAnnotationId}
538
+ showAnnotations={showAnnotations}
539
+ />
540
+
541
+
542
+ {/* Annotations count */}
543
+ {showAnnotations && annotations.length > 0 && (
544
+ <div className="absolute bottom-4 left-4 bg-white px-3 py-2 rounded shadow-md text-sm z-40">
545
+ <span className="font-semibold">Annotations:</span> {annotations.length}
546
+ </div>
547
+ )}
548
+
549
+ {/* Heatmap overlay placeholder */}
550
+ {showHeatmap && (
551
+ <div className="absolute inset-0 pointer-events-none bg-gradient-to-br from-red-500/20 via-yellow-500/20 to-green-500/20" />
552
+ )}
553
+
554
+ {tileMode === "none" && !isLoading && !loadError && (
555
+ <div className="absolute inset-0 flex items-center justify-center bg-gray-900/60 z-40">
556
+ <div className="rounded-lg px-5 py-4 text-center">
557
+ <div className="text-sm font-semibold text-white">Upload a WSI to start</div>
558
+ <div className="text-xs text-white/80 mt-1">
559
+ Use the uploader in the left Uploads tab to load a slide.
560
+ </div>
561
+ </div>
562
+ </div>
563
+ )}
564
+ </div>
565
+ </div>
566
+ </div>
567
+ </div>
568
+ </div>
569
+ );
570
+ }
frontend/src/components/viewer/ToolsSidebar.tsx ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { MousePointer, Square, Pentagon, Circle, Brush, ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { Tool } from "./PathoraViewer";
4
+ import { type Annotation } from "./AnnotationCanvas";
5
+
6
+ interface ToolsSidebarProps {
7
+ selectedTool: Tool;
8
+ onToolChange: (tool: Tool) => void;
9
+ annotations: Annotation[];
10
+ selectedAnnotationId: string | null;
11
+ onSelectAnnotation: (annotationId: string | null) => void;
12
+ uploadedSlides: Array<{
13
+ id: string;
14
+ name: string;
15
+ uploadedAt: string;
16
+ levelCount: number;
17
+ levelDimensions: number[][];
18
+ }>;
19
+ tileServerUrl: string;
20
+ onTileServerUrlChange: (value: string) => void;
21
+ onSlideFileChange: (file: File | null) => void;
22
+ slideFileName: string | null;
23
+ onUploadSlide: () => void;
24
+ isTileLoading: boolean;
25
+ tileLoadError: string | null;
26
+ onSelectUploadedSlide: (slideId: string) => void;
27
+ activeLabel: string;
28
+ onLabelChange: (label: string) => void;
29
+ imageMeta: {
30
+ stain: string;
31
+ width: number | null;
32
+ height: number | null;
33
+ levelCount: number | null;
34
+ mpp: number | null;
35
+ slideId: string;
36
+ };
37
+ channelVisibility: {
38
+ original: boolean;
39
+ hematoxylin: boolean;
40
+ eosin: boolean;
41
+ };
42
+ onChannelToggle: (channel: "original" | "hematoxylin" | "eosin", value: boolean) => void;
43
+ isTileLoaded: boolean;
44
+ isCollapsed: boolean;
45
+ onToggleCollapsed: () => void;
46
+ }
47
+
48
+ export function ToolsSidebar({
49
+ selectedTool,
50
+ onToolChange,
51
+ annotations,
52
+ selectedAnnotationId,
53
+ onSelectAnnotation,
54
+ uploadedSlides,
55
+ tileServerUrl,
56
+ onTileServerUrlChange,
57
+ onSlideFileChange,
58
+ slideFileName,
59
+ onUploadSlide,
60
+ isTileLoading,
61
+ tileLoadError,
62
+ onSelectUploadedSlide,
63
+ activeLabel,
64
+ onLabelChange,
65
+ imageMeta,
66
+ channelVisibility,
67
+ onChannelToggle,
68
+ isTileLoaded,
69
+ isCollapsed,
70
+ onToggleCollapsed,
71
+ }: ToolsSidebarProps) {
72
+ const [activeTab, setActiveTab] = useState<"tools" | "image" | "images" | "annotations">("tools");
73
+
74
+ const tools = [
75
+ { id: "select" as Tool, label: "Select", icon: MousePointer },
76
+ { id: "rectangle" as Tool, label: "Rectangle", icon: Square },
77
+ { id: "polygon" as Tool, label: "Polygon", icon: Pentagon },
78
+ { id: "ellipse" as Tool, label: "Ellipse", icon: Circle },
79
+ { id: "brush" as Tool, label: "Brush", icon: Brush },
80
+ ];
81
+
82
+ const annotationLabel = (annotation: Annotation, index: number) => {
83
+ if (annotation.label && annotation.label.trim().length > 0) {
84
+ return annotation.label;
85
+ }
86
+
87
+ return annotation.type === "rectangle"
88
+ ? "Rectangle"
89
+ : annotation.type === "polygon"
90
+ ? "Polygon"
91
+ : annotation.type === "ellipse"
92
+ ? "Ellipse"
93
+ : "Brush";
94
+ };
95
+
96
+ const normalizePoints = (points: Array<{ x: number; y: number }>, size = 24) => {
97
+ if (points.length === 0) return [];
98
+ const xs = points.map((p) => p.x);
99
+ const ys = points.map((p) => p.y);
100
+ const minX = Math.min(...xs);
101
+ const maxX = Math.max(...xs);
102
+ const minY = Math.min(...ys);
103
+ const maxY = Math.max(...ys);
104
+ const width = Math.max(maxX - minX, 1);
105
+ const height = Math.max(maxY - minY, 1);
106
+ const padding = 2;
107
+ const scale = Math.min((size - padding * 2) / width, (size - padding * 2) / height);
108
+
109
+ return points.map((p) => ({
110
+ x: (p.x - minX) * scale + padding,
111
+ y: (p.y - minY) * scale + padding,
112
+ }));
113
+ };
114
+
115
+ const normalizeBaseUrl = (value: string) => value.replace(/\/$/, "");
116
+ const baseTileUrl = normalizeBaseUrl(tileServerUrl);
117
+ const thumbnailSize = 192;
118
+
119
+ const renderAnnotationPreview = (annotation: Annotation) => {
120
+ const size = 24;
121
+ const stroke = annotation.color || "#9CA3AF";
122
+ const normalized = normalizePoints(annotation.points, size);
123
+
124
+ if (annotation.type === "rectangle" && normalized.length >= 2) {
125
+ const [p1, p2] = normalized;
126
+ const x = Math.min(p1.x, p2.x);
127
+ const y = Math.min(p1.y, p2.y);
128
+ const width = Math.abs(p2.x - p1.x);
129
+ const height = Math.abs(p2.y - p1.y);
130
+ return (
131
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
132
+ <rect x={x} y={y} width={width} height={height} fill="none" stroke={stroke} strokeWidth="2" />
133
+ </svg>
134
+ );
135
+ }
136
+
137
+ if (annotation.type === "ellipse" && normalized.length >= 2) {
138
+ const [p1, p2] = normalized;
139
+ const cx = (p1.x + p2.x) / 2;
140
+ const cy = (p1.y + p2.y) / 2;
141
+ const rx = Math.abs(p2.x - p1.x) / 2;
142
+ const ry = Math.abs(p2.y - p1.y) / 2;
143
+ return (
144
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
145
+ <ellipse cx={cx} cy={cy} rx={Math.max(1, rx)} ry={Math.max(1, ry)} fill="none" stroke={stroke} strokeWidth="2" />
146
+ </svg>
147
+ );
148
+ }
149
+
150
+ if ((annotation.type === "polygon" || annotation.type === "brush") && normalized.length >= 2) {
151
+ const path = normalized.map((p, idx) => `${idx === 0 ? "M" : "L"}${p.x} ${p.y}`).join(" ");
152
+ const closed = annotation.type === "polygon" ? " Z" : "";
153
+ return (
154
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
155
+ <path d={`${path}${closed}`} fill="none" stroke={stroke} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
156
+ </svg>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
162
+ <rect x="4" y="4" width="16" height="16" fill="none" stroke={stroke} strokeWidth="2" />
163
+ </svg>
164
+ );
165
+ };
166
+
167
+ return (
168
+ <aside
169
+ className={`bg-gray-800 border-r border-gray-700 flex flex-col py-4 transition-all duration-300 ${
170
+ isCollapsed ? "w-20" : "w-72"
171
+ }`}
172
+ >
173
+ <div className={`flex items-center justify-between px-3 ${isCollapsed ? "mb-2" : "mb-3"}`}>
174
+ <div className="text-white text-xs font-semibold">
175
+ {isCollapsed ? "" : "PANEL"}
176
+ </div>
177
+ <button
178
+ onClick={onToggleCollapsed}
179
+ className="p-1 rounded hover:bg-gray-700 text-gray-300"
180
+ title={isCollapsed ? "Expand panel" : "Collapse panel"}
181
+ >
182
+ {isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
183
+ </button>
184
+ </div>
185
+
186
+ <div className={`h-px ${isCollapsed ? "mx-3" : "mx-4"} bg-gray-600 mb-3`} />
187
+
188
+ {!isCollapsed && (
189
+ <div className="px-3 mb-3">
190
+ <div className="flex items-center bg-gray-700 rounded-lg p-1 text-[11px]">
191
+ {([
192
+ { id: "tools", label: "Tools" },
193
+ { id: "images", label: "Uploads" },
194
+ { id: "image", label: "Image" },
195
+ { id: "annotations", label: "Annotations" },
196
+ ] as const).map((tab) => (
197
+ <button
198
+ key={tab.id}
199
+ onClick={() => setActiveTab(tab.id)}
200
+ className={`flex-1 px-2 py-1 rounded-md transition-colors ${
201
+ activeTab === tab.id
202
+ ? "bg-teal-600 text-white"
203
+ : "text-gray-200 hover:bg-gray-600"
204
+ }`}
205
+ >
206
+ {tab.label}
207
+ </button>
208
+ ))}
209
+ </div>
210
+ </div>
211
+ )}
212
+
213
+ {(isCollapsed || activeTab === "tools") && (
214
+ <div className="px-3">
215
+ {!isCollapsed && (
216
+ <div className="mb-3">
217
+ <label className="block text-[11px] text-gray-300 mb-1">Annotation label</label>
218
+ <select
219
+ value={activeLabel}
220
+ onChange={(e) => onLabelChange(e.target.value)}
221
+ className="w-full text-xs bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1"
222
+ >
223
+ <option value="Tumor">Tumor</option>
224
+ <option value="Benign">Benign</option>
225
+ <option value="Stroma">Stroma</option>
226
+ <option value="Necrosis">Necrosis</option>
227
+ <option value="DCIS">DCIS</option>
228
+ <option value="Invasive">Invasive</option>
229
+ </select>
230
+ </div>
231
+ )}
232
+
233
+ <div className={`grid ${isCollapsed ? "grid-cols-1" : "grid-cols-2"} gap-2`}>
234
+ {tools.map((tool) => {
235
+ const Icon = tool.icon;
236
+ const isSelected = selectedTool === tool.id;
237
+
238
+ return (
239
+ <button
240
+ key={tool.id}
241
+ onClick={() => onToolChange(isSelected ? "none" : tool.id)}
242
+ className={`
243
+ h-14 flex flex-col items-center justify-center rounded-lg
244
+ transition-all duration-200
245
+ ${
246
+ isSelected
247
+ ? "bg-teal-600 text-white shadow-lg"
248
+ : "bg-gray-700 text-gray-300 hover:bg-gray-600 hover:text-white"
249
+ }
250
+ `}
251
+ title={tool.label}
252
+ >
253
+ <Icon className="w-5 h-5" />
254
+ {!isCollapsed && (
255
+ <span className="text-[10px] mt-1 font-medium">{tool.label}</span>
256
+ )}
257
+ </button>
258
+ );
259
+ })}
260
+ </div>
261
+ </div>
262
+ )}
263
+
264
+ {!isCollapsed && activeTab === "image" && (
265
+ <div className="mt-2 px-3">
266
+ <div className="text-xs font-semibold text-gray-200 mb-2">Slide metadata</div>
267
+ <div className="space-y-2 text-[11px] text-gray-300">
268
+ <div className="flex items-center justify-between">
269
+ <span className="text-gray-400">Stain</span>
270
+ <span>{imageMeta.stain}</span>
271
+ </div>
272
+ <div className="flex items-center justify-between">
273
+ <span className="text-gray-400">Size</span>
274
+ <span>
275
+ {imageMeta.width && imageMeta.height
276
+ ? `${imageMeta.width} x ${imageMeta.height}`
277
+ : "n/a"}
278
+ </span>
279
+ </div>
280
+ <div className="flex items-center justify-between">
281
+ <span className="text-gray-400">Levels</span>
282
+ <span>{imageMeta.levelCount ?? "n/a"}</span>
283
+ </div>
284
+ <div className="flex items-center justify-between">
285
+ <span className="text-gray-400">MPP</span>
286
+ <span>{imageMeta.mpp ? `${imageMeta.mpp.toFixed(3)} µm/px` : "n/a"}</span>
287
+ </div>
288
+ <div className="flex items-center justify-between">
289
+ <span className="text-gray-400">Case / Slide</span>
290
+ <span className="text-right max-w-[130px] truncate">{imageMeta.slideId}</span>
291
+ </div>
292
+ </div>
293
+
294
+ <div className="mt-4">
295
+ <div className="text-xs font-semibold text-gray-200 mb-2">Channels</div>
296
+ <div className="space-y-2 text-[11px] text-gray-300">
297
+ {([
298
+ { id: "original", label: "Original", color: "bg-gray-400" },
299
+ { id: "hematoxylin", label: "Hematoxylin", color: "bg-indigo-500" },
300
+ { id: "eosin", label: "Eosin", color: "bg-pink-500" },
301
+ ] as const).map((channel) => (
302
+ <label
303
+ key={channel.id}
304
+ className={`flex items-center justify-between rounded px-2 py-1 border border-gray-700 ${
305
+ isTileLoaded ? "hover:bg-gray-700/50" : "opacity-50"
306
+ }`}
307
+ >
308
+ <span className="flex items-center gap-2">
309
+ <span className={`w-3 h-3 rounded-sm ${channel.color}`} />
310
+ {channel.label}
311
+ </span>
312
+ <input
313
+ type="radio"
314
+ name="channel"
315
+ disabled={!isTileLoaded}
316
+ checked={channelVisibility[channel.id]}
317
+ onChange={() => {
318
+ onChannelToggle("original", channel.id === "original");
319
+ onChannelToggle("hematoxylin", channel.id === "hematoxylin");
320
+ onChannelToggle("eosin", channel.id === "eosin");
321
+ }}
322
+ className="accent-teal-500"
323
+ />
324
+ </label>
325
+ ))}
326
+ </div>
327
+ </div>
328
+ </div>
329
+ )}
330
+
331
+ {!isCollapsed && activeTab === "images" && (
332
+ <div className="mt-2 px-3">
333
+ <div className="text-xs font-semibold text-gray-200 mb-2">Uploaded images</div>
334
+ <div className="space-y-2 mb-3">
335
+ <label className="block text-[11px] text-gray-300">Tile server URL</label>
336
+ <input
337
+ value={tileServerUrl}
338
+ onChange={(e) => onTileServerUrlChange(e.target.value)}
339
+ className="w-full text-xs bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1"
340
+ placeholder="http://localhost:8001"
341
+ />
342
+
343
+ <label className="block text-[11px] text-gray-300">WSI file (SVS/TIFF)</label>
344
+ <input
345
+ type="file"
346
+ accept=".svs,.tif,.tiff"
347
+ onChange={(e) => {
348
+ const file = e.target.files?.[0] ?? null;
349
+ onSlideFileChange(file);
350
+ }}
351
+ className="w-full text-xs bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1"
352
+ />
353
+ {slideFileName && (
354
+ <p className="text-[11px] text-gray-400">Selected: {slideFileName}</p>
355
+ )}
356
+
357
+ <button
358
+ onClick={onUploadSlide}
359
+ disabled={isTileLoading}
360
+ className="w-full bg-teal-600 text-white text-xs font-semibold py-1.5 rounded hover:bg-teal-700 disabled:opacity-60"
361
+ >
362
+ {isTileLoading ? "Uploading slide..." : "Load Image"}
363
+ </button>
364
+
365
+ {tileLoadError && (
366
+ <p className="text-[11px] text-red-400">{tileLoadError}</p>
367
+ )}
368
+ </div>
369
+ <div className="text-[11px] text-gray-400 mb-2">
370
+ {uploadedSlides.length} total
371
+ </div>
372
+ <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
373
+ {uploadedSlides.length === 0 && (
374
+ <div className="text-[11px] text-gray-500 bg-gray-700/40 rounded p-2">
375
+ No uploads yet
376
+ </div>
377
+ )}
378
+ {uploadedSlides.map((slide) => (
379
+ <button
380
+ key={slide.id}
381
+ type="button"
382
+ onClick={() => onSelectUploadedSlide(slide.id)}
383
+ className={`w-full rounded border px-2 py-2 text-[11px] transition-colors h-[88px] ${
384
+ slide.id === imageMeta.slideId
385
+ ? "bg-teal-600/20 border-teal-500 text-teal-100"
386
+ : "bg-gray-700/40 border-gray-700 text-gray-200"
387
+ }`}
388
+ >
389
+ <div className="flex items-center gap-3 h-full">
390
+ <div className="w-16 h-16 rounded bg-white border border-gray-700 overflow-hidden flex-shrink-0">
391
+ <img
392
+ src={`${baseTileUrl}/slides/${slide.id}/thumbnail?size=${thumbnailSize}&channel=original`}
393
+ alt={`WSI preview ${slide.id}`}
394
+ className="w-full h-full object-contain"
395
+ loading="lazy"
396
+ />
397
+ </div>
398
+ <div className="min-w-0 text-left">
399
+ <div className="text-xs font-semibold truncate text-left">{slide.name}</div>
400
+ <div className="text-[10px] text-gray-400 truncate text-left">{slide.uploadedAt}</div>
401
+ <div className="text-[10px] text-gray-400 truncate text-left">{slide.id}</div>
402
+ </div>
403
+ </div>
404
+ </button>
405
+ ))}
406
+ </div>
407
+ </div>
408
+ )}
409
+
410
+ {!isCollapsed && activeTab === "annotations" && (
411
+ <div className="mt-4 px-3">
412
+ <div className="text-xs font-semibold text-gray-200 mb-2">Annotations</div>
413
+ <div className="text-[11px] text-gray-400 mb-2">
414
+ {annotations.length} total
415
+ </div>
416
+ <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-1">
417
+ {annotations.length === 0 && (
418
+ <div className="text-[11px] text-gray-500 bg-gray-700/40 rounded p-2">
419
+ No annotations yet
420
+ </div>
421
+ )}
422
+ {annotations.map((annotation, index) => {
423
+ const isActive = annotation.id === selectedAnnotationId;
424
+ const label = annotationLabel(annotation, index);
425
+ return (
426
+ <button
427
+ key={annotation.id}
428
+ onClick={() => onSelectAnnotation(annotation.id)}
429
+ className={`w-full text-left px-2 py-2 rounded border transition-colors ${
430
+ isActive
431
+ ? "bg-teal-600/20 border-teal-500 text-teal-100"
432
+ : "bg-gray-700/40 border-gray-700 text-gray-200 hover:bg-gray-700"
433
+ }`}
434
+ title={annotation.id}
435
+ >
436
+ <div className="flex items-center gap-2">
437
+ <div className="w-7 h-7 flex items-center justify-center rounded bg-gray-800/60 border border-gray-700">
438
+ {renderAnnotationPreview(annotation)}
439
+ </div>
440
+ <span className="text-xs font-semibold truncate text-left flex-1">{label}</span>
441
+ <span className="text-[10px] text-gray-400">
442
+ {annotation.type === "rectangle"
443
+ ? "Rect"
444
+ : annotation.type === "polygon"
445
+ ? "Poly"
446
+ : annotation.type === "ellipse"
447
+ ? "Ell"
448
+ : "Brush"}
449
+ </span>
450
+ </div>
451
+ <div className="text-[10px] text-gray-400 truncate">
452
+ {annotation.id}
453
+ </div>
454
+ </button>
455
+ );
456
+ })}
457
+ </div>
458
+ </div>
459
+ )}
460
+
461
+ <div className="flex-1" />
462
+ </aside>
463
+ );
464
+ }
frontend/src/components/viewer/TopToolbar.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Undo2, Trash2, ZoomIn, ZoomOut, Eye, EyeOff, Layers } from "lucide-react";
2
+
3
+ interface TopToolbarProps {
4
+ slideName: string;
5
+ zoomLevel: number;
6
+ zoomPresets: number[];
7
+ onZoomPreset: (level: number) => void;
8
+ micronsPerPixel?: number | null;
9
+ showAnnotations: boolean;
10
+ showHeatmap: boolean;
11
+ onUndo: () => void;
12
+ onDelete: () => void;
13
+ onZoomIn: () => void;
14
+ onZoomOut: () => void;
15
+ onToggleAnnotations: () => void;
16
+ onToggleHeatmap: () => void;
17
+ canUndo: boolean;
18
+ canDelete: boolean;
19
+ }
20
+
21
+ export function TopToolbar({
22
+ slideName,
23
+ zoomLevel,
24
+ zoomPresets,
25
+ onZoomPreset,
26
+ micronsPerPixel,
27
+ showAnnotations,
28
+ showHeatmap,
29
+ onUndo,
30
+ onDelete,
31
+ onZoomIn,
32
+ onZoomOut,
33
+ onToggleAnnotations,
34
+ onToggleHeatmap,
35
+ canUndo,
36
+ canDelete,
37
+ }: TopToolbarProps) {
38
+ const scaleBarPixels = 90;
39
+ const safeZoom = Math.max(zoomLevel, 0.0001);
40
+ const microns = micronsPerPixel ? (micronsPerPixel * scaleBarPixels) / safeZoom : null;
41
+ const micronsLabel = microns
42
+ ? `${microns >= 100 ? Math.round(microns) : microns.toFixed(1)} µm`
43
+ : "";
44
+
45
+ const isActivePreset = (preset: number) => {
46
+ return Math.abs(zoomLevel - preset) < 0.5;
47
+ };
48
+
49
+ return (
50
+ <div className="min-h-14 bg-white border-b border-gray-200 flex flex-wrap items-center justify-between gap-3 px-4 py-2 shadow-sm">
51
+ {/* Left Section: Slide Info */}
52
+ <div className="flex items-center gap-3">
53
+ <div>
54
+ <h2 className="text-base font-semibold text-gray-800">{slideName}</h2>
55
+ <p className="text-[11px] text-gray-500">
56
+ Zoom: {zoomLevel.toFixed(2)}x
57
+ </p>
58
+ </div>
59
+ </div>
60
+
61
+ {/* Center-Left Section: Annotation Controls */}
62
+ <div className="flex items-center gap-2">
63
+ <button
64
+ onClick={onUndo}
65
+ disabled={!canUndo}
66
+ className={`px-2 py-1 rounded-md flex items-center gap-1.5 transition-all ${
67
+ canUndo
68
+ ? "bg-blue-600 text-white hover:bg-blue-700"
69
+ : "bg-gray-300 text-gray-500 cursor-not-allowed"
70
+ }`}
71
+ title="Undo last annotation"
72
+ >
73
+ <Undo2 className="w-3.5 h-3.5" />
74
+ <span className="text-[11px] font-semibold">Undo</span>
75
+ </button>
76
+
77
+ <button
78
+ onClick={onDelete}
79
+ disabled={!canDelete}
80
+ className={`px-2 py-1 rounded-md flex items-center gap-1.5 transition-all ${
81
+ canDelete
82
+ ? "bg-red-600 text-white hover:bg-red-700"
83
+ : "bg-gray-300 text-gray-500 cursor-not-allowed"
84
+ }`}
85
+ title="Delete selected annotation"
86
+ >
87
+ <Trash2 className="w-3.5 h-3.5" />
88
+ <span className="text-[11px] font-semibold">Delete</span>
89
+ </button>
90
+ </div>
91
+
92
+ {/* Center Section: Zoom Controls */}
93
+ <div className="flex items-center gap-2">
94
+ <div className="flex items-center gap-1">
95
+ {zoomPresets.map((preset) => (
96
+ <button
97
+ key={preset}
98
+ onClick={() => onZoomPreset(preset)}
99
+ className={`px-2 py-1 rounded-md text-[11px] font-semibold border transition-colors ${
100
+ isActivePreset(preset)
101
+ ? "bg-teal-600 text-white border-teal-600"
102
+ : "bg-white text-gray-700 border-gray-300 hover:bg-gray-100"
103
+ }`}
104
+ title={`Set zoom to ${preset}x`}
105
+ >
106
+ {preset}x
107
+ </button>
108
+ ))}
109
+ </div>
110
+ <button
111
+ onClick={onZoomOut}
112
+ className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
113
+ title="Zoom Out"
114
+ >
115
+ <ZoomOut className="w-4.5 h-4.5 text-gray-700" />
116
+ </button>
117
+
118
+ <button
119
+ onClick={onZoomIn}
120
+ className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
121
+ title="Zoom In"
122
+ >
123
+ <ZoomIn className="w-4.5 h-4.5 text-gray-700" />
124
+ </button>
125
+ </div>
126
+
127
+ {/* Right Section: Scale + Toggle Buttons */}
128
+ <div className="flex items-center gap-3">
129
+ {micronsPerPixel && (
130
+ <div className="flex items-center gap-2">
131
+ <div className="text-[11px] text-gray-500">Scale</div>
132
+ <div className="flex flex-col items-end">
133
+ <div
134
+ className="h-1 bg-gray-700 rounded"
135
+ style={{ width: scaleBarPixels }}
136
+ />
137
+ <div className="text-[11px] text-gray-600 mt-1">{micronsLabel}</div>
138
+ </div>
139
+ </div>
140
+ )}
141
+
142
+ <div className="flex items-center gap-2">
143
+ <button
144
+ onClick={onToggleAnnotations}
145
+ className={`
146
+ px-3 py-1.5 rounded-lg flex items-center gap-2 transition-all
147
+ ${
148
+ showAnnotations
149
+ ? "bg-teal-100 text-teal-700 border border-teal-300"
150
+ : "bg-gray-100 text-gray-600 border border-gray-300 hover:bg-gray-200"
151
+ }
152
+ `}
153
+ title="Toggle Annotations"
154
+ >
155
+ {showAnnotations ? (
156
+ <Eye className="w-4 h-4" />
157
+ ) : (
158
+ <EyeOff className="w-4 h-4" />
159
+ )}
160
+ <span className="text-[11px] font-medium">Annotations</span>
161
+ </button>
162
+
163
+ <button
164
+ onClick={onToggleHeatmap}
165
+ className={`
166
+ px-3 py-1.5 rounded-lg flex items-center gap-2 transition-all
167
+ ${
168
+ showHeatmap
169
+ ? "bg-orange-100 text-orange-700 border border-orange-300"
170
+ : "bg-gray-100 text-gray-600 border border-gray-300 hover:bg-gray-200"
171
+ }
172
+ `}
173
+ title="Toggle Heatmap"
174
+ >
175
+ <Layers className="w-4 h-4" />
176
+ <span className="text-[11px] font-medium">Heatmap</span>
177
+ </button>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
frontend/src/components/viewer/index.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export { PathoraViewer } from "./PathoraViewer";
2
+ export { ToolsSidebar } from "./ToolsSidebar";
3
+ export { TopToolbar } from "./TopToolbar";
4
+ export { AnnotationCanvas, type Annotation } from "./AnnotationCanvas";
5
+ export type { Tool } from "./PathoraViewer";
frontend/src/components/viewer/viewer.css ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* OpenSeadragon Navigator Styling */
2
+ .navigator {
3
+ border: 2px solid #14b8a6 !important;
4
+ background-color: rgba(0, 0, 0, 0.5) !important;
5
+ }
6
+
7
+ /* OpenSeadragon Display Region (current view indicator in minimap) */
8
+ .displayregion {
9
+ border: 2px solid #0d9488 !important;
10
+ background-color: rgba(20, 184, 166, 0.2) !important;
11
+ }
12
+
13
+ /* Annotation Canvas Styling */
14
+ canvas {
15
+ image-rendering: pixelated;
16
+ image-rendering: crisp-edges;
17
+ }
18
+
19
+ /* Crosshair cursor for drawing tools */
20
+ .crosshair {
21
+ cursor: crosshair !important;
22
+ }
23
+
24
+ /* Default cursor */
25
+ .viewer-container {
26
+ cursor: default;
27
+ }
28
+
29
+ /* Pan cursor */
30
+ .pan-cursor {
31
+ cursor: grab !important;
32
+ }
33
+
34
+ .pan-cursor:active {
35
+ cursor: grabbing !important;
36
+ }
37
+
38
+ /* Smooth transitions */
39
+ .openseadragon-canvas {
40
+ transition: opacity 0.3s ease-in-out;
41
+ }
42
+
43
+ /* Annotation overlay z-index stacking */
44
+ .viewer-controls {
45
+ position: relative;
46
+ z-index: 30;
47
+ }
48
+
49
+ .annotation-canvas {
50
+ position: absolute;
51
+ top: 0;
52
+ left: 0;
53
+ z-index: 40;
54
+ }
55
+
56
+ .osd-navigator {
57
+ z-index: 45;
58
+ }