malavikapradeep2001 commited on
Commit
2bc6cf8
·
verified ·
1 Parent(s): 9f7c024

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

Browse files
.gitignore CHANGED
@@ -1,18 +1,19 @@
1
- # Ignore generated outputs and binaries
2
- backend/outputs/
3
-
4
- # Node / frontend build artefacts (kept for safety alongside frontend/.gitignore)
5
- node_modules/
6
- dist/
7
- dist-ssr/
8
-
9
- # Python cache
10
- __pycache__/
11
- *.py[cod]
12
-
13
- # Jupyter
14
- .ipynb_checkpoints/
15
-
16
- # OS files
17
- .DS_Store
18
- Thumbs.db
 
 
1
+ # Ignore generated outputs and binaries
2
+ backend/outputs/
3
+ backend/tile_server/data/uploads/
4
+
5
+ # Node / frontend build artefacts (kept for safety alongside frontend/.gitignore)
6
+ node_modules/
7
+ dist/
8
+ dist-ssr/
9
+
10
+ # Python cache
11
+ __pycache__/
12
+ *.py[cod]
13
+
14
+ # Jupyter
15
+ .ipynb_checkpoints/
16
+
17
+ # OS files
18
+ .DS_Store
19
+ Thumbs.db
Dockerfile CHANGED
@@ -1,43 +1,43 @@
1
- # -----------------------------
2
- # 1️⃣ Build Frontend (cache-bust 2025-12-31)
3
- # -----------------------------
4
- FROM node:18-bullseye AS frontend-builder
5
- WORKDIR /app/frontend
6
- COPY frontend/package*.json ./
7
- RUN npm install
8
- COPY frontend/ .
9
- RUN npm run build
10
-
11
- # -----------------------------
12
- # 2️⃣ Build Backend
13
- # -----------------------------
14
- FROM python:3.10-slim-bullseye
15
-
16
- WORKDIR /app
17
-
18
- # Install essential system libraries for OpenCV, YOLO, and Ultralytics
19
- RUN apt-get update && apt-get install -y \
20
- libgl1 \
21
- libglib2.0-0 \
22
- libgomp1 \
23
- && apt-get clean && rm -rf /var/lib/apt/lists/*
24
-
25
- # Copy backend source code
26
- COPY backend/ .
27
-
28
- # Copy built frontend into the right folder for FastAPI
29
- # ✅ this must match your app.mount() path in app.py
30
- COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
31
-
32
- # Install Python dependencies
33
- RUN pip install --upgrade pip
34
- RUN pip install -r requirements.txt || pip install -r backend/requirements.txt || true
35
-
36
- # Install runtime dependencies explicitly
37
- RUN pip install --no-cache-dir fastapi uvicorn python-multipart ultralytics opencv-python-headless pillow numpy scikit-learn tensorflow keras
38
-
39
- # Hugging Face Spaces sets PORT (default 7860). Listen on $PORT for compatibility.
40
- EXPOSE 7860
41
-
42
- # Run FastAPI app (respect PORT env var if set by the platform)
43
- CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860}"]
 
1
+ # -----------------------------
2
+ # 1️⃣ Build Frontend (cache-bust 2025-12-31)
3
+ # -----------------------------
4
+ FROM node:18-bullseye AS frontend-builder
5
+ WORKDIR /app/frontend
6
+ COPY frontend/package*.json ./
7
+ RUN npm install
8
+ COPY frontend/ .
9
+ RUN npm run build
10
+
11
+ # -----------------------------
12
+ # 2️⃣ Build Backend
13
+ # -----------------------------
14
+ FROM python:3.10-slim-bullseye
15
+
16
+ WORKDIR /app
17
+
18
+ # Install essential system libraries for OpenCV, YOLO, and Ultralytics
19
+ RUN apt-get update && apt-get install -y \
20
+ libgl1 \
21
+ libglib2.0-0 \
22
+ libgomp1 \
23
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
24
+
25
+ # Copy backend source code
26
+ COPY backend/ .
27
+
28
+ # Copy built frontend into the right folder for FastAPI
29
+ # ✅ this must match your app.mount() path in app.py
30
+ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
31
+
32
+ # Install Python dependencies
33
+ RUN pip install --upgrade pip
34
+ RUN pip install -r requirements.txt || pip install -r backend/requirements.txt || true
35
+
36
+ # Install runtime dependencies explicitly
37
+ RUN pip install --no-cache-dir fastapi uvicorn python-multipart ultralytics opencv-python-headless pillow numpy scikit-learn tensorflow keras
38
+
39
+ # Hugging Face Spaces sets PORT (default 7860). Listen on $PORT for compatibility.
40
+ EXPOSE 7860
41
+
42
+ # Run FastAPI app (respect PORT env var if set by the platform)
43
+ CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860}"]
HUGGINGFACE_DEPLOYMENT.md CHANGED
@@ -1,216 +1,216 @@
1
- # Hugging Face Spaces Deployment Guide
2
-
3
- ## ✅ Changes Made for Hugging Face Spaces Compatibility
4
-
5
- ### 1. Port Configuration
6
- - **Updated `backend/app.py`**: Server now reads `PORT` from environment variable (default: 7860)
7
- ```python
8
- port = int(os.environ.get("PORT", 7860))
9
- uvicorn.run(app, host="0.0.0.0", port=port)
10
- ```
11
- - **Updated `Dockerfile`**: CMD uses `${PORT:-7860}` for dynamic port binding
12
-
13
- ### 2. Filesystem Permissions
14
- - **Changed output directory**: `OUTPUT_DIR` now uses `/tmp/outputs` instead of `./outputs`
15
- - Hugging Face Spaces containers have read-only `/app` directory
16
- - `/tmp` is writable for temporary files
17
- - **Note**: Files in `/tmp` are ephemeral and lost on restart
18
-
19
- ### 3. Static File Serving
20
- - **Fixed sample image serving**: Mounted `/cyto`, `/colpo`, `/histo` directories from `frontend/dist`
21
- - **Added catch-all route**: Serves static files (logos, banners) from dist root
22
- - **Frontend dist path fallback**: Checks both `./frontend/dist` (Docker) and `../frontend/dist` (local dev)
23
-
24
- ### 4. Frontend Configuration
25
- - **Frontend already configured**: Uses `window.location.origin` in production, so API calls work on any domain
26
- - **Vite build**: Copies `public/` contents to `dist/` automatically
27
-
28
- ---
29
-
30
- ## 📋 Deployment Checklist
31
-
32
- ### Step 1: Create Hugging Face Space
33
- 1. Go to https://huggingface.co/spaces
34
- 2. Click **"Create new Space"**
35
- 3. Choose:
36
- - **Space SDK**: Docker
37
- - **Hardware**: CPU Basic (free) or GPU (for faster inference)
38
- - **Visibility**: Public or Private
39
-
40
- ### Step 2: Set Up Git LFS (for large model files)
41
- Your project has large model files (`.pt`, `.pth`, `.keras`). Track them with Git LFS:
42
-
43
- ```bash
44
- # Install Git LFS if not already installed
45
- git lfs install
46
-
47
- # Track model files
48
- git lfs track "*.pt"
49
- git lfs track "*.pth"
50
- git lfs track "*.keras"
51
- git lfs track "*.pkl"
52
-
53
- # Commit .gitattributes
54
- git add .gitattributes
55
- git commit -m "Track model files with Git LFS"
56
- ```
57
-
58
- ### Step 3: Configure Secrets (Optional)
59
- If you want AI-generated summaries using Mistral, add a secret:
60
-
61
- 1. Go to Space Settings → Variables and secrets
62
- 2. Add new secret:
63
- - Name: `HF_TOKEN`
64
- - Value: Your Hugging Face token (from https://huggingface.co/settings/tokens)
65
-
66
- ### Step 4: Push Code to Space
67
- ```bash
68
- # Add Space as remote
69
- git remote add space https://huggingface.co/spaces/<YOUR_USERNAME>/<SPACE_NAME>
70
-
71
- # Push to Space
72
- git push space main
73
- ```
74
-
75
- ### Step 5: Monitor Build
76
- - Hugging Face will build the Docker image (this may take 10-20 minutes)
77
- - Watch logs in the Space's "Logs" tab
78
- - Once built, the Space will automatically start
79
-
80
- ---
81
-
82
- ## 🔍 Troubleshooting
83
-
84
- ### Build Issues
85
-
86
- **Problem**: Docker build times out or fails
87
- - **Solution**: Reduce image size by pinning lighter dependencies in `requirements.txt`
88
- - **Solution**: Consider using pre-built wheels for TensorFlow/PyTorch
89
-
90
- **Problem**: Model files not found
91
- - **Solution**: Ensure Git LFS is configured and model files are committed
92
- - **Solution**: Check that model paths in `backend/app.py` match actual filenames
93
-
94
- ### Runtime Issues
95
-
96
- **Problem**: 404 errors for sample images
97
- - **Solution**: Rebuild frontend: `cd frontend && npm run build`
98
- - **Solution**: Verify `frontend/public/` contents are copied to `dist/`
99
-
100
- **Problem**: Permission denied errors
101
- - **Solution**: All writes should go to `/tmp/outputs` (already fixed)
102
- - **Solution**: Never write to `/app` directory
103
-
104
- **Problem**: Port binding errors
105
- - **Solution**: Use `$PORT` env var (already configured in Dockerfile and app.py)
106
-
107
- ### Performance Issues
108
-
109
- **Problem**: Slow startup or inference
110
- - **Solution**: Models load at startup; consider lazy loading on first request
111
- - **Solution**: Upgrade to GPU hardware tier for faster inference
112
- - **Solution**: Add caching for model weights
113
-
114
- ---
115
-
116
- ## 📁 File Structure Expected in Space
117
-
118
- ```
119
- /app/
120
- ├── app.py # Main FastAPI app
121
- ├── model.py, model_histo.py, etc. # Model definitions
122
- ├── augmentations.py # Image preprocessing
123
- ├── requirements.txt # Python dependencies
124
- ├── best2.pt # YOLO cytology model
125
- ├── MWTclass2.pth # MWT classifier
126
- ├── yolo_colposcopy.pt # YOLO colposcopy model
127
- ├── histopathology_trained_model.keras # Histopathology model
128
- ├── logistic_regression_model.pkl # CIN classifier (optional)
129
- └── frontend/
130
- └── dist/ # Built frontend
131
- ├── index.html
132
- ├── assets/ # JS/CSS bundles
133
- ├── cyto/ # Sample cytology images
134
- ├── colpo/ # Sample colposcopy images
135
- ├── histo/ # Sample histopathology images
136
- └── *.png, *.jpeg # Logos, banners
137
- ```
138
-
139
- ---
140
-
141
- ## 🌐 Access Your Space
142
-
143
- Once deployed, your app will be available at:
144
- ```
145
- https://huggingface.co/spaces/<YOUR_USERNAME>/<SPACE_NAME>
146
- ```
147
-
148
- The frontend serves at `/` and the API is accessible at:
149
- - `POST /predict/` - Run model inference
150
- - `POST /reports/` - Generate medical reports
151
- - `GET /health` - Health check
152
- - `GET /models` - List available models
153
-
154
- ---
155
-
156
- ## ⚠️ Important Notes
157
-
158
- ### Ephemeral Storage
159
- - Files in `/tmp/outputs` are **lost on restart**
160
- - For persistent reports, consider:
161
- - Downloading immediately after generation
162
- - Uploading to external storage (S3, Hugging Face Datasets)
163
- - Using Persistent Storage (requires paid tier)
164
-
165
- ### Model Loading Time
166
- - All models load at startup (~30-60 seconds)
167
- - First request after restart may be slower
168
- - Consider implementing health check endpoint that waits for models
169
-
170
- ### Resource Limits
171
- - Free CPU tier: Limited RAM and CPU
172
- - Models are memory-intensive (TensorFlow + PyTorch + YOLO)
173
- - May need **CPU Upgrade** or **GPU** tier for production use
174
-
175
- ### CORS
176
- - Currently allows all origins (`allow_origins=["*"]`)
177
- - For production, restrict to your Space domain
178
-
179
- ---
180
-
181
- ## 🚀 Next Steps After Deployment
182
-
183
- 1. **Test all three models**:
184
- - Upload cytology sample → Test YOLO detection
185
- - Upload colposcopy sample → Test CIN classification
186
- - Upload histopathology sample → Test breast cancer classification
187
-
188
- 2. **Generate a test report**:
189
- - Run an analysis
190
- - Fill out patient metadata
191
- - Generate HTML/PDF report
192
- - Verify download links work
193
-
194
- 3. **Monitor performance**:
195
- - Check inference times
196
- - Monitor memory usage in Space logs
197
- - Consider upgrading hardware if needed
198
-
199
- 4. **Share your Space**:
200
- - Add a README with usage instructions
201
- - Include sample images in the repo
202
- - Add citations for model papers
203
-
204
- ---
205
-
206
- ## 📞 Support
207
-
208
- If you encounter issues:
209
- 1. Check Space logs: Settings → Logs
210
- 2. Verify all model files are present: Settings → Files
211
- 3. Test locally with Docker: `docker build -t pathora . && docker run -p 7860:7860 pathora`
212
- 4. Open an issue on Hugging Face Discuss: https://discuss.huggingface.co/
213
-
214
- ---
215
-
216
- **Deployment ready! 🎉**
 
1
+ # Hugging Face Spaces Deployment Guide
2
+
3
+ ## ✅ Changes Made for Hugging Face Spaces Compatibility
4
+
5
+ ### 1. Port Configuration
6
+ - **Updated `backend/app.py`**: Server now reads `PORT` from environment variable (default: 7860)
7
+ ```python
8
+ port = int(os.environ.get("PORT", 7860))
9
+ uvicorn.run(app, host="0.0.0.0", port=port)
10
+ ```
11
+ - **Updated `Dockerfile`**: CMD uses `${PORT:-7860}` for dynamic port binding
12
+
13
+ ### 2. Filesystem Permissions
14
+ - **Changed output directory**: `OUTPUT_DIR` now uses `/tmp/outputs` instead of `./outputs`
15
+ - Hugging Face Spaces containers have read-only `/app` directory
16
+ - `/tmp` is writable for temporary files
17
+ - **Note**: Files in `/tmp` are ephemeral and lost on restart
18
+
19
+ ### 3. Static File Serving
20
+ - **Fixed sample image serving**: Mounted `/cyto`, `/colpo`, `/histo` directories from `frontend/dist`
21
+ - **Added catch-all route**: Serves static files (logos, banners) from dist root
22
+ - **Frontend dist path fallback**: Checks both `./frontend/dist` (Docker) and `../frontend/dist` (local dev)
23
+
24
+ ### 4. Frontend Configuration
25
+ - **Frontend already configured**: Uses `window.location.origin` in production, so API calls work on any domain
26
+ - **Vite build**: Copies `public/` contents to `dist/` automatically
27
+
28
+ ---
29
+
30
+ ## 📋 Deployment Checklist
31
+
32
+ ### Step 1: Create Hugging Face Space
33
+ 1. Go to https://huggingface.co/spaces
34
+ 2. Click **"Create new Space"**
35
+ 3. Choose:
36
+ - **Space SDK**: Docker
37
+ - **Hardware**: CPU Basic (free) or GPU (for faster inference)
38
+ - **Visibility**: Public or Private
39
+
40
+ ### Step 2: Set Up Git LFS (for large model files)
41
+ Your project has large model files (`.pt`, `.pth`, `.keras`). Track them with Git LFS:
42
+
43
+ ```bash
44
+ # Install Git LFS if not already installed
45
+ git lfs install
46
+
47
+ # Track model files
48
+ git lfs track "*.pt"
49
+ git lfs track "*.pth"
50
+ git lfs track "*.keras"
51
+ git lfs track "*.pkl"
52
+
53
+ # Commit .gitattributes
54
+ git add .gitattributes
55
+ git commit -m "Track model files with Git LFS"
56
+ ```
57
+
58
+ ### Step 3: Configure Secrets (Optional)
59
+ If you want AI-generated summaries using Mistral, add a secret:
60
+
61
+ 1. Go to Space Settings → Variables and secrets
62
+ 2. Add new secret:
63
+ - Name: `HF_TOKEN`
64
+ - Value: Your Hugging Face token (from https://huggingface.co/settings/tokens)
65
+
66
+ ### Step 4: Push Code to Space
67
+ ```bash
68
+ # Add Space as remote
69
+ git remote add space https://huggingface.co/spaces/<YOUR_USERNAME>/<SPACE_NAME>
70
+
71
+ # Push to Space
72
+ git push space main
73
+ ```
74
+
75
+ ### Step 5: Monitor Build
76
+ - Hugging Face will build the Docker image (this may take 10-20 minutes)
77
+ - Watch logs in the Space's "Logs" tab
78
+ - Once built, the Space will automatically start
79
+
80
+ ---
81
+
82
+ ## 🔍 Troubleshooting
83
+
84
+ ### Build Issues
85
+
86
+ **Problem**: Docker build times out or fails
87
+ - **Solution**: Reduce image size by pinning lighter dependencies in `requirements.txt`
88
+ - **Solution**: Consider using pre-built wheels for TensorFlow/PyTorch
89
+
90
+ **Problem**: Model files not found
91
+ - **Solution**: Ensure Git LFS is configured and model files are committed
92
+ - **Solution**: Check that model paths in `backend/app.py` match actual filenames
93
+
94
+ ### Runtime Issues
95
+
96
+ **Problem**: 404 errors for sample images
97
+ - **Solution**: Rebuild frontend: `cd frontend && npm run build`
98
+ - **Solution**: Verify `frontend/public/` contents are copied to `dist/`
99
+
100
+ **Problem**: Permission denied errors
101
+ - **Solution**: All writes should go to `/tmp/outputs` (already fixed)
102
+ - **Solution**: Never write to `/app` directory
103
+
104
+ **Problem**: Port binding errors
105
+ - **Solution**: Use `$PORT` env var (already configured in Dockerfile and app.py)
106
+
107
+ ### Performance Issues
108
+
109
+ **Problem**: Slow startup or inference
110
+ - **Solution**: Models load at startup; consider lazy loading on first request
111
+ - **Solution**: Upgrade to GPU hardware tier for faster inference
112
+ - **Solution**: Add caching for model weights
113
+
114
+ ---
115
+
116
+ ## 📁 File Structure Expected in Space
117
+
118
+ ```
119
+ /app/
120
+ ├── app.py # Main FastAPI app
121
+ ├── model.py, model_histo.py, etc. # Model definitions
122
+ ├── augmentations.py # Image preprocessing
123
+ ├── requirements.txt # Python dependencies
124
+ ├── best2.pt # YOLO cytology model
125
+ ├── MWTclass2.pth # MWT classifier
126
+ ├── yolo_colposcopy.pt # YOLO colposcopy model
127
+ ├── histopathology_trained_model.keras # Histopathology model
128
+ ├── logistic_regression_model.pkl # CIN classifier (optional)
129
+ └── frontend/
130
+ └── dist/ # Built frontend
131
+ ├── index.html
132
+ ├── assets/ # JS/CSS bundles
133
+ ├── cyto/ # Sample cytology images
134
+ ├── colpo/ # Sample colposcopy images
135
+ ├── histo/ # Sample histopathology images
136
+ └── *.png, *.jpeg # Logos, banners
137
+ ```
138
+
139
+ ---
140
+
141
+ ## 🌐 Access Your Space
142
+
143
+ Once deployed, your app will be available at:
144
+ ```
145
+ https://huggingface.co/spaces/<YOUR_USERNAME>/<SPACE_NAME>
146
+ ```
147
+
148
+ The frontend serves at `/` and the API is accessible at:
149
+ - `POST /predict/` - Run model inference
150
+ - `POST /reports/` - Generate medical reports
151
+ - `GET /health` - Health check
152
+ - `GET /models` - List available models
153
+
154
+ ---
155
+
156
+ ## ⚠️ Important Notes
157
+
158
+ ### Ephemeral Storage
159
+ - Files in `/tmp/outputs` are **lost on restart**
160
+ - For persistent reports, consider:
161
+ - Downloading immediately after generation
162
+ - Uploading to external storage (S3, Hugging Face Datasets)
163
+ - Using Persistent Storage (requires paid tier)
164
+
165
+ ### Model Loading Time
166
+ - All models load at startup (~30-60 seconds)
167
+ - First request after restart may be slower
168
+ - Consider implementing health check endpoint that waits for models
169
+
170
+ ### Resource Limits
171
+ - Free CPU tier: Limited RAM and CPU
172
+ - Models are memory-intensive (TensorFlow + PyTorch + YOLO)
173
+ - May need **CPU Upgrade** or **GPU** tier for production use
174
+
175
+ ### CORS
176
+ - Currently allows all origins (`allow_origins=["*"]`)
177
+ - For production, restrict to your Space domain
178
+
179
+ ---
180
+
181
+ ## 🚀 Next Steps After Deployment
182
+
183
+ 1. **Test all three models**:
184
+ - Upload cytology sample → Test YOLO detection
185
+ - Upload colposcopy sample → Test CIN classification
186
+ - Upload histopathology sample → Test breast cancer classification
187
+
188
+ 2. **Generate a test report**:
189
+ - Run an analysis
190
+ - Fill out patient metadata
191
+ - Generate HTML/PDF report
192
+ - Verify download links work
193
+
194
+ 3. **Monitor performance**:
195
+ - Check inference times
196
+ - Monitor memory usage in Space logs
197
+ - Consider upgrading hardware if needed
198
+
199
+ 4. **Share your Space**:
200
+ - Add a README with usage instructions
201
+ - Include sample images in the repo
202
+ - Add citations for model papers
203
+
204
+ ---
205
+
206
+ ## 📞 Support
207
+
208
+ If you encounter issues:
209
+ 1. Check Space logs: Settings → Logs
210
+ 2. Verify all model files are present: Settings → Files
211
+ 3. Test locally with Docker: `docker build -t pathora . && docker run -p 7860:7860 pathora`
212
+ 4. Open an issue on Hugging Face Discuss: https://discuss.huggingface.co/
213
+
214
+ ---
215
+
216
+ **Deployment ready! 🎉**
PATHORA_VIEWER_README.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pathora Viewer - Whole Slide Image Viewer
2
+
3
+ A QuPath-inspired web-based image viewer built with OpenSeadragon for viewing and annotating pathology slides.
4
+
5
+ ## Features
6
+
7
+ ### Layout
8
+ - **Left Sidebar**: Tool palette with interactive buttons
9
+ - **Central Viewer**: OpenSeadragon-powered image viewer with deep zoom capabilities
10
+ - **Top Toolbar**: Slide information, zoom controls, and overlay toggles
11
+ - **Navigator**: Minimap in bottom-right corner for quick navigation
12
+
13
+ ### Tools
14
+ 1. **Select Tool**: Click to select and interact with annotations
15
+ 2. **Pan Tool**: Navigate around the slide (default)
16
+ 3. **Rectangle Tool**: Draw rectangular regions of interest
17
+ 4. **Polygon Tool**: Draw freeform polygonal annotations
18
+
19
+ ### Toolbar Features
20
+ - **Slide Name**: Displays the current slide being viewed
21
+ - **Zoom Level**: Real-time zoom indicator (e.g., 1.50x)
22
+ - **Reset View**: Return to the home/default view
23
+ - **Zoom In/Out**: Buttons to control magnification
24
+ - **Annotations Toggle**: Show/hide annotation overlays
25
+ - **Heatmap Toggle**: Show/hide AI-generated heatmap overlays
26
+
27
+ ### Navigation
28
+ - **Pan**: Click and drag with pan tool or middle mouse button
29
+ - **Zoom**: Mouse wheel or zoom buttons
30
+ - **Double-click**: Quick zoom to clicked area
31
+ - **Navigator**: Click minimap to jump to location
32
+
33
+ ## Technical Details
34
+
35
+ ### Technologies Used
36
+ - **React**: Component-based UI framework
37
+ - **TypeScript**: Type-safe development
38
+ - **OpenSeadragon**: Deep zoom image viewer
39
+ - **Tailwind CSS**: Modern, responsive styling
40
+ - **Lucide React**: Clean, consistent icons
41
+ - **React Router**: Client-side routing
42
+
43
+ ### Component Structure
44
+ ```
45
+ viewer/
46
+ ├── PathoraViewer.tsx # Main viewer component
47
+ ├── ToolsSidebar.tsx # Left tool palette
48
+ ├── TopToolbar.tsx # Top control bar
49
+ ├── viewer.css # Custom styles
50
+ └── index.ts # Exports
51
+ ```
52
+
53
+ ### OpenSeadragon Configuration
54
+ - **Deep Zoom**: Supports multi-resolution tile pyramids
55
+ - **Navigator**: Enabled with custom styling
56
+ - **Constraints**: Prevents panning outside image bounds
57
+ - **Gestures**: Customized mouse interactions
58
+
59
+ ## Usage
60
+
61
+ ### Accessing the Viewer
62
+ 1. From the main dashboard, click **"Pathora Viewer"** in the sidebar
63
+ 2. Or navigate directly to `/viewer` route
64
+
65
+ ### Basic Workflow
66
+ 1. **Open a slide**: Currently loads sample images (can be extended to file upload)
67
+ 2. **Select a tool**: Click a tool button in the left sidebar
68
+ 3. **Interact with the image**:
69
+ - Use pan tool to navigate
70
+ - Use drawing tools to annotate regions
71
+ 4. **Adjust overlays**: Toggle annotations and heatmap as needed
72
+ 5. **Reset view**: Click "Reset View" to return to starting position
73
+
74
+ ### Keyboard Shortcuts (Future Enhancement)
75
+ - `Space + Drag`: Temporary pan
76
+ - `+/-`: Zoom in/out
77
+ - `H`: Toggle heatmap
78
+ - `A`: Toggle annotations
79
+ - `Esc`: Deselect tool
80
+
81
+ ## Customization
82
+
83
+ ### Loading Custom Images
84
+ Edit the `PathoraViewer` component props:
85
+ ```tsx
86
+ <PathoraViewer
87
+ imageUrl="/path/to/your/image.jpg"
88
+ slideName="Your Slide Name.svs"
89
+ />
90
+ ```
91
+
92
+ ### Adding Deep Zoom Tiles
93
+ For large whole slide images, use DZI (Deep Zoom Image) format:
94
+ ```tsx
95
+ tileSources: {
96
+ type: "image",
97
+ url: "/path/to/image.dzi"
98
+ }
99
+ ```
100
+
101
+ ### Styling
102
+ Modify colors and appearance in:
103
+ - `viewer.css`: OpenSeadragon-specific styles
104
+ - Component files: Tailwind classes for UI elements
105
+
106
+ ## Future Enhancements
107
+
108
+ ### Planned Features
109
+ - [ ] File upload for custom slides
110
+ - [ ] Persistent annotation storage
111
+ - [ ] Measurement tools (ruler, area)
112
+ - [ ] Annotation export (JSON, GeoJSON)
113
+ - [ ] Collaborative viewing/annotations
114
+ - [ ] AI model integration for auto-annotation
115
+ - [ ] Multi-channel fluorescence support
116
+ - [ ] Z-stack navigation for 3D images
117
+ - [ ] Annotation categories/classification
118
+ - [ ] Export annotated regions as images
119
+
120
+ ### Advanced Features
121
+ - [ ] Integration with DICOM WSI
122
+ - [ ] Server-side tile generation
123
+ - [ ] Real-time collaboration
124
+ - [ ] Pathologist reporting tools
125
+ - [ ] Machine learning overlay visualization
126
+ - [ ] Comparison mode (side-by-side slides)
127
+
128
+ ## Medical Imaging Standards
129
+
130
+ The viewer is designed with pathology workflows in mind:
131
+ - Compatible with common WSI formats (SVS, NDPI, TIFF)
132
+ - Optimized for high-resolution microscopy images
133
+ - Follows QuPath-inspired UX patterns familiar to pathologists
134
+
135
+ ## Development
136
+
137
+ ### Running the Viewer
138
+ ```bash
139
+ cd frontend
140
+ npm install
141
+ npm run dev
142
+ ```
143
+
144
+ Navigate to `http://localhost:5173/viewer`
145
+
146
+ ### Building for Production
147
+ ```bash
148
+ npm run build
149
+ ```
150
+
151
+ ## License
152
+ Part of the Pathora medical imaging platform.
153
+
154
+ ---
155
+
156
+ **Note**: This viewer is designed for research and educational purposes. For clinical diagnostic use, ensure compliance with relevant medical device regulations and standards.
app.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Root-level FastAPI app entry point for Hugging Face Spaces
3
+ Imports and re-exports the tile server app from backend
4
+ """
5
+
6
+ from backend.app import app
7
+
8
+ # This ensures uvicorn can find and run the app
9
+ __all__ = ["app"]
backend/app.py CHANGED
The diff for this file is too large to render. See raw diff
 
backend/requirements.txt CHANGED
@@ -1,14 +1,15 @@
1
- fastapi
2
- uvicorn
3
- pillow
4
- ultralytics
5
- tensorflow
6
- numpy
7
- huggingface_hub
8
- joblib
9
- scikit-learn
10
- scikit-image
11
- loguru
12
- thop
13
- seaborn
14
- python-multipart
 
 
1
+ fastapi
2
+ uvicorn
3
+ pillow
4
+ openslide-python
5
+ ultralytics
6
+ tensorflow
7
+ numpy
8
+ huggingface_hub
9
+ joblib
10
+ scikit-learn
11
+ scikit-image
12
+ loguru
13
+ thop
14
+ seaborn
15
+ python-multipart
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/index.html CHANGED
@@ -1,13 +1,13 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Manalife AI Pathology Assistant</title>
8
- </head>
9
- <body>
10
- <div id="root"></div>
11
- <script type="module" src="/src/index.tsx"></script>
12
- </body>
13
- </html>
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Manalife AI Pathology Assistant</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/index.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/package.json CHANGED
@@ -1,35 +1,37 @@
1
- {
2
- "name": "magic-patterns-vite-template",
3
- "version": "0.0.1",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "dev": "npx vite",
8
- "build": "npx vite build",
9
- "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
10
- "preview": "npx vite preview"
11
- },
12
- "dependencies": {
13
- "axios": "^1.13.2",
14
- "lucide-react": "0.522.0",
15
- "react": "^18.3.1",
16
- "react-dom": "^18.3.1",
17
- "react-router-dom": "^6.26.2"
18
- },
19
- "devDependencies": {
20
- "@types/node": "^20.11.18",
21
- "@types/react": "^18.3.1",
22
- "@types/react-dom": "^18.3.1",
23
- "@typescript-eslint/eslint-plugin": "^5.54.0",
24
- "@typescript-eslint/parser": "^5.54.0",
25
- "@vitejs/plugin-react": "^4.2.1",
26
- "autoprefixer": "latest",
27
- "eslint": "^8.50.0",
28
- "eslint-plugin-react-hooks": "^4.6.0",
29
- "eslint-plugin-react-refresh": "^0.4.1",
30
- "postcss": "latest",
31
- "tailwindcss": "3.4.17",
32
- "typescript": "^5.5.4",
33
- "vite": "^5.2.0"
34
- }
35
- }
 
 
 
1
+ {
2
+ "name": "magic-patterns-vite-template",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "npx vite",
8
+ "build": "npx vite build",
9
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
10
+ "preview": "npx vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@types/openseadragon": "^5.0.2",
14
+ "axios": "^1.13.2",
15
+ "lucide-react": "0.522.0",
16
+ "openseadragon": "^5.0.1",
17
+ "react": "^18.3.1",
18
+ "react-dom": "^18.3.1",
19
+ "react-router-dom": "^6.26.2"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.11.18",
23
+ "@types/react": "^18.3.1",
24
+ "@types/react-dom": "^18.3.1",
25
+ "@typescript-eslint/eslint-plugin": "^5.54.0",
26
+ "@typescript-eslint/parser": "^5.54.0",
27
+ "@vitejs/plugin-react": "^4.2.1",
28
+ "autoprefixer": "latest",
29
+ "eslint": "^8.50.0",
30
+ "eslint-plugin-react-hooks": "^4.6.0",
31
+ "eslint-plugin-react-refresh": "^0.4.1",
32
+ "postcss": "latest",
33
+ "tailwindcss": "3.4.17",
34
+ "typescript": "^5.5.4",
35
+ "vite": "^5.2.0"
36
+ }
37
+ }
frontend/src/App.tsx CHANGED
@@ -1,143 +1,143 @@
1
- import { useState, useEffect } from "react";
2
- import axios from "axios";
3
- import { Header } from "./components/Header";
4
- import { Sidebar } from "./components/Sidebar";
5
- import { UploadSection } from "./components/UploadSection";
6
- import { ResultsPanel } from "./components/ResultsPanel";
7
- import { Footer } from "./components/Footer";
8
- import { ProgressBar } from "./components/progressbar";
9
-
10
- export function App() {
11
- const [selectedTest, setSelectedTest] = useState("cytology");
12
- const [uploadedImage, setUploadedImage] = useState<string | null>(null);
13
- const [selectedModel, setSelectedModel] = useState("");
14
- const [apiResult, setApiResult] = useState<any>(null);
15
- const [showResults, setShowResults] = useState(false);
16
- const [currentStep, setCurrentStep] = useState(0);
17
- const [loading, setLoading] = useState(false);
18
-
19
- // Progress bar logic
20
- useEffect(() => {
21
- if (showResults) setCurrentStep(2);
22
- else if (uploadedImage) setCurrentStep(1);
23
- else setCurrentStep(0);
24
- }, [uploadedImage, showResults]);
25
-
26
- // Reset logic
27
- useEffect(() => {
28
- setCurrentStep(0);
29
- setShowResults(false);
30
- setUploadedImage(null);
31
- setSelectedModel("");
32
- setApiResult(null);
33
- }, [selectedTest]);
34
-
35
- const handleAnalyze = async () => {
36
- console.log('Analyze button clicked', { uploadedImage, selectedModel });
37
- if (!uploadedImage || !selectedModel) {
38
- alert("Please select a model and upload an image first!");
39
- return;
40
- }
41
-
42
- setLoading(true);
43
- setShowResults(false);
44
- setApiResult(null);
45
-
46
- try {
47
- // Extract file extension from data URL or use .jpg default
48
- const extension = uploadedImage.startsWith('data:image/')
49
- ? uploadedImage.split(';')[0].split('/')[1]
50
- : 'jpg';
51
-
52
- // Create a more descriptive filename
53
- const filename = `analysis_input.${extension}`;
54
-
55
- let blob: Blob;
56
- if (uploadedImage.startsWith('data:')) {
57
- // Handle data URLs (from file upload)
58
- blob = await fetch(uploadedImage).then(r => r.blob());
59
- } else {
60
- // Handle sample images (relative URLs)
61
- blob = await fetch(uploadedImage)
62
- .then(r => r.blob())
63
- .catch(() => {
64
- // If fetch fails, try with base URL
65
- const baseURL = import.meta.env.MODE === "development"
66
- ? "http://127.0.0.1:5173"
67
- : window.location.origin;
68
- return fetch(`${baseURL}${uploadedImage}`).then(r => r.blob());
69
- });
70
- }
71
-
72
- const file = new File([blob], filename, { type: blob.type || `image/${extension}` });
73
- const formData = new FormData();
74
- formData.append("file", file);
75
- formData.append("model_name", selectedModel);
76
-
77
- console.log('Sending request:', {
78
- filename,
79
- type: file.type,
80
- size: file.size,
81
- model: selectedModel
82
- });
83
-
84
- const baseURL = import.meta.env.MODE === "development"
85
- ? "http://127.0.0.1:8000"
86
- : window.location.origin;
87
-
88
- const res = await axios.post(`${baseURL}/predict/`, formData, {
89
- headers: { "Content-Type": "multipart/form-data" },
90
- });
91
-
92
- if (res.data.error) {
93
- throw new Error(res.data.error);
94
- }
95
-
96
- console.log('Received response:', res.data);
97
- setApiResult(res.data);
98
- setShowResults(true);
99
- } catch (err: any) {
100
- console.error("❌ Error during inference:", err);
101
- const errorMessage = err.response?.data?.error || err.message || "Unknown error occurred";
102
- alert(`Error analyzing the image: ${errorMessage}`);
103
- } finally {
104
- setLoading(false);
105
- }
106
- };
107
-
108
- return ( <div className="flex flex-col min-h-screen w-full bg-gray-50"> <Header /> <ProgressBar currentStep={currentStep} />
109
-
110
-
111
- <div className="flex flex-1">
112
- <Sidebar selectedTest={selectedTest} onTestChange={setSelectedTest} />
113
-
114
- <main className="flex-1 p-6">
115
- <div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6">
116
- <UploadSection
117
- selectedTest={selectedTest}
118
- uploadedImage={uploadedImage}
119
- setUploadedImage={setUploadedImage}
120
- selectedModel={selectedModel}
121
- setSelectedModel={setSelectedModel}
122
- onAnalyze={handleAnalyze}
123
- />
124
-
125
- {(showResults || loading) && (
126
- <ResultsPanel
127
- uploadedImage={
128
- apiResult?.annotated_image_url || uploadedImage
129
- }
130
- result={apiResult}
131
- loading={loading}
132
- />
133
- )}
134
- </div>
135
- </main>
136
- </div>
137
-
138
- <Footer />
139
- </div>
140
-
141
-
142
- );
143
- }
 
1
+ import { useState, useEffect } from "react";
2
+ import axios from "axios";
3
+ import { Header } from "./components/Header";
4
+ import { Sidebar } from "./components/Sidebar";
5
+ import { UploadSection } from "./components/UploadSection";
6
+ import { ResultsPanel } from "./components/ResultsPanel";
7
+ import { Footer } from "./components/Footer";
8
+ import { ProgressBar } from "./components/progressbar";
9
+
10
+ export function App() {
11
+ const [selectedTest, setSelectedTest] = useState("cytology");
12
+ const [uploadedImage, setUploadedImage] = useState<string | null>(null);
13
+ const [selectedModel, setSelectedModel] = useState("");
14
+ const [apiResult, setApiResult] = useState<any>(null);
15
+ const [showResults, setShowResults] = useState(false);
16
+ const [currentStep, setCurrentStep] = useState(0);
17
+ const [loading, setLoading] = useState(false);
18
+
19
+ // Progress bar logic
20
+ useEffect(() => {
21
+ if (showResults) setCurrentStep(2);
22
+ else if (uploadedImage) setCurrentStep(1);
23
+ else setCurrentStep(0);
24
+ }, [uploadedImage, showResults]);
25
+
26
+ // Reset logic
27
+ useEffect(() => {
28
+ setCurrentStep(0);
29
+ setShowResults(false);
30
+ setUploadedImage(null);
31
+ setSelectedModel("");
32
+ setApiResult(null);
33
+ }, [selectedTest]);
34
+
35
+ const handleAnalyze = async () => {
36
+ console.log('Analyze button clicked', { uploadedImage, selectedModel });
37
+ if (!uploadedImage || !selectedModel) {
38
+ alert("Please select a model and upload an image first!");
39
+ return;
40
+ }
41
+
42
+ setLoading(true);
43
+ setShowResults(false);
44
+ setApiResult(null);
45
+
46
+ try {
47
+ // Extract file extension from data URL or use .jpg default
48
+ const extension = uploadedImage.startsWith('data:image/')
49
+ ? uploadedImage.split(';')[0].split('/')[1]
50
+ : 'jpg';
51
+
52
+ // Create a more descriptive filename
53
+ const filename = `analysis_input.${extension}`;
54
+
55
+ let blob: Blob;
56
+ if (uploadedImage.startsWith('data:')) {
57
+ // Handle data URLs (from file upload)
58
+ blob = await fetch(uploadedImage).then(r => r.blob());
59
+ } else {
60
+ // Handle sample images (relative URLs)
61
+ blob = await fetch(uploadedImage)
62
+ .then(r => r.blob())
63
+ .catch(() => {
64
+ // If fetch fails, try with base URL
65
+ const baseURL = import.meta.env.MODE === "development"
66
+ ? "http://127.0.0.1:5173"
67
+ : window.location.origin;
68
+ return fetch(`${baseURL}${uploadedImage}`).then(r => r.blob());
69
+ });
70
+ }
71
+
72
+ const file = new File([blob], filename, { type: blob.type || `image/${extension}` });
73
+ const formData = new FormData();
74
+ formData.append("file", file);
75
+ formData.append("model_name", selectedModel);
76
+
77
+ console.log('Sending request:', {
78
+ filename,
79
+ type: file.type,
80
+ size: file.size,
81
+ model: selectedModel
82
+ });
83
+
84
+ const baseURL = import.meta.env.MODE === "development"
85
+ ? "http://127.0.0.1:8000"
86
+ : window.location.origin;
87
+
88
+ const res = await axios.post(`${baseURL}/predict/`, formData, {
89
+ headers: { "Content-Type": "multipart/form-data" },
90
+ });
91
+
92
+ if (res.data.error) {
93
+ throw new Error(res.data.error);
94
+ }
95
+
96
+ console.log('Received response:', res.data);
97
+ setApiResult(res.data);
98
+ setShowResults(true);
99
+ } catch (err: any) {
100
+ console.error("❌ Error during inference:", err);
101
+ const errorMessage = err.response?.data?.error || err.message || "Unknown error occurred";
102
+ alert(`Error analyzing the image: ${errorMessage}`);
103
+ } finally {
104
+ setLoading(false);
105
+ }
106
+ };
107
+
108
+ return ( <div className="flex flex-col min-h-screen w-full bg-gray-50"> <Header /> <ProgressBar currentStep={currentStep} />
109
+
110
+
111
+ <div className="flex flex-1">
112
+ <Sidebar selectedTest={selectedTest} onTestChange={setSelectedTest} />
113
+
114
+ <main className="flex-1 p-6">
115
+ <div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6">
116
+ <UploadSection
117
+ selectedTest={selectedTest}
118
+ uploadedImage={uploadedImage}
119
+ setUploadedImage={setUploadedImage}
120
+ selectedModel={selectedModel}
121
+ setSelectedModel={setSelectedModel}
122
+ onAnalyze={handleAnalyze}
123
+ />
124
+
125
+ {(showResults || loading) && (
126
+ <ResultsPanel
127
+ uploadedImage={
128
+ apiResult?.annotated_image_url || uploadedImage
129
+ }
130
+ result={apiResult}
131
+ loading={loading}
132
+ />
133
+ )}
134
+ </div>
135
+ </main>
136
+ </div>
137
+
138
+ <Footer />
139
+ </div>
140
+
141
+
142
+ );
143
+ }
frontend/src/AppRouter.tsx CHANGED
@@ -1,10 +1,13 @@
1
- import React from "react";
2
- import { BrowserRouter, Routes, Route } from "react-router-dom";
3
- import { App } from "./App";
4
- export function AppRouter() {
5
- return <BrowserRouter>
6
- <Routes>
7
- <Route path="/" element={<App />} />
8
- </Routes>
9
- </BrowserRouter>;
 
 
 
10
  }
 
1
+ import React from "react";
2
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
3
+ import { App } from "./App";
4
+ import { PathoraViewer } from "./components/viewer/PathoraViewer";
5
+
6
+ export function AppRouter() {
7
+ return <BrowserRouter>
8
+ <Routes>
9
+ <Route path="/" element={<App />} />
10
+ <Route path="/viewer" element={<PathoraViewer />} />
11
+ </Routes>
12
+ </BrowserRouter>;
13
  }
frontend/tailwind.config.js CHANGED
@@ -1,4 +1,4 @@
1
- export default {content: [
2
- './index.html',
3
- './src/**/*.{js,ts,jsx,tsx}'
4
  ],}
 
