| import { useState, useRef } from 'react'; |
| import { ArrowLeft, X, ZoomIn, ZoomOut, Download, RotateCcw } from 'lucide-react'; |
| import { sessionStore } from '../store/sessionStore'; |
|
|
| type CapturedImage = { |
| id: string; |
| src: string; |
| stepId: string; |
| type: 'image' | 'video'; |
| }; |
|
|
| type CompareImage = { |
| id: string; |
| src: string; |
| stepId: string; |
| label: string; |
| }; |
|
|
| type Props = { |
| onBack: () => void; |
| onNext: () => void; |
| capturedImages: CapturedImage[]; |
| }; |
|
|
| const stepLabels: Record<string, string> = { |
| native: 'Native', |
| acetowhite: 'Acetic Acid', |
| greenFilter: 'Green Filter', |
| lugol: 'Lugol' |
| }; |
|
|
| const stepColors: Record<string, string> = { |
| native: 'from-blue-500 to-blue-600', |
| acetowhite: 'from-purple-500 to-purple-600', |
| greenFilter: 'from-green-500 to-green-600', |
| lugol: 'from-amber-500 to-amber-600' |
| }; |
|
|
| export function Compare({ onBack, onNext, capturedImages }: Props) { |
| const [leftImage, setLeftImage] = useState<CompareImage | null>(null); |
| const [rightImage, setRightImage] = useState<CompareImage | null>(null); |
| const [additionalNotes, setAdditionalNotes] = useState(''); |
| const [zoomLevel, setZoomLevel] = useState(1); |
| const [draggedImageData, setDraggedImageData] = useState<string | null>(null); |
| const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); |
| const [isPanning, setIsPanning] = useState(false); |
| const [panStart, setPanStart] = useState({ x: 0, y: 0 }); |
| const leftImageRef = useRef<HTMLDivElement>(null); |
| const rightImageRef = useRef<HTMLDivElement>(null); |
| const patientId = sessionStore.get().patientInfo?.id; |
|
|
| |
| const imagesByStep = capturedImages.reduce((acc, img) => { |
| if (img.type === 'image') { |
| if (!acc[img.stepId]) acc[img.stepId] = []; |
| acc[img.stepId].push(img); |
| } |
| return acc; |
| }, {} as Record<string, CapturedImage[]>); |
|
|
| const handleImageDragStart = (e: React.DragEvent, image: CapturedImage, side: 'left' | 'right') => { |
| const data = JSON.stringify({ ...image, side }); |
| e.dataTransfer.setData('application/compare-image', data); |
| e.dataTransfer.effectAllowed = 'copy'; |
| setDraggedImageData(data); |
| }; |
|
|
| const handleImageDragOver = (e: React.DragEvent) => { |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = 'copy'; |
| }; |
|
|
| const handleImageDrop = (e: React.DragEvent, side: 'left' | 'right') => { |
| e.preventDefault(); |
| const data = e.dataTransfer.getData('application/compare-image'); |
| if (data) { |
| try { |
| const image = JSON.parse(data); |
| const compareImage: CompareImage = { |
| id: image.id, |
| src: image.src, |
| stepId: image.stepId, |
| label: stepLabels[image.stepId] || image.stepId |
| }; |
| if (side === 'left') { |
| setLeftImage(compareImage); |
| } else { |
| setRightImage(compareImage); |
| } |
| } catch { |
| |
| } |
| } |
| setDraggedImageData(null); |
| }; |
|
|
| const handleDownloadComparison = () => { |
| if (!leftImage || !rightImage) return; |
|
|
| |
| const canvas = document.createElement('canvas'); |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) return; |
|
|
| |
| canvas.width = 1600; |
| canvas.height = 800; |
|
|
| |
| const leftImg = new Image(); |
| const rightImg = new Image(); |
| let imagesLoaded = 0; |
|
|
| const drawComparison = () => { |
| |
| ctx.drawImage(leftImg, 0, 0, 800, 800); |
| |
| |
| ctx.drawImage(rightImg, 800, 0, 800, 800); |
|
|
| |
| ctx.fillStyle = 'white'; |
| ctx.font = 'bold 28px Arial'; |
| ctx.fillText(leftImage.label, 20, 750); |
| ctx.fillText(rightImage.label, 820, 750); |
|
|
| |
| canvas.toBlob(blob => { |
| if (blob) { |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `comparison_${Date.now()}.png`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| } |
| }); |
| }; |
|
|
| leftImg.onload = () => { |
| imagesLoaded++; |
| if (imagesLoaded === 2) drawComparison(); |
| }; |
| rightImg.onload = () => { |
| imagesLoaded++; |
| if (imagesLoaded === 2) drawComparison(); |
| }; |
|
|
| leftImg.src = leftImage.src; |
| rightImg.src = rightImage.src; |
| }; |
|
|
| const handleMouseDown = (e: React.MouseEvent) => { |
| if (zoomLevel > 1) { |
| setIsPanning(true); |
| setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }); |
| } |
| }; |
|
|
| const handleMouseMove = (e: React.MouseEvent) => { |
| if (isPanning && zoomLevel > 1) { |
| setPanOffset({ |
| x: e.clientX - panStart.x, |
| y: e.clientY - panStart.y |
| }); |
| } |
| }; |
|
|
| const handleMouseUp = () => { |
| setIsPanning(false); |
| }; |
|
|
| const handleWheel = (e: React.WheelEvent) => { |
| e.preventDefault(); |
| const delta = e.deltaY > 0 ? -0.1 : 0.1; |
| setZoomLevel(prev => Math.max(0.5, Math.min(3, prev + delta))); |
| }; |
|
|
| const handleReset = () => { |
| setZoomLevel(1); |
| setPanOffset({ x: 0, y: 0 }); |
| }; |
|
|
| return ( |
| <div className="w-full bg-white"> |
| <div className="py-4 md:py-6"> |
| <div className="w-full max-w-7xl mx-auto px-4 md:px-6"> |
| |
| {/* Header */} |
| <div className="flex items-center justify-between mb-6"> |
| <div className="flex items-center gap-4"> |
| <button |
| onClick={onBack} |
| className="p-2 hover:bg-gray-100 rounded-lg transition-colors" |
| > |
| <ArrowLeft className="w-5 h-5 text-gray-700" /> |
| </button> |
| <h1 className="text-2xl font-bold text-gray-900">Image Comparison</h1> |
| {patientId && ( |
| <div className="flex items-center gap-2 px-4 py-1.5 bg-purple-50 border border-purple-200 rounded-full text-sm font-mono font-semibold text-[#0A2540]"> |
| <span>ID:</span> |
| <span>{patientId}</span> |
| </div> |
| )} |
| </div> |
| <div className="flex items-center gap-3"> |
| <button |
| onClick={handleDownloadComparison} |
| disabled={!leftImage || !rightImage} |
| className="flex items-center gap-2 px-6 py-3 bg-[#05998c] text-white rounded-lg font-semibold hover:bg-[#047569] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" |
| > |
| <Download className="w-4 h-4" /> |
| Download Comparison |
| </button> |
| <button |
| onClick={onNext} |
| className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg font-semibold hover:bg-slate-700 transition-colors" |
| > |
| Next |
| <ArrowLeft className="w-4 h-4 transform rotate-180" /> |
| </button> |
| </div> |
| </div> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| |
| {/* Left Comparison View */} |
| <div className="lg:col-span-2"> |
| <div className="grid grid-cols-2 gap-6"> |
| {/* Left Side */} |
| <div |
| onDragOver={handleImageDragOver} |
| onDrop={(e) => handleImageDrop(e, 'left')} |
| className={`rounded-lg border-4 border-dashed transition-all ${ |
| draggedImageData ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50' |
| }`} |
| > |
| {leftImage ? ( |
| <div |
| ref={leftImageRef} |
| className="relative h-full flex items-center justify-center bg-black rounded-lg overflow-hidden" |
| onMouseDown={handleMouseDown} |
| onMouseMove={handleMouseMove} |
| onMouseUp={handleMouseUp} |
| onMouseLeave={handleMouseUp} |
| onWheel={handleWheel} |
| style={{ cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }} |
| > |
| <img |
| src={leftImage.src} |
| alt={leftImage.label} |
| style={{ |
| transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`, |
| transformOrigin: 'center center' |
| }} |
| className="w-full h-96 object-contain transition-transform select-none" |
| draggable={false} |
| /> |
| <button |
| onClick={() => setLeftImage(null)} |
| className="absolute top-3 right-3 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors z-10" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| <div className="absolute bottom-3 left-3 bg-black/70 text-white px-3 py-2 rounded-lg z-10"> |
| <p className="text-sm font-semibold">{leftImage.label}</p> |
| </div> |
| </div> |
| ) : ( |
| <div className="h-96 flex flex-col items-center justify-center text-gray-500"> |
| <div className="text-center"> |
| <p className="text-lg font-semibold mb-2">Left Image</p> |
| <p className="text-sm">Drag and drop an image here</p> |
| </div> |
| </div> |
| )} |
| </div> |
| |
| {/* Right Side */} |
| <div |
| onDragOver={handleImageDragOver} |
| onDrop={(e) => handleImageDrop(e, 'right')} |
| className={`rounded-lg border-4 border-dashed transition-all ${ |
| draggedImageData ? 'border-green-500 bg-green-50' : 'border-gray-300 bg-gray-50' |
| }`} |
| > |
| {rightImage ? ( |
| <div |
| ref={rightImageRef} |
| className="relative h-full flex items-center justify-center bg-black rounded-lg overflow-hidden" |
| onMouseDown={handleMouseDown} |
| onMouseMove={handleMouseMove} |
| onMouseUp={handleMouseUp} |
| onMouseLeave={handleMouseUp} |
| onWheel={handleWheel} |
| style={{ cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }} |
| > |
| <img |
| src={rightImage.src} |
| alt={rightImage.label} |
| style={{ |
| transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`, |
| transformOrigin: 'center center' |
| }} |
| className="w-full h-96 object-contain transition-transform select-none" |
| draggable={false} |
| /> |
| <button |
| onClick={() => setRightImage(null)} |
| className="absolute top-3 right-3 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors z-10" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| <div className="absolute bottom-3 left-3 bg-black/70 text-white px-3 py-2 rounded-lg z-10"> |
| <p className="text-sm font-semibold">{rightImage.label}</p> |
| </div> |
| </div> |
| ) : ( |
| <div className="h-96 flex flex-col items-center justify-center text-gray-500"> |
| <div className="text-center"> |
| <p className="text-lg font-semibold mb-2">Right Image</p> |
| <p className="text-sm">Drag and drop an image here</p> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| {/* Zoom Controls */} |
| {(leftImage || rightImage) && ( |
| <div className="mt-6 flex items-center justify-center gap-4 bg-gray-50 p-4 rounded-lg"> |
| <button |
| onClick={() => setZoomLevel(prev => Math.max(0.5, prev - 0.1))} |
| className="p-2 hover:bg-gray-200 rounded-lg transition-colors" |
| title="Zoom Out" |
| > |
| <ZoomOut className="w-5 h-5 text-gray-700" /> |
| </button> |
| <span className="text-sm font-semibold text-gray-700 w-16 text-center"> |
| {Math.round(zoomLevel * 100)}% |
| </span> |
| <button |
| onClick={() => setZoomLevel(prev => Math.min(3, prev + 0.1))} |
| className="p-2 hover:bg-gray-200 rounded-lg transition-colors" |
| title="Zoom In" |
| > |
| <ZoomIn className="w-5 h-5 text-gray-700" /> |
| </button> |
| <div className="flex-1 ml-4"> |
| <input |
| type="range" |
| min="0.5" |
| max="3" |
| step="0.1" |
| value={zoomLevel} |
| onChange={(e) => setZoomLevel(parseFloat(e.target.value))} |
| className="w-full" |
| /> |
| </div> |
| <button |
| onClick={handleReset} |
| className="p-2 hover:bg-gray-200 rounded-lg transition-colors" |
| title="Reset Zoom & Pan" |
| > |
| <RotateCcw className="w-5 h-5 text-gray-700" /> |
| </button> |
| </div> |
| )} |
| |
| {/* Instructions */} |
| {(leftImage || rightImage) && zoomLevel > 1 && ( |
| <div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3"> |
| <p className="text-xs text-blue-700"> |
| 🖱️ <strong>Click and drag</strong> to pan the image | <strong>Scroll</strong> to zoom | Use controls to fine-tune |
| </p> |
| </div> |
| )} |
| </div> |
| |
| {/* Image Library Sidebar */} |
| <div className="lg:col-span-1"> |
| <div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4 sticky top-20"> |
| <h2 className="font-bold text-gray-900 mb-4 pb-2 border-b border-gray-300"> |
| Image Library |
| </h2> |
| |
| {Object.entries(imagesByStep).length === 0 ? ( |
| <div className="text-center py-8 text-gray-400"> |
| <p className="text-sm font-medium">No images available</p> |
| <p className="text-xs mt-1">Capture images to compare</p> |
| </div> |
| ) : ( |
| <div className="space-y-4 max-h-[600px] overflow-y-auto"> |
| {Object.entries(imagesByStep).map(([stepId, images]) => ( |
| <div key={stepId} className={`bg-gradient-to-br ${stepColors[stepId] || 'from-gray-500 to-gray-600'} rounded-lg overflow-hidden shadow-sm`}> |
| <div className="bg-black/30 px-3 py-2"> |
| <p className="text-white font-bold text-sm">{stepLabels[stepId] || stepId}</p> |
| </div> |
| <div className="p-3 space-y-2 bg-white"> |
| {images.map((image, idx) => ( |
| <div |
| key={image.id} |
| draggable |
| onDragStart={(e) => handleImageDragStart(e, image, 'left')} |
| className="relative group cursor-grab hover:cursor-grabbing" |
| > |
| <div className="bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-200 hover:border-[#05998c] transition-colors"> |
| <img |
| src={image.src} |
| alt={`${stepLabels[stepId]} ${idx + 1}`} |
| className="w-full h-20 object-cover group-hover:opacity-80 transition-opacity" |
| /> |
| <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100"> |
| <p className="text-white text-xs font-semibold">Drag to compare</p> |
| </div> |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| <div className="mt-4 pt-4 border-t border-gray-200"> |
| <label className="block text-sm font-semibold text-gray-700 mb-2"> |
| Additional Notes |
| </label> |
| <textarea |
| value={additionalNotes} |
| onChange={(e) => setAdditionalNotes(e.target.value)} |
| rows={4} |
| placeholder="Add comparison notes..." |
| className="w-full px-3 py-2 text-sm bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent outline-none transition-all resize-none" |
| /> |
| </div> |
| |
| {/* Info Box */} |
| <div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3"> |
| <p className="text-xs text-blue-700"> |
| 💡 Drag images from the library to left or right side to compare them side by side. |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|