Spaces:
Running
Running
| import { useState } from "react"; | |
| import { MousePointer, Square, Pentagon, Circle, Brush, ChevronLeft, ChevronRight } from "lucide-react"; | |
| import { Tool } from "./PathoraViewer"; | |
| import { type Annotation } from "./AnnotationCanvas"; | |
| interface ToolsSidebarProps { | |
| selectedTool: Tool; | |
| onToolChange: (tool: Tool) => void; | |
| annotations: Annotation[]; | |
| selectedAnnotationId: string | null; | |
| onSelectAnnotation: (annotationId: string | null) => void; | |
| uploadedSlides: Array<{ | |
| id: string; | |
| name: string; | |
| uploadedAt: string; | |
| levelCount: number; | |
| levelDimensions: number[][]; | |
| }>; | |
| tileServerUrl: string; | |
| onTileServerUrlChange: (value: string) => void; | |
| onSlideFileChange: (file: File | null) => void; | |
| slideFileName: string | null; | |
| onUploadSlide: () => void; | |
| isTileLoading: boolean; | |
| tileLoadError: string | null; | |
| onSelectUploadedSlide: (slideId: string) => void; | |
| activeLabel: string; | |
| onLabelChange: (label: string) => void; | |
| imageMeta: { | |
| stain: string; | |
| width: number | null; | |
| height: number | null; | |
| levelCount: number | null; | |
| mpp: number | null; | |
| slideId: string; | |
| }; | |
| channelVisibility: { | |
| original: boolean; | |
| hematoxylin: boolean; | |
| eosin: boolean; | |
| }; | |
| onChannelToggle: (channel: "original" | "hematoxylin" | "eosin", value: boolean) => void; | |
| isTileLoaded: boolean; | |
| isCollapsed: boolean; | |
| onToggleCollapsed: () => void; | |
| } | |
| export function ToolsSidebar({ | |
| selectedTool, | |
| onToolChange, | |
| annotations, | |
| selectedAnnotationId, | |
| onSelectAnnotation, | |
| uploadedSlides, | |
| tileServerUrl, | |
| onTileServerUrlChange, | |
| onSlideFileChange, | |
| slideFileName, | |
| onUploadSlide, | |
| isTileLoading, | |
| tileLoadError, | |
| onSelectUploadedSlide, | |
| activeLabel, | |
| onLabelChange, | |
| imageMeta, | |
| channelVisibility, | |
| onChannelToggle, | |
| isTileLoaded, | |
| isCollapsed, | |
| onToggleCollapsed, | |
| }: ToolsSidebarProps) { | |
| const [activeTab, setActiveTab] = useState<"tools" | "image" | "images" | "annotations">("tools"); | |
| const tools = [ | |
| { id: "select" as Tool, label: "Select", icon: MousePointer }, | |
| { id: "rectangle" as Tool, label: "Rectangle", icon: Square }, | |
| { id: "polygon" as Tool, label: "Polygon", icon: Pentagon }, | |
| { id: "ellipse" as Tool, label: "Ellipse", icon: Circle }, | |
| { id: "brush" as Tool, label: "Brush", icon: Brush }, | |
| ]; | |
| const annotationLabel = (annotation: Annotation, index: number) => { | |
| if (annotation.label && annotation.label.trim().length > 0) { | |
| return annotation.label; | |
| } | |
| return annotation.type === "rectangle" | |
| ? "Rectangle" | |
| : annotation.type === "polygon" | |
| ? "Polygon" | |
| : annotation.type === "ellipse" | |
| ? "Ellipse" | |
| : "Brush"; | |
| }; | |
| const normalizePoints = (points: Array<{ x: number; y: number }>, size = 24) => { | |
| if (points.length === 0) return []; | |
| const xs = points.map((p) => p.x); | |
| const ys = points.map((p) => p.y); | |
| const minX = Math.min(...xs); | |
| const maxX = Math.max(...xs); | |
| const minY = Math.min(...ys); | |
| const maxY = Math.max(...ys); | |
| const width = Math.max(maxX - minX, 1); | |
| const height = Math.max(maxY - minY, 1); | |
| const padding = 2; | |
| const scale = Math.min((size - padding * 2) / width, (size - padding * 2) / height); | |
| return points.map((p) => ({ | |
| x: (p.x - minX) * scale + padding, | |
| y: (p.y - minY) * scale + padding, | |
| })); | |
| }; | |
| const normalizeBaseUrl = (value: string) => value.replace(/\/$/, ""); | |
| const baseTileUrl = normalizeBaseUrl(tileServerUrl); | |
| const thumbnailSize = 192; | |
| const renderAnnotationPreview = (annotation: Annotation) => { | |
| const size = 24; | |
| const stroke = annotation.color || "#9CA3AF"; | |
| const normalized = normalizePoints(annotation.points, size); | |
| if (annotation.type === "rectangle" && normalized.length >= 2) { | |
| const [p1, p2] = normalized; | |
| const x = Math.min(p1.x, p2.x); | |
| const y = Math.min(p1.y, p2.y); | |
| const width = Math.abs(p2.x - p1.x); | |
| const height = Math.abs(p2.y - p1.y); | |
| return ( | |
| <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> | |
| <rect x={x} y={y} width={width} height={height} fill="none" stroke={stroke} strokeWidth="2" /> | |
| </svg> | |
| ); | |
| } | |
| if (annotation.type === "ellipse" && normalized.length >= 2) { | |
| const [p1, p2] = normalized; | |
| const cx = (p1.x + p2.x) / 2; | |
| const cy = (p1.y + p2.y) / 2; | |
| const rx = Math.abs(p2.x - p1.x) / 2; | |
| const ry = Math.abs(p2.y - p1.y) / 2; | |
| return ( | |
| <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> | |
| <ellipse cx={cx} cy={cy} rx={Math.max(1, rx)} ry={Math.max(1, ry)} fill="none" stroke={stroke} strokeWidth="2" /> | |
| </svg> | |
| ); | |
| } | |
| if ((annotation.type === "polygon" || annotation.type === "brush") && normalized.length >= 2) { | |
| const path = normalized.map((p, idx) => `${idx === 0 ? "M" : "L"}${p.x} ${p.y}`).join(" "); | |
| const closed = annotation.type === "polygon" ? " Z" : ""; | |
| return ( | |
| <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> | |
| <path d={`${path}${closed}`} fill="none" stroke={stroke} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" /> | |
| </svg> | |
| ); | |
| } | |
| return ( | |
| <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}> | |
| <rect x="4" y="4" width="16" height="16" fill="none" stroke={stroke} strokeWidth="2" /> | |
| </svg> | |
| ); | |
| }; | |
| return ( | |
| <aside | |
| className={`bg-gray-800 border-r border-gray-700 flex flex-col py-4 transition-all duration-300 ${ | |
| isCollapsed ? "w-20" : "w-72" | |
| }`} | |
| > | |
| <div className={`flex items-center justify-between px-3 ${isCollapsed ? "mb-2" : "mb-3"}`}> | |
| <div className="text-white text-xs font-semibold"> | |
| {isCollapsed ? "" : "PANEL"} | |
| </div> | |
| <button | |
| onClick={onToggleCollapsed} | |
| className="p-1 rounded hover:bg-gray-700 text-gray-300" | |
| title={isCollapsed ? "Expand panel" : "Collapse panel"} | |
| > | |
| {isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />} | |
| </button> | |
| </div> | |
| <div className={`h-px ${isCollapsed ? "mx-3" : "mx-4"} bg-gray-600 mb-3`} /> | |
| {!isCollapsed && ( | |
| <div className="px-3 mb-3"> | |
| <div className="flex items-center bg-gray-700 rounded-lg p-1 text-[11px]"> | |
| {([ | |
| { id: "tools", label: "Tools" }, | |
| { id: "images", label: "Uploads" }, | |
| { id: "image", label: "Image" }, | |
| { id: "annotations", label: "Annotations" }, | |
| ] as const).map((tab) => ( | |
| <button | |
| key={tab.id} | |
| onClick={() => setActiveTab(tab.id)} | |
| className={`flex-1 px-2 py-1 rounded-md transition-colors ${ | |
| activeTab === tab.id | |
| ? "bg-teal-600 text-white" | |
| : "text-gray-200 hover:bg-gray-600" | |
| }`} | |
| > | |
| {tab.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {(isCollapsed || activeTab === "tools") && ( | |
| <div className="px-3"> | |
| {!isCollapsed && ( | |
| <div className="mb-3"> | |
| <label className="block text-[11px] text-gray-300 mb-1">Annotation label</label> | |
| <select | |
| value={activeLabel} | |
| onChange={(e) => onLabelChange(e.target.value)} | |
| className="w-full text-xs bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1" | |
| > | |
| <option value="Tumor">Tumor</option> | |
| <option value="Benign">Benign</option> | |
| <option value="Stroma">Stroma</option> | |
| <option value="Necrosis">Necrosis</option> | |
| <option value="DCIS">DCIS</option> | |
| <option value="Invasive">Invasive</option> | |
| </select> | |
| </div> | |
| )} | |
| <div className={`grid ${isCollapsed ? "grid-cols-1" : "grid-cols-2"} gap-2`}> | |
| {tools.map((tool) => { | |
| const Icon = tool.icon; | |
| const isSelected = selectedTool === tool.id; | |
| return ( | |
| <button | |
| key={tool.id} | |
| onClick={() => onToolChange(isSelected ? "none" : tool.id)} | |
| className={` | |
| h-14 flex flex-col items-center justify-center rounded-lg | |
| transition-all duration-200 | |
| ${ | |
| isSelected | |
| ? "bg-teal-600 text-white shadow-lg" | |
| : "bg-gray-700 text-gray-300 hover:bg-gray-600 hover:text-white" | |
| } | |
| `} | |
| title={tool.label} | |
| > | |
| <Icon className="w-5 h-5" /> | |
| {!isCollapsed && ( | |
| <span className="text-[10px] mt-1 font-medium">{tool.label}</span> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {!isCollapsed && activeTab === "image" && ( | |
| <div className="mt-2 px-3"> | |
| <div className="text-xs font-semibold text-gray-200 mb-2">Slide metadata</div> | |
| <div className="space-y-2 text-[11px] text-gray-300"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-gray-400">Stain</span> | |
| <span>{imageMeta.stain}</span> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-gray-400">Size</span> | |
| <span> | |
| {imageMeta.width && imageMeta.height | |
| ? `${imageMeta.width} x ${imageMeta.height}` | |
| : "n/a"} | |
| </span> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-gray-400">Levels</span> | |
| <span>{imageMeta.levelCount ?? "n/a"}</span> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-gray-400">MPP</span> | |
| <span>{imageMeta.mpp ? `${imageMeta.mpp.toFixed(3)} µm/px` : "n/a"}</span> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-gray-400">Case / Slide</span> | |
| <span className="text-right max-w-[130px] truncate">{imageMeta.slideId}</span> | |
| </div> | |
| </div> | |
| <div className="mt-4"> | |
| <div className="text-xs font-semibold text-gray-200 mb-2">Channels</div> | |
| <div className="space-y-2 text-[11px] text-gray-300"> | |
| {([ | |
| { id: "original", label: "Original", color: "bg-gray-400" }, | |
| { id: "hematoxylin", label: "Hematoxylin", color: "bg-indigo-500" }, | |
| { id: "eosin", label: "Eosin", color: "bg-pink-500" }, | |
| ] as const).map((channel) => ( | |
| <label | |
| key={channel.id} | |
| className={`flex items-center justify-between rounded px-2 py-1 border border-gray-700 ${ | |
| isTileLoaded ? "hover:bg-gray-700/50" : "opacity-50" | |
| }`} | |
| > | |
| <span className="flex items-center gap-2"> | |
| <span className={`w-3 h-3 rounded-sm ${channel.color}`} /> | |
| {channel.label} | |
| </span> | |
| <input | |
| type="radio" | |
| name="channel" | |
| disabled={!isTileLoaded} | |
| checked={channelVisibility[channel.id]} | |
| onChange={() => { | |
| onChannelToggle("original", channel.id === "original"); | |
| onChannelToggle("hematoxylin", channel.id === "hematoxylin"); | |
| onChannelToggle("eosin", channel.id === "eosin"); | |
| }} | |
| className="accent-teal-500" | |
| /> | |
| </label> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {!isCollapsed && activeTab === "images" && ( | |
| <div className="mt-2 px-3"> | |
| <div className="text-xs font-semibold text-gray-200 mb-2">Uploaded images</div> | |
| <div className="space-y-2 mb-3"> | |
| <label className="block text-[11px] text-gray-300">Tile server URL</label> | |
| <input | |
| value={tileServerUrl} | |
| onChange={(e) => onTileServerUrlChange(e.target.value)} | |
| className="w-full text-xs bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1" | |
| placeholder="http://localhost:8001" | |
| /> | |
| <label className="block text-[11px] text-gray-300">WSI file (SVS/TIFF)</label> | |
| <input | |
| type="file" | |
| accept=".svs,.tif,.tiff" | |
| onChange={(e) => { | |
| const file = e.target.files?.[0] ?? null; | |
| onSlideFileChange(file); | |
| }} | |
| className="w-full text-xs bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1" | |
| /> | |
| {slideFileName && ( | |
| <p className="text-[11px] text-gray-400">Selected: {slideFileName}</p> | |
| )} | |
| <button | |
| onClick={onUploadSlide} | |
| disabled={isTileLoading} | |
| className="w-full bg-teal-600 text-white text-xs font-semibold py-1.5 rounded hover:bg-teal-700 disabled:opacity-60" | |
| > | |
| {isTileLoading ? "Uploading slide..." : "Load Image"} | |
| </button> | |
| {tileLoadError && ( | |
| <p className="text-[11px] text-red-400">{tileLoadError}</p> | |
| )} | |
| </div> | |
| <div className="text-[11px] text-gray-400 mb-2"> | |
| {uploadedSlides.length} total | |
| </div> | |
| <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2"> | |
| {uploadedSlides.length === 0 && ( | |
| <div className="text-[11px] text-gray-500 bg-gray-700/40 rounded p-2"> | |
| No uploads yet | |
| </div> | |
| )} | |
| {uploadedSlides.map((slide) => ( | |
| <button | |
| key={slide.id} | |
| type="button" | |
| onClick={() => onSelectUploadedSlide(slide.id)} | |
| className={`w-full rounded border px-2 py-2 text-[11px] transition-colors h-[88px] ${ | |
| slide.id === imageMeta.slideId | |
| ? "bg-teal-600/20 border-teal-500 text-teal-100" | |
| : "bg-gray-700/40 border-gray-700 text-gray-200" | |
| }`} | |
| > | |
| <div className="flex items-center gap-3 h-full"> | |
| <div className="w-16 h-16 rounded bg-white border border-gray-700 overflow-hidden flex-shrink-0"> | |
| <img | |
| src={`${baseTileUrl}/slides/${slide.id}/thumbnail?size=${thumbnailSize}&channel=original`} | |
| alt={`WSI preview ${slide.id}`} | |
| className="w-full h-full object-contain" | |
| loading="lazy" | |
| /> | |
| </div> | |
| <div className="min-w-0 text-left"> | |
| <div className="text-xs font-semibold truncate text-left">{slide.name}</div> | |
| <div className="text-[10px] text-gray-400 truncate text-left">{slide.uploadedAt}</div> | |
| <div className="text-[10px] text-gray-400 truncate text-left">{slide.id}</div> | |
| </div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {!isCollapsed && activeTab === "annotations" && ( | |
| <div className="mt-4 px-3"> | |
| <div className="text-xs font-semibold text-gray-200 mb-2">Annotations</div> | |
| <div className="text-[11px] text-gray-400 mb-2"> | |
| {annotations.length} total | |
| </div> | |
| <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-1"> | |
| {annotations.length === 0 && ( | |
| <div className="text-[11px] text-gray-500 bg-gray-700/40 rounded p-2"> | |
| No annotations yet | |
| </div> | |
| )} | |
| {annotations.map((annotation, index) => { | |
| const isActive = annotation.id === selectedAnnotationId; | |
| const label = annotationLabel(annotation, index); | |
| return ( | |
| <button | |
| key={annotation.id} | |
| onClick={() => onSelectAnnotation(annotation.id)} | |
| className={`w-full text-left px-2 py-2 rounded border transition-colors ${ | |
| isActive | |
| ? "bg-teal-600/20 border-teal-500 text-teal-100" | |
| : "bg-gray-700/40 border-gray-700 text-gray-200 hover:bg-gray-700" | |
| }`} | |
| title={annotation.id} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="w-7 h-7 flex items-center justify-center rounded bg-gray-800/60 border border-gray-700"> | |
| {renderAnnotationPreview(annotation)} | |
| </div> | |
| <span className="text-xs font-semibold truncate text-left flex-1">{label}</span> | |
| <span className="text-[10px] text-gray-400"> | |
| {annotation.type === "rectangle" | |
| ? "Rect" | |
| : annotation.type === "polygon" | |
| ? "Poly" | |
| : annotation.type === "ellipse" | |
| ? "Ell" | |
| : "Brush"} | |
| </span> | |
| </div> | |
| <div className="text-[10px] text-gray-400 truncate"> | |
| {annotation.id} | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex-1" /> | |
| </aside> | |
| ); | |
| } | |