mihirmithani
Phase 4
4ce5238
Raw History Blame Contribute Delete
22.5 kB
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useAppStore, useImageStore, useUIStore } from '@/store'
import { KernelSelector } from '@/components/kernel/KernelSelector'
import { PlaybackControls } from '@/components/controls/PlaybackControls'
import { OverlayControls, PaddingControls, KernelInfoPanel } from '@/components/controls/OverlayControls'
import { ConvolutionCanvas } from '@/components/canvas/ConvolutionCanvas'
import { StaticConvolutionView } from '@/components/canvas/StaticConvolutionView'
import { SplitView } from '@/components/canvas/SplitView'
import { ImageCanvas } from '@/components/canvas/ImageCanvas'
import { Button, Card, CardContent, Badge } from '@/components/ui'
import { useImageLoader, useDragAndDrop } from '@/hooks/useImageLoader'
import { useConvolution } from '@/hooks/useConvolution'
import { cn, formatNumber } from '@/utils/helpers'
import {
Upload,
Image as ImageIcon,
Download,
RotateCcw,
HelpCircle,
Settings,
ChevronLeft,
ChevronRight,
Keyboard,
} from 'lucide-react'
export default function Home() {
const {
originalImageData,
processedImageData,
selectedKernel,
convolution,
settings,
isLoading,
error,
showHelp,
showSettings,
setOriginalImage,
setError,
toggleHelp,
toggleSettings,
resetConvolution,
} = useAppStore()
const { runConvolution, pause, resume, stop, step, reset, isRunning, isPaused, canRun, toggleStepMode } = useConvolution()
const { fileInputRef, openFileDialog, handleFileSelect } = useImageLoader()
const { handleDragOver, handleDragLeave, handleDrop, isDragging } = useDragAndDrop((image, imageData) => {
setOriginalImage(image, imageData)
resetConvolution()
})
const [showComparison, setShowComparison] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(320)
const [inspectedPosition, setInspectedPosition] = useState<{ x: number; y: number } | null>(null)
const [showShortcuts, setShowShortcuts] = useState(false)
const handleInspectClick = useCallback((x: number, y: number) => {
setInspectedPosition({ x, y })
}, [])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.code) {
case 'Space':
e.preventDefault()
if (isRunning) pause()
else if (canRun) runConvolution()
else if (isPaused) resume()
break
case 'ArrowRight':
e.preventDefault()
step()
break
case 'ArrowLeft':
e.preventDefault()
// Step back not implemented yet
break
case 'Equal':
case 'NumpadAdd':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const newZoom = Math.min(20, settings.zoomLevel * 1.2)
useAppStore.getState().setZoomLevel(newZoom)
}
break
case 'Minus':
case 'NumpadSubtract':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const newZoom = Math.max(0.1, settings.zoomLevel / 1.2)
useAppStore.getState().setZoomLevel(newZoom)
}
break
case 'Digit0':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
useAppStore.getState().setZoomLevel(1)
useAppStore.getState().setPanOffset({ x: 0, y: 0 })
}
break
case 'KeyR':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
reset()
resetConvolution()
}
break
case 'KeyS':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
toggleStepMode(!settings.stepMode)
}
break
case 'Slash':
if (e.shiftKey) {
e.preventDefault()
setShowShortcuts(!showShortcuts)
}
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isRunning, isPaused, canRun, settings.zoomLevel, settings.stepMode, runConvolution, pause, resume, step, reset, resetConvolution, toggleStepMode])
const handleImageLoad = useCallback((data: { image: HTMLImageElement; imageData: ImageData }) => {
setOriginalImage(data.image, data.imageData)
resetConvolution()
}, [setOriginalImage, resetConvolution])
const handleDownload = useCallback(() => {
if (!processedImageData) return
const canvas = document.createElement('canvas')
canvas.width = processedImageData.width
canvas.height = processedImageData.height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.putImageData(processedImageData, 0, 0)
const link = document.createElement('a')
link.download = 'convolved-image.png'
link.href = canvas.toDataURL('image/png')
link.click()
}, [processedImageData])
const handleReset = useCallback(() => {
reset()
resetConvolution()
}, [reset, resetConvolution])
if (isLoading) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-50">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent" />
</div>
)
}
return (
<div
className="h-screen w-screen flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Header */}
<header className="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-b dark:border-gray-700 z-10">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Kernel Convolution Visualizer
</h1>
<Badge variant="outline" className="text-xs">
Educational Tool
</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleHelp} className="relative">
<HelpCircle className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={toggleSettings}>
<Settings className="h-5 w-5" />
</Button>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex overflow-hidden">
{/* Left Sidebar - Controls */}
<aside
className={cn(
'flex flex-col bg-white dark:bg-gray-800 border-r dark:border-gray-700 overflow-y-auto transition-all duration-200',
'min-w-[280px] max-w-[400px]'
)}
style={{ width: sidebarWidth }}
>
{/* Image Upload */}
<Card className="m-3">
<CardContent className="p-4">
<div
className={cn(
'relative border-2 dashed rounded-lg p-6 text-center transition-colors',
'cursor-pointer',
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-blue-400'
)}
onClick={openFileDialog}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={(e) => handleFileSelect(e, handleImageLoad)}
/>
<ImageIcon className="h-12 w-12 mx-auto text-gray-400 mb-3" />
<p className="text-gray-600 dark:text-gray-400">
{originalImageData ? 'Click to change image' : 'Drag & drop or click to upload'}
</p>
<p className="text-sm text-gray-400 mt-1">PNG, JPG, WebP, BMP</p>
</div>
{originalImageData && (
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
{originalImageData.width} × {originalImageData.height}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4" />
Save
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Kernel Selector */}
<div className="flex-1 m-3 min-h-0">
<KernelSelector />
</div>
{/* Playback Controls */}
<div className="m-3">
<PlaybackControls />
</div>
</aside>
{/* Resizer */}
<div
className="w-1 cursor-col-resize bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 active:bg-blue-600 flex items-center justify-center"
onMouseDown={(e) => {
const startX = e.clientX
const startWidth = sidebarWidth
const onMouseMove = (e: MouseEvent) => {
setSidebarWidth(Math.max(280, Math.min(400, startWidth + (e.clientX - startX))))
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}}
>
<div className="w-px h-8 bg-gray-300 dark:bg-gray-600 rounded" />
</div>
{/* Center - Visualization */}
<section className="flex-1 flex flex-col overflow-hidden p-4 min-w-0">
{originalImageData ? (
<>
{/* Convolution Visualization */}
<div className="flex-1 min-h-0 relative">
<ConvolutionCanvas
width={originalImageData.width}
height={originalImageData.height}
className="w-full h-full"
onInspectClick={handleInspectClick}
/>
</div>
{/* Comparison View Toggle */}
<div className="flex items-center justify-center gap-4 mt-4">
<Button
variant={showComparison ? 'primary' : 'outline'}
onClick={() => setShowComparison(!showComparison)}
>
{showComparison ? <ChevronLeft className="h-4 w-4 mr-2" /> : <ChevronRight className="h-4 w-4 mr-2" />}
{showComparison ? 'Hide Comparison' : 'Show Comparison'}
</Button>
{selectedKernel && (
<Badge variant="secondary">
{selectedKernel.name} ({selectedKernel.size}×{selectedKernel.size})
</Badge>
)}
{convolution.status !== 'idle' && (
<Badge variant={convolution.status === 'completed' ? 'success' : 'default'}>
{convolution.status}
</Badge>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="text-center text-gray-500">
<ImageIcon className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p className="text-lg">Upload an image to begin</p>
<p className="text-sm mt-2">Drag & drop or click the upload area</p>
</div>
</div>
)}
{/* Comparison View */}
{showComparison && processedImageData && (
<div className="mt-4 h-96 flex-1">
<SplitView
width={originalImageData?.width || 800}
height={originalImageData?.height || 600}
className="w-full h-full"
/>
</div>
)}
</section>
{/* Right Sidebar - Info Panels */}
<aside className="w-80 flex flex-col bg-white dark:bg-gray-800 border-l dark:border-gray-700 overflow-y-auto">
<div className="p-4 space-y-4">
{inspectedPosition ? (
<StaticConvolutionView
inspectedPosition={inspectedPosition}
onClearInspection={() => setInspectedPosition(null)}
/>
) : (
<>
<KernelInfoPanel />
<OverlayControls />
<PaddingControls />
</>
)}
</div>
</aside>
</main>
{/* Error Toast */}
{error && (
<div className="fixed bottom-4 right-4 z-50 animate-slide-in">
<div className="bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-md">
<span className="flex-1 text-sm">{error}</span>
<Button variant="ghost" size="sm" onClick={() => setError(null)}>
Dismiss
</Button>
</div>
</div>
)}
{/* Help Modal */}
{showHelp && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold mb-4">How to Use</h2>
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<h3 className="font-medium mb-2">1. Upload an Image</h3>
<p>Drag & drop or click the upload area in the left sidebar.</p>
</div>
<div>
<h3 className="font-medium mb-2">2. Select a Kernel</h3>
<p>Choose from predefined kernels (Blur, Sharpen, Edge Detection, etc.) or create your own custom kernel.</p>
</div>
<div>
<h3 className="font-medium mb-2">3. Watch the Convolution</h3>
<p>Press Play to see the kernel move across the image pixel by pixel. The visualization shows:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Current image patch under the kernel</li>
<li>Element-wise multiplication of kernel and pixel values</li>
<li>Summation that produces the output pixel</li>
</ul>
</div>
<div>
<h3 className="font-medium mb-2">4. Control Playback</h3>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Play/Pause/Step through the animation</li>
<li>Adjust animation speed</li>
<li>Zoom and pan for pixel-level inspection</li>
</ul>
</div>
<div>
<h3 className="font-medium mb-2">5. Compare Results</h3>
<p>Click &ldquo;Show Comparison&rdquo; to see side-by-side or split view of original vs processed image.</p>
</div>
<div>
<h3 className="font-medium mb-2">6. Experiment</h3>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Try different padding strategies (Zero, Reflect, Replicate, Wrap)</li>
<li>Toggle overlays to see different visualization aspects</li>
<li>Create custom kernels with the editor</li>
<li>Download the processed image</li>
</ul>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={toggleHelp}>Got it</Button>
</div>
</div>
</div>
</div>
)}
{/* Settings Modal */}
{showSettings && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full mx-4">
<div className="p-6">
<h2 className="text-xl font-bold mb-4">Settings</h2>
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.showOverlay.kernelBoundary}
onChange={() => useAppStore.getState().toggleOverlay('kernelBoundary')}
className="rounded border-gray-300"
/>
<span>Show Kernel Boundary</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.showOverlay.pixelValues}
onChange={() => useAppStore.getState().toggleOverlay('pixelValues')}
className="rounded border-gray-300"
/>
<span>Show Pixel Values</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.showOverlay.multiplications}
onChange={() => useAppStore.getState().toggleOverlay('multiplications')}
className="rounded border-gray-300"
/>
<span>Show Multiplications</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.showOverlay.summation}
onChange={() => useAppStore.getState().toggleOverlay('summation')}
className="rounded border-gray-300"
/>
<span>Show Summation</span>
</label>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={toggleSettings}>Close</Button>
</div>
</div>
</div>
</div>
)}
{/* Shortcuts Modal */}
{showShortcuts && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<Keyboard className="h-5 w-5" />
Keyboard Shortcuts
</h2>
<Button variant="ghost" size="icon" onClick={() => setShowShortcuts(false)}>
<ChevronLeft className="h-5 w-5" />
</Button>
</div>
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded">
<h3 className="font-medium mb-2">Playback</h3>
<dl className="grid grid-cols-2 gap-2 text-xs">
<dt className="font-mono text-blue-600 dark:text-blue-400">Space</dt>
<dd>Play / Pause / Resume</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">→</dt>
<dd>Step Forward</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">←</dt>
<dd>Step Backward (future)</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+S</dt>
<dd>Toggle Step Mode</dd>
</dl>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded">
<h3 className="font-medium mb-2">Zoom & Pan</h3>
<dl className="grid grid-cols-2 gap-2 text-xs">
<dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl++ / =</dt>
<dd>Zoom In</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+-</dt>
<dd>Zoom Out</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+0</dt>
<dd>Reset Zoom & Pan</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">Mouse Wheel</dt>
<dd>Zoom at Cursor</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">Drag</dt>
<dd>Pan Image</dd>
</dl>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded">
<h3 className="font-medium mb-2">General</h3>
<dl className="grid grid-cols-2 gap-2 text-xs">
<dt className="font-mono text-blue-600 dark:text-blue-400">Ctrl+R</dt>
<dd>Reset All</dd>
<dt className="font-mono text-blue-600 dark:text-blue-400">Shift+/</dt>
<dd>Show This Help</dd>
</dl>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
<p className="text-xs text-blue-800 dark:text-blue-300">
<strong>Tip:</strong> Shortcuts don't work when typing in input fields. Click canvas to focus.
</p>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={() => setShowShortcuts(false)}>Got it</Button>
</div>
</div>
</div>
</div>
)}
</div>
)
}