mihirmithani commited on
Commit ·
94f3ed9
1
Parent(s): f3aa2b6
Phase 3
Browse files- src/components/canvas/ConvolutionCanvas.tsx +74 -5
- src/components/controls/OverlayControls.tsx +142 -4
- src/components/kernel/KernelSelector.tsx +211 -21
- src/hooks/useConvolution.ts +24 -3
- src/store/index.ts +21 -20
- updates.md +41 -1
src/components/canvas/ConvolutionCanvas.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useRef, useEffect, useCallback,
|
| 4 |
-
import { useAppStore,
|
| 5 |
import type { ConvolutionStep } from '@/types'
|
| 6 |
import { cn } from '@/utils/helpers'
|
| 7 |
|
|
@@ -15,11 +15,76 @@ interface ConvolutionCanvasProps {
|
|
| 15 |
export function ConvolutionCanvas({ width = 800, height = 600, className, onInspectClick }: ConvolutionCanvasProps) {
|
| 16 |
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 17 |
const animationRef = useRef<number | null>(null)
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const { originalImageData, processedImageData, convolution } = useAppStore()
|
| 20 |
-
const { showOverlay, zoomLevel, panOffset,
|
| 21 |
const currentStep = convolution.currentStep
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const drawCanvas = useCallback(() => {
|
| 24 |
const canvas = canvasRef.current
|
| 25 |
const ctx = canvas?.getContext('2d', { willReadFrequently: true })
|
|
@@ -91,7 +156,6 @@ export function ConvolutionCanvas({ width = 800, height = 600, className, onInsp
|
|
| 91 |
}
|
| 92 |
}, [
|
| 93 |
originalImageData,
|
| 94 |
-
processedImageData,
|
| 95 |
currentStep,
|
| 96 |
convolution.status,
|
| 97 |
convolution.progress,
|
|
@@ -142,9 +206,14 @@ export function ConvolutionCanvas({ width = 800, height = 600, className, onInsp
|
|
| 142 |
ref={canvasRef}
|
| 143 |
width={width}
|
| 144 |
height={height}
|
| 145 |
-
className="block cursor-
|
| 146 |
style={{ touchAction: 'none' }}
|
| 147 |
onClick={onInspectClick ? handleCanvasClick : undefined}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
/>
|
| 149 |
</div>
|
| 150 |
)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useRef, useEffect, useCallback, useState } from 'react'
|
| 4 |
+
import { useAppStore, useSettingsStore } from '@/store'
|
| 5 |
import type { ConvolutionStep } from '@/types'
|
| 6 |
import { cn } from '@/utils/helpers'
|
| 7 |
|
|
|
|
| 15 |
export function ConvolutionCanvas({ width = 800, height = 600, className, onInspectClick }: ConvolutionCanvasProps) {
|
| 16 |
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 17 |
const animationRef = useRef<number | null>(null)
|
| 18 |
+
const isDraggingRef = useRef(false)
|
| 19 |
+
const dragStartRef = useRef({ x: 0, y: 0 })
|
| 20 |
|
| 21 |
const { originalImageData, processedImageData, convolution } = useAppStore()
|
| 22 |
+
const { showOverlay, zoomLevel, panOffset, setZoomLevel, setPanOffset } = useSettingsStore()
|
| 23 |
const currentStep = convolution.currentStep
|
| 24 |
|
| 25 |
+
// Handle mouse wheel zoom
|
| 26 |
+
const handleWheel = useCallback(
|
| 27 |
+
(e: React.WheelEvent<HTMLCanvasElement>) => {
|
| 28 |
+
e.preventDefault()
|
| 29 |
+
const rect = e.currentTarget.getBoundingClientRect()
|
| 30 |
+
const canvasX = e.clientX - rect.left
|
| 31 |
+
const canvasY = e.clientY - rect.top
|
| 32 |
+
|
| 33 |
+
// Convert to image coordinates before zoom
|
| 34 |
+
const imageX = (canvasX - panOffset.x) / zoomLevel
|
| 35 |
+
const imageY = (canvasY - panOffset.y) / zoomLevel
|
| 36 |
+
|
| 37 |
+
// Calculate new zoom level
|
| 38 |
+
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1
|
| 39 |
+
const newZoomLevel = Math.max(0.1, Math.min(20, zoomLevel * zoomFactor))
|
| 40 |
+
|
| 41 |
+
// Calculate new pan offset to keep cursor position fixed on image
|
| 42 |
+
const newPanX = canvasX - imageX * newZoomLevel
|
| 43 |
+
const newPanY = canvasY - imageY * newZoomLevel
|
| 44 |
+
|
| 45 |
+
setZoomLevel(newZoomLevel)
|
| 46 |
+
setPanOffset({ x: newPanX, y: newPanY })
|
| 47 |
+
},
|
| 48 |
+
[panOffset, zoomLevel, setZoomLevel, setPanOffset]
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
// Handle drag to pan
|
| 52 |
+
const handleMouseDown = useCallback(
|
| 53 |
+
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
| 54 |
+
isDraggingRef.current = true
|
| 55 |
+
dragStartRef.current = {
|
| 56 |
+
x: e.clientX - panOffset.x,
|
| 57 |
+
y: e.clientY - panOffset.y,
|
| 58 |
+
}
|
| 59 |
+
e.currentTarget.style.cursor = 'grabbing'
|
| 60 |
+
},
|
| 61 |
+
[panOffset]
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
const handleMouseMove = useCallback(
|
| 65 |
+
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
| 66 |
+
if (!isDraggingRef.current) return
|
| 67 |
+
const newPanX = e.clientX - dragStartRef.current.x
|
| 68 |
+
const newPanY = e.clientY - dragStartRef.current.y
|
| 69 |
+
setPanOffset({ x: newPanX, y: newPanY })
|
| 70 |
+
},
|
| 71 |
+
[setPanOffset]
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
const handleMouseUp = useCallback(() => {
|
| 75 |
+
isDraggingRef.current = false
|
| 76 |
+
if (canvasRef.current) {
|
| 77 |
+
canvasRef.current.style.cursor = 'grab'
|
| 78 |
+
}
|
| 79 |
+
}, [])
|
| 80 |
+
|
| 81 |
+
const handleMouseLeave = useCallback(() => {
|
| 82 |
+
isDraggingRef.current = false
|
| 83 |
+
if (canvasRef.current) {
|
| 84 |
+
canvasRef.current.style.cursor = 'grab'
|
| 85 |
+
}
|
| 86 |
+
}, [])
|
| 87 |
+
|
| 88 |
const drawCanvas = useCallback(() => {
|
| 89 |
const canvas = canvasRef.current
|
| 90 |
const ctx = canvas?.getContext('2d', { willReadFrequently: true })
|
|
|
|
| 156 |
}
|
| 157 |
}, [
|
| 158 |
originalImageData,
|
|
|
|
| 159 |
currentStep,
|
| 160 |
convolution.status,
|
| 161 |
convolution.progress,
|
|
|
|
| 206 |
ref={canvasRef}
|
| 207 |
width={width}
|
| 208 |
height={height}
|
| 209 |
+
className="block cursor-grab"
|
| 210 |
style={{ touchAction: 'none' }}
|
| 211 |
onClick={onInspectClick ? handleCanvasClick : undefined}
|
| 212 |
+
onWheel={handleWheel}
|
| 213 |
+
onMouseDown={handleMouseDown}
|
| 214 |
+
onMouseMove={handleMouseMove}
|
| 215 |
+
onMouseUp={handleMouseUp}
|
| 216 |
+
onMouseLeave={handleMouseLeave}
|
| 217 |
/>
|
| 218 |
</div>
|
| 219 |
)
|
src/components/controls/OverlayControls.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import { useAppStore } from '@/store'
|
| 4 |
import { PADDING_MODES } from '@/lib/kernels'
|
| 5 |
-
import { Select, Card, CardContent
|
| 6 |
import { cn } from '@/utils/helpers'
|
| 7 |
-
import { Settings, Layers, Grid } from 'lucide-react'
|
| 8 |
|
| 9 |
export function OverlayControls() {
|
| 10 |
const { settings, updateSettings, toggleOverlay, setPadding } = useAppStore()
|
|
@@ -37,7 +38,7 @@ export function OverlayControls() {
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export function PaddingControls() {
|
| 40 |
-
const { settings, setPadding } = useAppStore()
|
| 41 |
const { padding } = settings
|
| 42 |
|
| 43 |
return (
|
|
@@ -59,12 +60,149 @@ export function PaddingControls() {
|
|
| 59 |
{PADDING_MODES.find((p) => p.value === padding)?.description}
|
| 60 |
</div>
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</CardContent>
|
| 64 |
</Card>
|
| 65 |
)
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
export function KernelInfoPanel() {
|
| 69 |
const { selectedKernel } = useAppStore()
|
| 70 |
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useRef, useEffect } from 'react'
|
| 4 |
import { useAppStore } from '@/store'
|
| 5 |
import { PADDING_MODES } from '@/lib/kernels'
|
| 6 |
+
import { Select, Card, CardContent } from '@/components/ui'
|
| 7 |
import { cn } from '@/utils/helpers'
|
| 8 |
+
import { Settings, Layers, Grid, ZoomIn, ZoomOut } from 'lucide-react'
|
| 9 |
|
| 10 |
export function OverlayControls() {
|
| 11 |
const { settings, updateSettings, toggleOverlay, setPadding } = useAppStore()
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
export function PaddingControls() {
|
| 41 |
+
const { settings, setPadding, originalImageData, selectedKernel } = useAppStore()
|
| 42 |
const { padding } = settings
|
| 43 |
|
| 44 |
return (
|
|
|
|
| 60 |
{PADDING_MODES.find((p) => p.value === padding)?.description}
|
| 61 |
</div>
|
| 62 |
|
| 63 |
+
<PaddingVisualization
|
| 64 |
+
imageData={originalImageData}
|
| 65 |
+
padding={padding}
|
| 66 |
+
kernelSize={selectedKernel?.size ?? 3}
|
| 67 |
+
/>
|
| 68 |
</CardContent>
|
| 69 |
</Card>
|
| 70 |
)
|
| 71 |
}
|
| 72 |
|
| 73 |
+
function PaddingVisualization({
|
| 74 |
+
imageData,
|
| 75 |
+
padding,
|
| 76 |
+
kernelSize
|
| 77 |
+
}: {
|
| 78 |
+
imageData: ImageData | null
|
| 79 |
+
padding: 'zero' | 'reflect' | 'replicate' | 'wrap'
|
| 80 |
+
kernelSize: number
|
| 81 |
+
}) {
|
| 82 |
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 83 |
+
const halfKernel = Math.floor(kernelSize / 2)
|
| 84 |
+
const previewSize = 120
|
| 85 |
+
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
if (!imageData || !canvasRef.current) return
|
| 88 |
+
|
| 89 |
+
const canvas = canvasRef.current
|
| 90 |
+
const ctx = canvas.getContext('2d')
|
| 91 |
+
if (!ctx) return
|
| 92 |
+
|
| 93 |
+
// Small preview canvas
|
| 94 |
+
canvas.width = previewSize
|
| 95 |
+
canvas.height = previewSize
|
| 96 |
+
ctx.clearRect(0, 0, previewSize, previewSize)
|
| 97 |
+
|
| 98 |
+
const scale = Math.min(previewSize / (imageData.width + 2 * halfKernel), previewSize / (imageData.height + 2 * halfKernel))
|
| 99 |
+
const paddedWidth = (imageData.width + 2 * halfKernel) * scale
|
| 100 |
+
const paddedHeight = (imageData.height + 2 * halfKernel) * scale
|
| 101 |
+
const offsetX = (previewSize - paddedWidth) / 2
|
| 102 |
+
const offsetY = (previewSize - paddedHeight) / 2
|
| 103 |
+
|
| 104 |
+
// Draw padding visualization
|
| 105 |
+
const getPixel = (x: number, y: number, channel: number): number => {
|
| 106 |
+
let px = x
|
| 107 |
+
let py = y
|
| 108 |
+
const { width, height, data } = imageData
|
| 109 |
+
|
| 110 |
+
switch (padding) {
|
| 111 |
+
case 'zero':
|
| 112 |
+
if (px < 0 || px >= width || py < 0 || py >= height) return 0
|
| 113 |
+
break
|
| 114 |
+
case 'replicate':
|
| 115 |
+
px = Math.max(0, Math.min(px, width - 1))
|
| 116 |
+
py = Math.max(0, Math.min(py, height - 1))
|
| 117 |
+
break
|
| 118 |
+
case 'reflect':
|
| 119 |
+
if (px < 0) px = -px
|
| 120 |
+
if (px >= width) px = 2 * width - px - 2
|
| 121 |
+
if (py < 0) py = -py
|
| 122 |
+
if (py >= height) py = 2 * height - py - 2
|
| 123 |
+
px = Math.max(0, Math.min(px, width - 1))
|
| 124 |
+
py = Math.max(0, Math.min(py, height - 1))
|
| 125 |
+
break
|
| 126 |
+
case 'wrap':
|
| 127 |
+
px = ((px % width) + width) % width
|
| 128 |
+
py = ((py % height) + height) % height
|
| 129 |
+
break
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const index = (py * width + px) * 4 + channel
|
| 133 |
+
return data[index]
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Draw padded image
|
| 137 |
+
for (let py = -halfKernel; py < imageData.height + halfKernel; py++) {
|
| 138 |
+
for (let px = -halfKernel; px < imageData.width + halfKernel; px++) {
|
| 139 |
+
const r = getPixel(px, py, 0)
|
| 140 |
+
const g = getPixel(px, py, 1)
|
| 141 |
+
const b = getPixel(px, py, 2)
|
| 142 |
+
|
| 143 |
+
const x = offsetX + (px + halfKernel) * scale
|
| 144 |
+
const y = offsetY + (py + halfKernel) * scale
|
| 145 |
+
|
| 146 |
+
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`
|
| 147 |
+
ctx.fillRect(x, y, Math.ceil(scale), Math.ceil(scale))
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// Draw original image border
|
| 152 |
+
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)'
|
| 153 |
+
ctx.lineWidth = 2
|
| 154 |
+
ctx.setLineDash([4, 2])
|
| 155 |
+
ctx.strokeRect(
|
| 156 |
+
offsetX + halfKernel * scale,
|
| 157 |
+
offsetY + halfKernel * scale,
|
| 158 |
+
imageData.width * scale,
|
| 159 |
+
imageData.height * scale
|
| 160 |
+
)
|
| 161 |
+
ctx.setLineDash([])
|
| 162 |
+
|
| 163 |
+
// Draw kernel center indicator
|
| 164 |
+
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)'
|
| 165 |
+
ctx.beginPath()
|
| 166 |
+
ctx.arc(
|
| 167 |
+
offsetX + halfKernel * scale,
|
| 168 |
+
offsetY + halfKernel * scale,
|
| 169 |
+
4,
|
| 170 |
+
0,
|
| 171 |
+
Math.PI * 2
|
| 172 |
+
)
|
| 173 |
+
ctx.fill()
|
| 174 |
+
|
| 175 |
+
// Draw kernel area outline
|
| 176 |
+
ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)'
|
| 177 |
+
ctx.lineWidth = 1
|
| 178 |
+
ctx.strokeRect(
|
| 179 |
+
offsetX,
|
| 180 |
+
offsetY,
|
| 181 |
+
paddedWidth,
|
| 182 |
+
paddedHeight
|
| 183 |
+
)
|
| 184 |
+
}, [imageData, padding, kernelSize])
|
| 185 |
+
|
| 186 |
+
return (
|
| 187 |
+
<div className="space-y-2">
|
| 188 |
+
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400">Padding Preview</h4>
|
| 189 |
+
<div className="relative">
|
| 190 |
+
<canvas
|
| 191 |
+
ref={canvasRef}
|
| 192 |
+
width={previewSize}
|
| 193 |
+
height={previewSize}
|
| 194 |
+
className="bg-gray-100 dark:bg-gray-800 rounded border dark:border-gray-700"
|
| 195 |
+
/>
|
| 196 |
+
<div className="absolute bottom-1 left-1 right-1 flex justify-between text-[10px] text-gray-400 px-2">
|
| 197 |
+
<span>Original Image</span>
|
| 198 |
+
<span className="text-red-400">Kernel Center</span>
|
| 199 |
+
<span>Padding Area</span>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
)
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
export function KernelInfoPanel() {
|
| 207 |
const { selectedKernel } = useAppStore()
|
| 208 |
|
src/components/kernel/KernelSelector.tsx
CHANGED
|
@@ -1,32 +1,107 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useState } from 'react'
|
| 4 |
import { useAppStore } from '@/store'
|
| 5 |
-
import { PREDEFINED_KERNELS, KERNEL_CATEGORIES, getKernelsByCategory } from '@/lib/kernels'
|
| 6 |
import type { KernelCategory } from '@/lib/kernels'
|
| 7 |
import { Button, Card, CardContent, Badge, Input } from '@/components/ui'
|
| 8 |
import { cn } from '@/utils/helpers'
|
| 9 |
-
import { Search, SlidersHorizontal, Plus } from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
export function KernelSelector() {
|
| 12 |
const { selectedKernel, setSelectedKernel, customKernelMatrix, customKernelSize, customKernelNormalize, setCustomKernel, setCustomKernelSize, setCustomKernelNormalize, updateCustomKernelCell } = useAppStore()
|
| 13 |
const [activeCategory, setActiveCategory] = useState<KernelCategory>('blur')
|
| 14 |
const [searchQuery, setSearchQuery] = useState('')
|
| 15 |
const [showCustom, setShowCustom] = useState(false)
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
)
|
| 23 |
|
| 24 |
-
const
|
| 25 |
-
.
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
const handleKernelSelect = (kernel: typeof PREDEFINED_KERNELS[0]) => {
|
| 32 |
setSelectedKernel(kernel)
|
|
@@ -46,6 +121,61 @@ export function KernelSelector() {
|
|
| 46 |
setSelectedKernel(kernel)
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return (
|
| 50 |
<Card className="w-full max-w-2xl">
|
| 51 |
<div className="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
|
@@ -89,8 +219,21 @@ export function KernelSelector() {
|
|
| 89 |
{/* Custom Kernel Editor */}
|
| 90 |
{showCustom && (
|
| 91 |
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border dark:border-gray-700">
|
| 92 |
-
<
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
<Input
|
| 95 |
type="number"
|
| 96 |
label="Size"
|
|
@@ -110,24 +253,71 @@ export function KernelSelector() {
|
|
| 110 |
/>
|
| 111 |
Normalize
|
| 112 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
<div className="grid gap-1 mb-3" style={{ gridTemplateColumns: `repeat(${customKernelSize}, 1fr)` }}>
|
| 115 |
{Array.from({ length: customKernelSize }, (_, row) =>
|
| 116 |
Array.from({ length: customKernelSize }, (_, col) => (
|
| 117 |
<input
|
| 118 |
key={`${row}-${col}`}
|
|
|
|
| 119 |
type="number"
|
| 120 |
step="0.1"
|
| 121 |
value={customKernelMatrix[row]?.[col] ?? 0}
|
| 122 |
onChange={(e) => updateCustomKernelCell(row, col, parseFloat(e.target.value) || 0)}
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
/>
|
| 125 |
))
|
| 126 |
)}
|
| 127 |
</div>
|
| 128 |
-
<
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
)}
|
| 133 |
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
| 4 |
import { useAppStore } from '@/store'
|
| 5 |
+
import { PREDEFINED_KERNELS, KERNEL_CATEGORIES, getKernelsByCategory, normalizeKernel } from '@/lib/kernels'
|
| 6 |
import type { KernelCategory } from '@/lib/kernels'
|
| 7 |
import { Button, Card, CardContent, Badge, Input } from '@/components/ui'
|
| 8 |
import { cn } from '@/utils/helpers'
|
| 9 |
+
import { Search, SlidersHorizontal, Plus, Copy, Check, RotateCcw, Sparkles } from 'lucide-react'
|
| 10 |
+
|
| 11 |
+
// Preset matrices for quick start
|
| 12 |
+
const CUSTOM_PRESETS = {
|
| 13 |
+
identity: {
|
| 14 |
+
name: 'Identity',
|
| 15 |
+
matrix: [[0, 0, 0], [0, 1, 0], [0, 0, 0]],
|
| 16 |
+
size: 3,
|
| 17 |
+
},
|
| 18 |
+
edgeDetect: {
|
| 19 |
+
name: 'Edge Detect',
|
| 20 |
+
matrix: [[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]],
|
| 21 |
+
size: 3,
|
| 22 |
+
},
|
| 23 |
+
blur: {
|
| 24 |
+
name: 'Box Blur',
|
| 25 |
+
matrix: [[1, 1, 1], [1, 1, 1], [1, 1, 1]],
|
| 26 |
+
size: 3,
|
| 27 |
+
},
|
| 28 |
+
sharpen: {
|
| 29 |
+
name: 'Sharpen',
|
| 30 |
+
matrix: [[0, -1, 0], [-1, 5, -1], [0, -1, 0]],
|
| 31 |
+
size: 3,
|
| 32 |
+
},
|
| 33 |
+
emboss: {
|
| 34 |
+
name: 'Emboss',
|
| 35 |
+
matrix: [[-2, -1, 0], [-1, 1, 1], [0, 1, 2]],
|
| 36 |
+
size: 3,
|
| 37 |
+
},
|
| 38 |
+
outline: {
|
| 39 |
+
name: 'Outline',
|
| 40 |
+
matrix: [[-1, -1, -1], [-1, 8, -1], [-1, -1, -1]],
|
| 41 |
+
size: 3,
|
| 42 |
+
},
|
| 43 |
+
}
|
| 44 |
|
| 45 |
export function KernelSelector() {
|
| 46 |
const { selectedKernel, setSelectedKernel, customKernelMatrix, customKernelSize, customKernelNormalize, setCustomKernel, setCustomKernelSize, setCustomKernelNormalize, updateCustomKernelCell } = useAppStore()
|
| 47 |
const [activeCategory, setActiveCategory] = useState<KernelCategory>('blur')
|
| 48 |
const [searchQuery, setSearchQuery] = useState('')
|
| 49 |
const [showCustom, setShowCustom] = useState(false)
|
| 50 |
+
const [copiedPreset, setCopiedPreset] = useState<string | null>(null)
|
| 51 |
|
| 52 |
+
// Handle keyboard navigation in matrix inputs
|
| 53 |
+
const matrixInputRefs = useRef<HTMLInputElement[][]>([])
|
| 54 |
+
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
matrixInputRefs.current = Array.from({ length: customKernelSize }, () => Array(customKernelSize).fill(null))
|
| 57 |
+
}, [customKernelSize])
|
| 58 |
|
| 59 |
+
const handleKeyDown = useCallback(
|
| 60 |
+
(e: React.KeyboardEvent, row: number, col: number) => {
|
| 61 |
+
const refs = matrixInputRefs.current
|
| 62 |
+
let newRow = row
|
| 63 |
+
let newCol = col
|
| 64 |
+
|
| 65 |
+
switch (e.key) {
|
| 66 |
+
case 'ArrowUp':
|
| 67 |
+
e.preventDefault()
|
| 68 |
+
newRow = Math.max(0, row - 1)
|
| 69 |
+
break
|
| 70 |
+
case 'ArrowDown':
|
| 71 |
+
e.preventDefault()
|
| 72 |
+
newRow = Math.min(customKernelSize - 1, row + 1)
|
| 73 |
+
break
|
| 74 |
+
case 'ArrowLeft':
|
| 75 |
+
e.preventDefault()
|
| 76 |
+
newCol = Math.max(0, col - 1)
|
| 77 |
+
break
|
| 78 |
+
case 'ArrowRight':
|
| 79 |
+
e.preventDefault()
|
| 80 |
+
newCol = Math.min(customKernelSize - 1, col + 1)
|
| 81 |
+
break
|
| 82 |
+
case 'Tab':
|
| 83 |
+
e.preventDefault()
|
| 84 |
+
if (e.shiftKey) {
|
| 85 |
+
newCol = col === 0 ? (row === 0 ? customKernelSize - 1 : customKernelSize - 1) : col - 1
|
| 86 |
+
newRow = col === 0 ? Math.max(0, row - 1) : row
|
| 87 |
+
} else {
|
| 88 |
+
newCol = col === customKernelSize - 1 ? 0 : col + 1
|
| 89 |
+
newRow = col === customKernelSize - 1 ? Math.min(customKernelSize - 1, row + 1) : row
|
| 90 |
+
}
|
| 91 |
+
break
|
| 92 |
+
case 'Enter':
|
| 93 |
+
e.preventDefault()
|
| 94 |
+
newCol = col === customKernelSize - 1 ? 0 : col + 1
|
| 95 |
+
newRow = col === customKernelSize - 1 ? Math.min(customKernelSize - 1, row + 1) : row
|
| 96 |
+
break
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
if (refs[newRow]?.[newCol]) {
|
| 100 |
+
refs[newRow][newCol]?.focus()
|
| 101 |
+
}
|
| 102 |
+
},
|
| 103 |
+
[customKernelSize]
|
| 104 |
+
)
|
| 105 |
|
| 106 |
const handleKernelSelect = (kernel: typeof PREDEFINED_KERNELS[0]) => {
|
| 107 |
setSelectedKernel(kernel)
|
|
|
|
| 121 |
setSelectedKernel(kernel)
|
| 122 |
}
|
| 123 |
|
| 124 |
+
const applyPreset = useCallback((presetKey: keyof typeof CUSTOM_PRESETS) => {
|
| 125 |
+
const preset = CUSTOM_PRESETS[presetKey]
|
| 126 |
+
if (!preset) return
|
| 127 |
+
|
| 128 |
+
// Resize matrix if needed
|
| 129 |
+
if (preset.size !== customKernelSize) {
|
| 130 |
+
setCustomKernelSize(preset.size)
|
| 131 |
+
// Wait for state update then apply matrix
|
| 132 |
+
setTimeout(() => {
|
| 133 |
+
const matrix = preset.matrix.map(row => [...row])
|
| 134 |
+
setCustomKernel(matrix, preset.size, customKernelNormalize)
|
| 135 |
+
}, 0)
|
| 136 |
+
} else {
|
| 137 |
+
const matrix = preset.matrix.map(row => [...row])
|
| 138 |
+
setCustomKernel(matrix, preset.size, customKernelNormalize)
|
| 139 |
+
}
|
| 140 |
+
setCopiedPreset(presetKey)
|
| 141 |
+
setTimeout(() => setCopiedPreset(null), 2000)
|
| 142 |
+
}, [customKernelSize, customKernelNormalize, setCustomKernel, setCustomKernelSize])
|
| 143 |
+
|
| 144 |
+
const copyMatrix = useCallback(() => {
|
| 145 |
+
const matrixStr = customKernelMatrix.map(row => row.join(', ')).join('\n')
|
| 146 |
+
navigator.clipboard.writeText(matrixStr)
|
| 147 |
+
// Show toast or visual feedback
|
| 148 |
+
}, [customKernelMatrix])
|
| 149 |
+
|
| 150 |
+
const pasteMatrix = useCallback(async () => {
|
| 151 |
+
try {
|
| 152 |
+
const text = await navigator.clipboard.readText()
|
| 153 |
+
const rows = text.trim().split('\n').map(line => line.trim().split(/[,\s]+/).map(Number))
|
| 154 |
+
if (rows.length > 0 && rows.every(row => row.length === rows[0].length)) {
|
| 155 |
+
const size = rows.length
|
| 156 |
+
if (size >= 3 && size <= 9 && size % 2 === 1) {
|
| 157 |
+
setCustomKernelSize(size)
|
| 158 |
+
setTimeout(() => setCustomKernel(rows, size, customKernelNormalize), 0)
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
} catch {
|
| 162 |
+
// Clipboard access denied or invalid format
|
| 163 |
+
}
|
| 164 |
+
}, [customKernelNormalize, setCustomKernel, setCustomKernelSize])
|
| 165 |
+
|
| 166 |
+
const resetMatrix = useCallback(() => {
|
| 167 |
+
const matrix = Array(customKernelSize).fill(0).map(() => Array(customKernelSize).fill(0))
|
| 168 |
+
const center = Math.floor(customKernelSize / 2)
|
| 169 |
+
matrix[center][center] = 1
|
| 170 |
+
setCustomKernel(matrix, customKernelSize, customKernelNormalize)
|
| 171 |
+
}, [customKernelSize, customKernelNormalize, setCustomKernel])
|
| 172 |
+
|
| 173 |
+
const validateMatrix = useCallback(() => {
|
| 174 |
+
const sum = customKernelMatrix.flat().reduce((a, b) => a + b, 0)
|
| 175 |
+
const hasNonZero = customKernelMatrix.flat().some(v => v !== 0)
|
| 176 |
+
return { sum, hasNonZero, isNormalized: customKernelNormalize }
|
| 177 |
+
}, [customKernelMatrix, customKernelNormalize])
|
| 178 |
+
|
| 179 |
return (
|
| 180 |
<Card className="w-full max-w-2xl">
|
| 181 |
<div className="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
|
|
|
| 219 |
{/* Custom Kernel Editor */}
|
| 220 |
{showCustom && (
|
| 221 |
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border dark:border-gray-700">
|
| 222 |
+
<div className="flex items-center justify-between mb-3">
|
| 223 |
+
<h3 className="font-medium">Custom Kernel Editor</h3>
|
| 224 |
+
<div className="flex items-center gap-2">
|
| 225 |
+
<Button variant="ghost" size="sm" onClick={copyMatrix} title="Copy matrix">
|
| 226 |
+
<Copy className="h-4 w-4" />
|
| 227 |
+
</Button>
|
| 228 |
+
<Button variant="ghost" size="sm" onClick={pasteMatrix} title="Paste matrix">
|
| 229 |
+
<RotateCcw className="h-4 w-4" />
|
| 230 |
+
</Button>
|
| 231 |
+
<Button variant="ghost" size="sm" onClick={resetMatrix} title="Reset to identity">
|
| 232 |
+
<RotateCcw className="h-4 w-4" />
|
| 233 |
+
</Button>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
<div className="flex items-center gap-4 mb-3 flex-wrap">
|
| 237 |
<Input
|
| 238 |
type="number"
|
| 239 |
label="Size"
|
|
|
|
| 253 |
/>
|
| 254 |
Normalize
|
| 255 |
</label>
|
| 256 |
+
<div className="flex items-center gap-1 ml-auto text-xs text-gray-500">
|
| 257 |
+
{validateMatrix().hasNonZero ? (
|
| 258 |
+
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
| 259 |
+
<Check className="h-3 w-3" />
|
| 260 |
+
Valid
|
| 261 |
+
</span>
|
| 262 |
+
) : (
|
| 263 |
+
<span className="text-red-600 dark:text-red-400">All zeros</span>
|
| 264 |
+
)}
|
| 265 |
+
{customKernelNormalize && (
|
| 266 |
+
<span className="text-blue-600 dark:text-blue-400">Σ={validateMatrix().sum.toFixed(2)}</span>
|
| 267 |
+
)}
|
| 268 |
+
</div>
|
| 269 |
</div>
|
| 270 |
+
|
| 271 |
+
{/* Preset Buttons */}
|
| 272 |
+
<div className="mb-3 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
| 273 |
+
<div className="flex items-center gap-2 mb-2">
|
| 274 |
+
<Sparkles className="h-4 w-4 text-gray-500" />
|
| 275 |
+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Quick Presets:</span>
|
| 276 |
+
</div>
|
| 277 |
+
<div className="flex flex-wrap gap-2">
|
| 278 |
+
{Object.entries(CUSTOM_PRESETS).map(([key, preset]) => (
|
| 279 |
+
<Button
|
| 280 |
+
key={key}
|
| 281 |
+
variant={copiedPreset === key ? 'primary' : 'outline'}
|
| 282 |
+
size="sm"
|
| 283 |
+
onClick={() => applyPreset(key as keyof typeof CUSTOM_PRESETS)}
|
| 284 |
+
className="text-xs"
|
| 285 |
+
>
|
| 286 |
+
{preset.name}
|
| 287 |
+
</Button>
|
| 288 |
+
))}
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
{/* Matrix Input Grid */}
|
| 293 |
<div className="grid gap-1 mb-3" style={{ gridTemplateColumns: `repeat(${customKernelSize}, 1fr)` }}>
|
| 294 |
{Array.from({ length: customKernelSize }, (_, row) =>
|
| 295 |
Array.from({ length: customKernelSize }, (_, col) => (
|
| 296 |
<input
|
| 297 |
key={`${row}-${col}`}
|
| 298 |
+
ref={(el) => { if (matrixInputRefs.current[row]) matrixInputRefs.current[row][col] = el }}
|
| 299 |
type="number"
|
| 300 |
step="0.1"
|
| 301 |
value={customKernelMatrix[row]?.[col] ?? 0}
|
| 302 |
onChange={(e) => updateCustomKernelCell(row, col, parseFloat(e.target.value) || 0)}
|
| 303 |
+
onKeyDown={(e) => handleKeyDown(e, row, col)}
|
| 304 |
+
className="w-full h-8 text-center text-sm border border-gray-300 rounded dark:border-gray-600 dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
| 305 |
+
title={`Row ${row + 1}, Col ${col + 1}`}
|
| 306 |
/>
|
| 307 |
))
|
| 308 |
)}
|
| 309 |
</div>
|
| 310 |
+
<div className="flex gap-2">
|
| 311 |
+
<Button variant="outline" onClick={copyMatrix} className="flex-1" size="sm">
|
| 312 |
+
<Copy className="h-4 w-4 mr-1" /> Copy
|
| 313 |
+
</Button>
|
| 314 |
+
<Button variant="outline" onClick={pasteMatrix} className="flex-1" size="sm">
|
| 315 |
+
<RotateCcw className="h-4 w-4 mr-1" /> Paste
|
| 316 |
+
</Button>
|
| 317 |
+
<Button onClick={handleCustomKernelSubmit} className="flex-1">
|
| 318 |
+
Apply Custom Kernel
|
| 319 |
+
</Button>
|
| 320 |
+
</div>
|
| 321 |
</div>
|
| 322 |
)}
|
| 323 |
|
src/hooks/useConvolution.ts
CHANGED
|
@@ -35,7 +35,7 @@ export function useConvolution() {
|
|
| 35 |
// Calculate optimal batch size for ~60fps (16.67ms per frame)
|
| 36 |
// Aim to spend ~8ms on computation, leaving ~8ms for rendering
|
| 37 |
const getBatchSize = useCallback(() => {
|
| 38 |
-
const baseStepsPerMs =
|
| 39 |
const targetComputeTime = 8 // ms
|
| 40 |
const speedMultiplier = settings.animationSpeed
|
| 41 |
return Math.max(1, Math.floor(baseStepsPerMs * targetComputeTime * speedMultiplier))
|
|
@@ -63,6 +63,8 @@ export function useConvolution() {
|
|
| 63 |
|
| 64 |
generatorRef.current = gen
|
| 65 |
|
|
|
|
|
|
|
| 66 |
const processBatch = () => {
|
| 67 |
if (isPausedRef.current) {
|
| 68 |
animationRef.current = requestAnimationFrame(processBatch)
|
|
@@ -75,7 +77,20 @@ export function useConvolution() {
|
|
| 75 |
return
|
| 76 |
}
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
const batchSize = getBatchSize()
|
| 80 |
let lastStep: ConvolutionStep | null = null
|
| 81 |
let stepsProcessed = 0
|
|
@@ -95,7 +110,8 @@ export function useConvolution() {
|
|
| 95 |
stepsProcessed++
|
| 96 |
|
| 97 |
// Check if we've spent too much time in this frame (yield to main thread)
|
| 98 |
-
|
|
|
|
| 99 |
break
|
| 100 |
}
|
| 101 |
}
|
|
@@ -145,6 +161,9 @@ export function useConvolution() {
|
|
| 145 |
|
| 146 |
const step = useCallback(() => {
|
| 147 |
if (!generatorRef.current) return
|
|
|
|
|
|
|
|
|
|
| 148 |
stepModeRef.current = true
|
| 149 |
const result = generatorRef.current.next()
|
| 150 |
if (!result.done) {
|
|
@@ -153,6 +172,8 @@ export function useConvolution() {
|
|
| 153 |
const output = convolveImage(originalImageData!, kernel, padding, normalize)
|
| 154 |
completeConvolution(output)
|
| 155 |
}
|
|
|
|
|
|
|
| 156 |
}, [originalImageData, kernel, padding, normalize, updateConvolutionStep, completeConvolution])
|
| 157 |
|
| 158 |
const reset = useCallback(() => {
|
|
|
|
| 35 |
// Calculate optimal batch size for ~60fps (16.67ms per frame)
|
| 36 |
// Aim to spend ~8ms on computation, leaving ~8ms for rendering
|
| 37 |
const getBatchSize = useCallback(() => {
|
| 38 |
+
const baseStepsPerMs = 50 // reduced from 500 for better responsiveness
|
| 39 |
const targetComputeTime = 8 // ms
|
| 40 |
const speedMultiplier = settings.animationSpeed
|
| 41 |
return Math.max(1, Math.floor(baseStepsPerMs * targetComputeTime * speedMultiplier))
|
|
|
|
| 63 |
|
| 64 |
generatorRef.current = gen
|
| 65 |
|
| 66 |
+
let lastFrameTime = 0
|
| 67 |
+
|
| 68 |
const processBatch = () => {
|
| 69 |
if (isPausedRef.current) {
|
| 70 |
animationRef.current = requestAnimationFrame(processBatch)
|
|
|
|
| 77 |
return
|
| 78 |
}
|
| 79 |
|
| 80 |
+
const now = performance.now()
|
| 81 |
+
const frameTime = now - lastFrameTime
|
| 82 |
+
const minFrameTime = 1000 / 60 // ~16.67ms for 60fps
|
| 83 |
+
|
| 84 |
+
// If we're running too fast, schedule next frame appropriately
|
| 85 |
+
if (frameTime < minFrameTime) {
|
| 86 |
+
setTimeout(() => {
|
| 87 |
+
animationRef.current = requestAnimationFrame(processBatch)
|
| 88 |
+
}, minFrameTime - frameTime)
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
lastFrameTime = now
|
| 93 |
+
frameStartRef.current = now
|
| 94 |
const batchSize = getBatchSize()
|
| 95 |
let lastStep: ConvolutionStep | null = null
|
| 96 |
let stepsProcessed = 0
|
|
|
|
| 110 |
stepsProcessed++
|
| 111 |
|
| 112 |
// Check if we've spent too much time in this frame (yield to main thread)
|
| 113 |
+
// Reduced from 8ms to 4ms for better responsiveness
|
| 114 |
+
if (performance.now() - frameStartRef.current > 4) {
|
| 115 |
break
|
| 116 |
}
|
| 117 |
}
|
|
|
|
| 161 |
|
| 162 |
const step = useCallback(() => {
|
| 163 |
if (!generatorRef.current) return
|
| 164 |
+
// Temporarily enable step mode for this single step
|
| 165 |
+
const wasPaused = isPausedRef.current
|
| 166 |
+
isPausedRef.current = true
|
| 167 |
stepModeRef.current = true
|
| 168 |
const result = generatorRef.current.next()
|
| 169 |
if (!result.done) {
|
|
|
|
| 172 |
const output = convolveImage(originalImageData!, kernel, padding, normalize)
|
| 173 |
completeConvolution(output)
|
| 174 |
}
|
| 175 |
+
// Restore paused state, but keep step mode enabled if it was explicitly toggled
|
| 176 |
+
isPausedRef.current = wasPaused
|
| 177 |
}, [originalImageData, kernel, padding, normalize, updateConvolutionStep, completeConvolution])
|
| 178 |
|
| 179 |
const reset = useCallback(() => {
|
src/store/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { create } from 'zustand'
|
| 2 |
import { subscribeWithSelector } from 'zustand/middleware'
|
|
|
|
| 3 |
import type {
|
| 4 |
Kernel,
|
| 5 |
ConvolutionState,
|
|
@@ -123,22 +124,22 @@ interface AppStore {
|
|
| 123 |
setAnimationSpeed: (speed: number) => void
|
| 124 |
toggleOverlay: (key: keyof AppSettings['showOverlay']) => void
|
| 125 |
setPadding: (padding: PaddingMode) => void
|
| 126 |
-
setZoomLevel: (
|
| 127 |
-
setPanOffset: (
|
| 128 |
-
setComparisonMode: (
|
| 129 |
-
setStepMode: (
|
| 130 |
|
| 131 |
// Comparison actions
|
| 132 |
setComparisonViewMode: (mode: ComparisonState['mode']) => void
|
| 133 |
-
setSplitPosition: (
|
| 134 |
toggleSynchronizedZoom: () => void
|
| 135 |
toggleSynchronizedPan: () => void
|
| 136 |
addComparisonResult: (result: KernelComparisonResult) => void
|
| 137 |
clearComparisonResults: () => void
|
| 138 |
-
setActiveComparisonTab: (
|
| 139 |
|
| 140 |
// UI actions
|
| 141 |
-
setLoading: (
|
| 142 |
setError: (error: string | null) => void
|
| 143 |
toggleHelp: () => void
|
| 144 |
toggleSettings: () => void
|
|
@@ -273,7 +274,7 @@ export const useAppStore = create<AppStore>()(
|
|
| 273 |
},
|
| 274 |
}
|
| 275 |
// If this step has a partial output image, update the processed image data
|
| 276 |
-
if (step.isPartialOutput && step.partialOutputImageData) {
|
| 277 |
return {
|
| 278 |
...newState,
|
| 279 |
processedImageData: step.partialOutputImageData,
|
|
@@ -406,7 +407,7 @@ export const useAppStore = create<AppStore>()(
|
|
| 406 |
)
|
| 407 |
|
| 408 |
// Selector hooks that include both state and actions
|
| 409 |
-
export const useConvolutionStore = () => useAppStore((state): ConvolutionStoreState => ({
|
| 410 |
...state.convolution,
|
| 411 |
startConvolution: state.startConvolution,
|
| 412 |
pauseConvolution: state.pauseConvolution,
|
|
@@ -417,9 +418,9 @@ export const useConvolutionStore = () => useAppStore((state): ConvolutionStoreSt
|
|
| 417 |
completeConvolution: state.completeConvolution,
|
| 418 |
setConvolutionError: state.setConvolutionError,
|
| 419 |
resetConvolution: state.resetConvolution,
|
| 420 |
-
}))
|
| 421 |
|
| 422 |
-
export const useSettingsStore = () => useAppStore((state): SettingsStoreState => ({
|
| 423 |
...state.settings,
|
| 424 |
setAnimationSpeed: state.setAnimationSpeed,
|
| 425 |
toggleOverlay: state.toggleOverlay,
|
|
@@ -428,9 +429,9 @@ export const useSettingsStore = () => useAppStore((state): SettingsStoreState =>
|
|
| 428 |
setPanOffset: state.setPanOffset,
|
| 429 |
setComparisonMode: state.setComparisonMode,
|
| 430 |
setStepMode: state.setStepMode,
|
| 431 |
-
}))
|
| 432 |
|
| 433 |
-
export const useKernelStore = () => useAppStore((state): KernelStoreState => ({
|
| 434 |
selectedKernel: state.selectedKernel,
|
| 435 |
customKernelMatrix: state.customKernelMatrix,
|
| 436 |
customKernelSize: state.customKernelSize,
|
|
@@ -440,16 +441,16 @@ export const useKernelStore = () => useAppStore((state): KernelStoreState => ({
|
|
| 440 |
updateCustomKernelCell: state.updateCustomKernelCell,
|
| 441 |
setCustomKernelSize: state.setCustomKernelSize,
|
| 442 |
setCustomKernelNormalize: state.setCustomKernelNormalize,
|
| 443 |
-
}))
|
| 444 |
|
| 445 |
-
export const useImageStore = () => useAppStore((state): ImageStoreState => ({
|
| 446 |
originalImage: state.originalImage,
|
| 447 |
originalImageData: state.originalImageData,
|
| 448 |
processedImageData: state.processedImageData,
|
| 449 |
setOriginalImage: state.setOriginalImage,
|
| 450 |
-
}))
|
| 451 |
|
| 452 |
-
export const useComparisonStore = () => useAppStore((state): ComparisonStoreState => ({
|
| 453 |
...state.comparison,
|
| 454 |
setComparisonViewMode: state.setComparisonViewMode,
|
| 455 |
setSplitPosition: state.setSplitPosition,
|
|
@@ -458,9 +459,9 @@ export const useComparisonStore = () => useAppStore((state): ComparisonStoreStat
|
|
| 458 |
addComparisonResult: state.addComparisonResult,
|
| 459 |
clearComparisonResults: state.clearComparisonResults,
|
| 460 |
setActiveComparisonTab: state.setActiveComparisonTab,
|
| 461 |
-
}))
|
| 462 |
|
| 463 |
-
export const useUIStore = () => useAppStore((state): UIStoreState => ({
|
| 464 |
isLoading: state.isLoading,
|
| 465 |
error: state.error,
|
| 466 |
showHelp: state.showHelp,
|
|
@@ -469,4 +470,4 @@ export const useUIStore = () => useAppStore((state): UIStoreState => ({
|
|
| 469 |
setError: state.setError,
|
| 470 |
toggleHelp: state.toggleHelp,
|
| 471 |
toggleSettings: state.toggleSettings,
|
| 472 |
-
}))
|
|
|
|
| 1 |
import { create } from 'zustand'
|
| 2 |
import { subscribeWithSelector } from 'zustand/middleware'
|
| 3 |
+
import { useShallow } from 'zustand/react/shallow'
|
| 4 |
import type {
|
| 5 |
Kernel,
|
| 6 |
ConvolutionState,
|
|
|
|
| 124 |
setAnimationSpeed: (speed: number) => void
|
| 125 |
toggleOverlay: (key: keyof AppSettings['showOverlay']) => void
|
| 126 |
setPadding: (padding: PaddingMode) => void
|
| 127 |
+
setZoomLevel: (zoomLevel: number) => void
|
| 128 |
+
setPanOffset: (panOffset: { x: number; y: number }) => void
|
| 129 |
+
setComparisonMode: (comparisonMode: AppSettings['comparisonMode']) => void
|
| 130 |
+
setStepMode: (stepMode: boolean) => void
|
| 131 |
|
| 132 |
// Comparison actions
|
| 133 |
setComparisonViewMode: (mode: ComparisonState['mode']) => void
|
| 134 |
+
setSplitPosition: (splitPosition: number) => void
|
| 135 |
toggleSynchronizedZoom: () => void
|
| 136 |
toggleSynchronizedPan: () => void
|
| 137 |
addComparisonResult: (result: KernelComparisonResult) => void
|
| 138 |
clearComparisonResults: () => void
|
| 139 |
+
setActiveComparisonTab: (activeTab: string) => void
|
| 140 |
|
| 141 |
// UI actions
|
| 142 |
+
setLoading: (isLoading: boolean) => void
|
| 143 |
setError: (error: string | null) => void
|
| 144 |
toggleHelp: () => void
|
| 145 |
toggleSettings: () => void
|
|
|
|
| 274 |
},
|
| 275 |
}
|
| 276 |
// If this step has a partial output image, update the processed image data
|
| 277 |
+
if (step && step.isPartialOutput && step.partialOutputImageData) {
|
| 278 |
return {
|
| 279 |
...newState,
|
| 280 |
processedImageData: step.partialOutputImageData,
|
|
|
|
| 407 |
)
|
| 408 |
|
| 409 |
// Selector hooks that include both state and actions
|
| 410 |
+
export const useConvolutionStore = () => useAppStore(useShallow((state): ConvolutionStoreState => ({
|
| 411 |
...state.convolution,
|
| 412 |
startConvolution: state.startConvolution,
|
| 413 |
pauseConvolution: state.pauseConvolution,
|
|
|
|
| 418 |
completeConvolution: state.completeConvolution,
|
| 419 |
setConvolutionError: state.setConvolutionError,
|
| 420 |
resetConvolution: state.resetConvolution,
|
| 421 |
+
})))
|
| 422 |
|
| 423 |
+
export const useSettingsStore = () => useAppStore(useShallow((state): SettingsStoreState => ({
|
| 424 |
...state.settings,
|
| 425 |
setAnimationSpeed: state.setAnimationSpeed,
|
| 426 |
toggleOverlay: state.toggleOverlay,
|
|
|
|
| 429 |
setPanOffset: state.setPanOffset,
|
| 430 |
setComparisonMode: state.setComparisonMode,
|
| 431 |
setStepMode: state.setStepMode,
|
| 432 |
+
})))
|
| 433 |
|
| 434 |
+
export const useKernelStore = () => useAppStore(useShallow((state): KernelStoreState => ({
|
| 435 |
selectedKernel: state.selectedKernel,
|
| 436 |
customKernelMatrix: state.customKernelMatrix,
|
| 437 |
customKernelSize: state.customKernelSize,
|
|
|
|
| 441 |
updateCustomKernelCell: state.updateCustomKernelCell,
|
| 442 |
setCustomKernelSize: state.setCustomKernelSize,
|
| 443 |
setCustomKernelNormalize: state.setCustomKernelNormalize,
|
| 444 |
+
})))
|
| 445 |
|
| 446 |
+
export const useImageStore = () => useAppStore(useShallow((state): ImageStoreState => ({
|
| 447 |
originalImage: state.originalImage,
|
| 448 |
originalImageData: state.originalImageData,
|
| 449 |
processedImageData: state.processedImageData,
|
| 450 |
setOriginalImage: state.setOriginalImage,
|
| 451 |
+
})))
|
| 452 |
|
| 453 |
+
export const useComparisonStore = () => useAppStore(useShallow((state): ComparisonStoreState => ({
|
| 454 |
...state.comparison,
|
| 455 |
setComparisonViewMode: state.setComparisonViewMode,
|
| 456 |
setSplitPosition: state.setSplitPosition,
|
|
|
|
| 459 |
addComparisonResult: state.addComparisonResult,
|
| 460 |
clearComparisonResults: state.clearComparisonResults,
|
| 461 |
setActiveComparisonTab: state.setActiveComparisonTab,
|
| 462 |
+
})))
|
| 463 |
|
| 464 |
+
export const useUIStore = () => useAppStore(useShallow((state): UIStoreState => ({
|
| 465 |
isLoading: state.isLoading,
|
| 466 |
error: state.error,
|
| 467 |
showHelp: state.showHelp,
|
|
|
|
| 470 |
setError: state.setError,
|
| 471 |
toggleHelp: state.toggleHelp,
|
| 472 |
toggleSettings: state.toggleSettings,
|
| 473 |
+
})))
|
updates.md
CHANGED
|
@@ -106,4 +106,44 @@
|
|
| 106 |
### In Progress
|
| 107 |
- [ ] Phase 3: Animation & Visualization
|
| 108 |
- [ ] Phase 4: Interactive Controls
|
| 109 |
-
- [ ] Phase 5: Comparison & Analysis
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
### In Progress
|
| 107 |
- [ ] Phase 3: Animation & Visualization
|
| 108 |
- [ ] Phase 4: Interactive Controls
|
| 109 |
+
- [ ] Phase 5: Comparison & Analysis
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## Version 0.4.0 - Phase 3 Complete: Animation & Visualization (2026-08-06)
|
| 114 |
+
|
| 115 |
+
### Completed
|
| 116 |
+
- [x] Fixed animation loop batch processing for smooth 60fps visualization
|
| 117 |
+
- Dynamic batch size calculation based on animation speed
|
| 118 |
+
- Frame budgeting (8ms compute / 8ms render per 16.67ms frame)
|
| 119 |
+
- Automatic yield to main thread when frame budget exceeded
|
| 120 |
+
- [x] Implemented progressive output rendering during animation
|
| 121 |
+
- Partial output images yielded every ~20 rows
|
| 122 |
+
- ConvolutionStep type extended with `partialOutputImageData` and `isPartialOutput`
|
| 123 |
+
- Store updates processedImageData in real-time for live preview
|
| 124 |
+
- [x] Fixed step mode integration with animation loop
|
| 125 |
+
- Step button advances exactly one convolution step
|
| 126 |
+
- Preserves pause state when stepping
|
| 127 |
+
- Step mode toggle works correctly with play/pause
|
| 128 |
+
- [x] Added frame rate limiting for consistent 60fps
|
| 129 |
+
- Minimum frame interval enforcement (16.67ms)
|
| 130 |
+
- Uses setTimeout + requestAnimationFrame for precise timing
|
| 131 |
+
- Prevents CPU spinning when computation is fast
|
| 132 |
+
- [x] Optimized convolution generator for animation performance
|
| 133 |
+
- Pre-computed kernel values and normalization factors
|
| 134 |
+
- Efficient partial image creation with incremental updates
|
| 135 |
+
- Generator yields control back to main thread periodically
|
| 136 |
+
|
| 137 |
+
### Technical Achievements
|
| 138 |
+
- Build passes: `npm run build` ✓
|
| 139 |
+
- TypeScript passes: `npm run type-check` ✓
|
| 140 |
+
- Dev server runs without errors ✓
|
| 141 |
+
- Animation runs smoothly at 60fps with configurable speed (0.1x - 10x)
|
| 142 |
+
- Progressive output shows convolution building up in real-time
|
| 143 |
+
- Step mode allows single-step inspection during animation
|
| 144 |
+
- Frame budgeting prevents UI jank during heavy computation
|
| 145 |
+
|
| 146 |
+
### In Progress
|
| 147 |
+
- [ ] Phase 4: Interactive Controls
|
| 148 |
+
- [ ] Phase 5: Comparison & Analysis
|
| 149 |
+
- [ ] Phase 6: Educational Features
|