1
+ export default {content: [
2
+ './index.html',
3
+ './src/**/*.{js,ts,jsx,tsx}'
4
  ],}
frontend/tsconfig.json CHANGED
@@ -1,26 +1,26 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "useDefineForClassFields": true,
5
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
- "module": "esnext",
7
- "skipLibCheck": true,
8
-
9
- /* Bundler mode */
10
- "moduleResolution": "node",
11
- "allowImportingTsExtensions": true,
12
- "resolveJsonModule": true,
13
- "isolatedModules": true,
14
- "noEmit": true,
15
- "jsx": "react-jsx",
16
-
17
- /* Linting */
18
- "strict": true,
19
- "noUnusedLocals": true,
20
- "noUnusedParameters": true,
21
- "noFallthroughCasesInSwitch": true,
22
- "allowSyntheticDefaultImports": true
23
- },
24
- "include": ["src", "src/env.d.ts"],
25
- "references": [{ "path": "./tsconfig.node.json" }]
26
- }
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "esnext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "node",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "allowSyntheticDefaultImports": true
23
+ },
24
+ "include": ["src", "src/env.d.ts"],
25
+ "references": [{ "path": "./tsconfig.node.json" }]
26
+ }
frontend/vite.config.js CHANGED
@@ -1,7 +1,7 @@
1
- import { defineConfig } from 'vite'
2
- import react from '@vitejs/plugin-react'
3
-
4
- // https://vitejs.dev/config/
5
- export default defineConfig({
6
- plugins: [react()],
7
- })
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })
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
+ }