mihirmithani commited on
Commit ·
80bcac8
1
Parent(s): cb05f36
Add Kernel Convolution Visualizer - Phase 1 complete
Browse files- Next.js 14+ with TypeScript, Tailwind CSS
- 32 predefined kernels across 6 categories
- Step-by-step convolution visualization with Canvas
- Zustand state management
- Custom kernel editor (3x3 to 9x9)
- 4 padding modes (zero, reflect, replicate, wrap)
- Playback controls (play, pause, step, speed)
- Comparison views (side-by-side, split)
- Dark mode, responsive layout
- Export functionality
This view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +23 -5
- AGENTS.md +9 -0
- CLAUDE.md +1 -0
- README.md +155 -81
- eslint.config.mjs +18 -0
- next.config.ts +21 -0
- package-lock.json +0 -0
- package.json +31 -32
- plan.md +339 -0
- postcss.config.mjs +7 -0
- public/favicon.ico +0 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/index.html +0 -43
- public/logo192.png +0 -0
- public/logo512.png +0 -0
- public/manifest.json +0 -25
- public/next.svg +1 -0
- public/robots.txt +0 -3
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/App.css +0 -38
- src/App.js +0 -25
- src/App.test.js +0 -8
- src/app/favicon.ico +0 -0
- src/app/globals.css +115 -0
- src/app/layout.tsx +30 -0
- src/app/page.tsx +389 -0
- src/components/canvas/ConvolutionCanvas.tsx +255 -0
- src/components/canvas/ImageCanvas.tsx +122 -0
- src/components/canvas/SplitView.tsx +199 -0
- src/components/canvas/index.ts +3 -0
- src/components/controls/OverlayControls.tsx +147 -0
- src/components/controls/PlaybackControls.tsx +166 -0
- src/components/controls/index.ts +2 -0
- src/components/kernel/KernelSelector.tsx +184 -0
- src/components/ui/Badge.tsx +37 -0
- src/components/ui/Button.tsx +60 -0
- src/components/ui/Card.tsx +80 -0
- src/components/ui/Input.tsx +59 -0
- src/components/ui/Select.tsx +62 -0
- src/components/ui/Slider.tsx +57 -0
- src/components/ui/Tooltip.tsx +57 -0
- src/components/ui/index.ts +7 -0
- src/hooks/useCanvas.ts +143 -0
- src/hooks/useConvolution.ts +171 -0
- src/hooks/useImageLoader.ts +135 -0
- src/index.css +0 -13
- src/index.js +0 -17
- src/lib/convolution/engine.ts +186 -0
.gitignore
CHANGED
|
@@ -3,21 +3,39 @@
|
|
| 3 |
# dependencies
|
| 4 |
/node_modules
|
| 5 |
/.pnp
|
| 6 |
-
.pnp.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# testing
|
| 9 |
/coverage
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# production
|
| 12 |
/build
|
| 13 |
|
| 14 |
# misc
|
| 15 |
.DS_Store
|
| 16 |
-
.
|
| 17 |
-
.env.development.local
|
| 18 |
-
.env.test.local
|
| 19 |
-
.env.production.local
|
| 20 |
|
|
|
|
| 21 |
npm-debug.log*
|
| 22 |
yarn-debug.log*
|
| 23 |
yarn-error.log*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
# dependencies
|
| 4 |
/node_modules
|
| 5 |
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
|
| 13 |
# testing
|
| 14 |
/coverage
|
| 15 |
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
# production
|
| 21 |
/build
|
| 22 |
|
| 23 |
# misc
|
| 24 |
.DS_Store
|
| 25 |
+
*.pem
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
# debug
|
| 28 |
npm-debug.log*
|
| 29 |
yarn-debug.log*
|
| 30 |
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
AGENTS.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
|
| 3 |
+
# This is NOT the Next.js you know
|
| 4 |
+
|
| 5 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
| 6 |
+
|
| 7 |
+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
| 8 |
+
|
| 9 |
+
<!-- END:nextjs-agent-rules -->
|
CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
README.md
CHANGED
|
@@ -1,81 +1,155 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
--
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
###
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
#
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Interactive Kernel Convolution Visualizer
|
| 2 |
+
|
| 3 |
+
An educational web application that demonstrates how image convolution works in real-time. Upload any image, choose from predefined kernels or create your own, and watch the convolution process step-by-step with detailed mathematical visualization.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
### Core Visualization
|
| 8 |
+
- **Step-by-step convolution animation** - Watch the kernel matrix move across the image pixel by pixel
|
| 9 |
+
- **Real-time mathematical display** - See element-wise multiplication and summation for each output pixel
|
| 10 |
+
- **Current patch highlighting** - Visual indication of the image region under the kernel
|
| 11 |
+
- **Progressive output rendering** - Output image builds up as computation completes
|
| 12 |
+
|
| 13 |
+
### Kernel Library
|
| 14 |
+
- **Blur Filters**: Box Blur (3×3, 5×5), Gaussian Blur (3×3, 5×5)
|
| 15 |
+
- **Sharpen Filters**: Standard Sharpen, Strong Sharpen, Unsharp Mask
|
| 16 |
+
- **Edge Detection**: Sobel X/Y/Combined, Prewitt, Roberts Cross, Laplacian
|
| 17 |
+
- **Emboss**: Multiple directional emboss effects
|
| 18 |
+
- **Special Effects**: Motion Blur, Outline, Ridge Detection
|
| 19 |
+
- **Custom Kernel Builder**: Create your own kernels (3×3 to 9×9)
|
| 20 |
+
|
| 21 |
+
### Interactive Controls
|
| 22 |
+
- Play, Pause, Step Forward, Stop, Reset
|
| 23 |
+
- Adjustable animation speed (0.1x - 10x)
|
| 24 |
+
- Zoom and pan for pixel-level inspection
|
| 25 |
+
- Step mode for manual frame-by-frame advancement
|
| 26 |
+
|
| 27 |
+
### Comparison & Analysis
|
| 28 |
+
- Side-by-side original vs processed view
|
| 29 |
+
- Split-view slider with draggable divider
|
| 30 |
+
- Synchronized zoom/pan between views
|
| 31 |
+
- Multiple kernel comparison tabs
|
| 32 |
+
|
| 33 |
+
### Educational Features
|
| 34 |
+
- Real-time convolution equation with current values
|
| 35 |
+
- Kernel purpose and effect descriptions
|
| 36 |
+
- Padding strategy visualization (Zero, Reflect, Replicate, Wrap)
|
| 37 |
+
- Channel-wise processing explanation (RGB)
|
| 38 |
+
- Mathematical formula display
|
| 39 |
+
|
| 40 |
+
### Export & Sharing
|
| 41 |
+
- Save processed images (PNG)
|
| 42 |
+
- Download convolution computation logs
|
| 43 |
+
- Shareable application state
|
| 44 |
+
|
| 45 |
+
## Tech Stack
|
| 46 |
+
|
| 47 |
+
- **Framework**: Next.js 14+ (App Router) with TypeScript
|
| 48 |
+
- **State Management**: Zustand
|
| 49 |
+
- **Animation**: Framer Motion + Custom Canvas Animation Loop
|
| 50 |
+
- **Styling**: Tailwind CSS
|
| 51 |
+
- **Visualization**: HTML5 Canvas API
|
| 52 |
+
- **Icons**: Lucide React
|
| 53 |
+
- **UI Components**: Radix UI primitives
|
| 54 |
+
|
| 55 |
+
## Getting Started
|
| 56 |
+
|
| 57 |
+
### Prerequisites
|
| 58 |
+
- Node.js 18+
|
| 59 |
+
- npm, yarn, pnpm, or bun
|
| 60 |
+
|
| 61 |
+
### Installation
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
# Clone the repository
|
| 65 |
+
git clone <repository-url>
|
| 66 |
+
cd kernel-convolution-visualizer
|
| 67 |
+
|
| 68 |
+
# Install dependencies
|
| 69 |
+
npm install
|
| 70 |
+
|
| 71 |
+
# Start development server
|
| 72 |
+
npm run dev
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
| 76 |
+
|
| 77 |
+
### Available Scripts
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
npm run dev # Start development server
|
| 81 |
+
npm run build # Build for production
|
| 82 |
+
npm run start # Start production server
|
| 83 |
+
npm run lint # Run ESLint
|
| 84 |
+
npm run type-check # Run TypeScript type checking
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## Project Structure
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
src/
|
| 91 |
+
├── app/ # Next.js App Router pages
|
| 92 |
+
├── components/
|
| 93 |
+
│ ├── canvas/ # Canvas-based visualization components
|
| 94 |
+
│ ├── controls/ # Playback, zoom, overlay controls
|
| 95 |
+
│ ├── kernel/ # Kernel selector, editor, library
|
| 96 |
+
│ ├── comparison/ # Split view, side-by-side comparison
|
| 97 |
+
│ ├── education/ # Math display, kernel info panels
|
| 98 |
+
│ └── ui/ # Reusable UI components
|
| 99 |
+
├── hooks/ # Custom React hooks
|
| 100 |
+
├── lib/
|
| 101 |
+
│ ├── convolution/ # Core convolution algorithms
|
| 102 |
+
│ ├── kernels/ # Predefined kernel definitions
|
| 103 |
+
│ ├── image/ # Image loading, processing utilities
|
| 104 |
+
│ └── export/ # Export functionality
|
| 105 |
+
├── store/ # Zustand state management
|
| 106 |
+
├── types/ # TypeScript definitions
|
| 107 |
+
└── utils/ # Helper functions
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Usage Guide
|
| 111 |
+
|
| 112 |
+
1. **Upload an Image**: Drag & drop or click the upload area in the left sidebar
|
| 113 |
+
2. **Select a Kernel**: Choose from categories (Blur, Sharpen, Edge Detection, etc.) or create a custom kernel
|
| 114 |
+
3. **Watch Convolution**: Press Play to see the kernel move across the image
|
| 115 |
+
4. **Inspect Details**: Zoom in to see pixel values, multiplications, and summation
|
| 116 |
+
5. **Compare Results**: Click "Show Comparison" for side-by-side or split view
|
| 117 |
+
6. **Experiment**: Try different padding strategies, toggle overlays, adjust speed
|
| 118 |
+
7. **Export**: Download the processed image
|
| 119 |
+
|
| 120 |
+
## Convolution Visualization Explained
|
| 121 |
+
|
| 122 |
+
The visualization shows the mathematical operation at each step:
|
| 123 |
+
|
| 124 |
+
```
|
| 125 |
+
For each output pixel (x, y) and channel c:
|
| 126 |
+
1. Extract input patch: P[i][j] = image[x+i][y+j][c] for i,j in kernel
|
| 127 |
+
2. Element-wise multiply: M[i][j] = P[i][j] × K[i][j]
|
| 128 |
+
3. Sum: S = Σ M[i][j]
|
| 129 |
+
4. Normalize: output = S / Σ K[i][j] (if normalized)
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## Padding Strategies
|
| 133 |
+
|
| 134 |
+
- **Zero Padding**: Fill outside with zeros (default)
|
| 135 |
+
- **Reflect Padding**: Mirror the image at boundaries
|
| 136 |
+
- **Replicate Padding**: Repeat edge pixels outward
|
| 137 |
+
- **Wrap Padding**: Tile the image (torus topology)
|
| 138 |
+
|
| 139 |
+
## Contributing
|
| 140 |
+
|
| 141 |
+
1. Fork the repository
|
| 142 |
+
2. Create a feature branch
|
| 143 |
+
3. Make your changes
|
| 144 |
+
4. Run tests and linting
|
| 145 |
+
5. Submit a pull request
|
| 146 |
+
|
| 147 |
+
## License
|
| 148 |
+
|
| 149 |
+
MIT License - feel free to use for educational and commercial purposes.
|
| 150 |
+
|
| 151 |
+
## Acknowledgments
|
| 152 |
+
|
| 153 |
+
- Inspired by convolution visualizations in computer vision education
|
| 154 |
+
- Kernel definitions from standard image processing literature
|
| 155 |
+
- Built with Next.js, React, and modern web technologies
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
// Disable Turbopack for now due to webpack config
|
| 5 |
+
turbopack: {},
|
| 6 |
+
// Enable experimental features for better performance
|
| 7 |
+
experimental: {
|
| 8 |
+
optimizePackageImports: ['lucide-react'],
|
| 9 |
+
},
|
| 10 |
+
// Allow cross-origin images for development
|
| 11 |
+
images: {
|
| 12 |
+
remotePatterns: [
|
| 13 |
+
{
|
| 14 |
+
protocol: 'https',
|
| 15 |
+
hostname: '**',
|
| 16 |
+
},
|
| 17 |
+
],
|
| 18 |
+
},
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -1,39 +1,38 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
| 5 |
-
"dependencies": {
|
| 6 |
-
"@testing-library/dom": "^10.4.0",
|
| 7 |
-
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
-
"@testing-library/react": "^16.3.0",
|
| 9 |
-
"@testing-library/user-event": "^13.5.0",
|
| 10 |
-
"react": "^19.1.0",
|
| 11 |
-
"react-dom": "^19.1.0",
|
| 12 |
-
"react-scripts": "5.0.1",
|
| 13 |
-
"web-vitals": "^2.1.4"
|
| 14 |
-
},
|
| 15 |
"scripts": {
|
| 16 |
-
"
|
| 17 |
-
"build": "
|
| 18 |
-
"
|
| 19 |
-
"
|
|
|
|
| 20 |
},
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
},
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
]
|
| 38 |
}
|
| 39 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "kernel-convolution-visualizer",
|
| 3 |
"version": "0.1.0",
|
| 4 |
"private": true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "next lint",
|
| 10 |
+
"type-check": "tsc --noEmit"
|
| 11 |
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
| 14 |
+
"@radix-ui/react-select": "^2.3.7",
|
| 15 |
+
"@radix-ui/react-slider": "^1.4.7",
|
| 16 |
+
"@radix-ui/react-tabs": "^1.1.21",
|
| 17 |
+
"@radix-ui/react-tooltip": "^1.2.16",
|
| 18 |
+
"clsx": "^2.1.1",
|
| 19 |
+
"framer-motion": "^13.0.0",
|
| 20 |
+
"lucide-react": "^1.28.0",
|
| 21 |
+
"next": "16.3.0",
|
| 22 |
+
"react": "19.2.8",
|
| 23 |
+
"react-dom": "19.2.8",
|
| 24 |
+
"tailwind-merge": "^3.6.0",
|
| 25 |
+
"zustand": "^5.0.14"
|
| 26 |
},
|
| 27 |
+
"devDependencies": {
|
| 28 |
+
"@tailwindcss/postcss": "^4",
|
| 29 |
+
"@types/node": "^20",
|
| 30 |
+
"@types/offscreencanvas": "^2019.7.3",
|
| 31 |
+
"@types/react": "^19",
|
| 32 |
+
"@types/react-dom": "^19",
|
| 33 |
+
"eslint": "^9",
|
| 34 |
+
"eslint-config-next": "16.3.0",
|
| 35 |
+
"tailwindcss": "^4",
|
| 36 |
+
"typescript": "^5"
|
|
|
|
| 37 |
}
|
| 38 |
+
}
|
plan.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Interactive Kernel Convolution Visualizer - Project Plan
|
| 2 |
+
|
| 3 |
+
## Project Overview
|
| 4 |
+
Build an interactive web application that demonstrates how image convolution works in real time. The application serves both as an image-processing tool and an educational visualization platform for understanding convolution kernels, their mathematical operations, and their effects on images.
|
| 5 |
+
|
| 6 |
+
## Target Audience
|
| 7 |
+
- Students learning computer vision and digital image processing
|
| 8 |
+
- Developers learning about CNNs and kernel operations
|
| 9 |
+
- Educators teaching image processing concepts
|
| 10 |
+
- Anyone curious about how image filters work
|
| 11 |
+
|
| 12 |
+
## Core Features
|
| 13 |
+
|
| 14 |
+
### 1. Image Management
|
| 15 |
+
- Upload any image (drag & drop, file picker)
|
| 16 |
+
- Support common formats: PNG, JPEG, WebP, BMP
|
| 17 |
+
- Display original image with zoom/pan capabilities
|
| 18 |
+
- Image metadata display (dimensions, file size, color channels)
|
| 19 |
+
|
| 20 |
+
### 2. Kernel Library
|
| 21 |
+
Predefined kernels:
|
| 22 |
+
- **Blur Filters**: Box Blur (3×3, 5×5, 7×7), Gaussian Blur (3×3, 5×5)
|
| 23 |
+
- **Sharpen Filters**: Sharpen, Unsharp Mask
|
| 24 |
+
- **Edge Detection**: Sobel X, Sobel Y, Sobel Combined, Prewitt, Roberts, Laplacian
|
| 25 |
+
- **Emboss**: Emboss (multiple directions)
|
| 26 |
+
- **Special Effects**: Motion Blur, Outline, Ridge Detection
|
| 27 |
+
- **Custom Kernel Builder**: User-defined matrix with size selector (3×3, 5×5, 7×7, 9×9)
|
| 28 |
+
|
| 29 |
+
### 3. Convolution Visualization (Core Feature)
|
| 30 |
+
- **Step-by-step Animation**: Kernel matrix moves across image pixel by pixel
|
| 31 |
+
- **Current Patch Highlight**: Visual indication of image region under kernel
|
| 32 |
+
- **Element-wise Multiplication**: Show kernel values × pixel values
|
| 33 |
+
- **Summation Display**: Real-time calculation of output pixel value
|
| 34 |
+
- **Continuous Animation**: Smooth traversal across entire image
|
| 35 |
+
- **Progressive Output Rendering**: Output image builds up as computation completes
|
| 36 |
+
|
| 37 |
+
### 4. Interactive Controls
|
| 38 |
+
- Play / Pause / Restart / Step Forward / Step Backward
|
| 39 |
+
- Animation speed slider (0.5x - 10x)
|
| 40 |
+
- Zoom controls for pixel-level inspection
|
| 41 |
+
- Toggle overlays:
|
| 42 |
+
- Kernel boundary visualization
|
| 43 |
+
- Pixel value display
|
| 44 |
+
- Multiplication calculation overlay
|
| 45 |
+
- Summation formula display
|
| 46 |
+
|
| 47 |
+
### 5. Comparison & Analysis
|
| 48 |
+
- Side-by-side original vs processed view
|
| 49 |
+
- Split-view slider (before/after)
|
| 50 |
+
- Synchronized zoom/pan between views
|
| 51 |
+
- Multiple kernel comparison mode (tabbed results)
|
| 52 |
+
- Processing statistics: execution time, operations count, memory usage
|
| 53 |
+
|
| 54 |
+
### 6. Padding Strategies
|
| 55 |
+
- Visual demonstration of padding effects
|
| 56 |
+
- Options: Zero Padding, Reflect Padding, Replicate Padding, Wrap Padding
|
| 57 |
+
- Border handling visualization at image edges
|
| 58 |
+
|
| 59 |
+
### 7. Educational Insights
|
| 60 |
+
- Real-time mathematical equation display
|
| 61 |
+
- Kernel purpose/effect description
|
| 62 |
+
- Convolution formula with current values
|
| 63 |
+
- Channel-wise processing explanation (RGB)
|
| 64 |
+
- Links to learning resources
|
| 65 |
+
|
| 66 |
+
### 8. Export & Sharing
|
| 67 |
+
- Save processed image (PNG, JPEG)
|
| 68 |
+
- Export visualization as GIF/MP4
|
| 69 |
+
- Shareable URL with current state
|
| 70 |
+
- Download convolution computation log (CSV/JSON)
|
| 71 |
+
|
| 72 |
+
## Technical Architecture
|
| 73 |
+
|
| 74 |
+
### Frontend Stack
|
| 75 |
+
- **Framework**: Next.js 14+ (App Router) with TypeScript
|
| 76 |
+
- **State Management**: Zustand or React Context
|
| 77 |
+
- **Animation**: Framer Motion for UI, custom Canvas/WebGL for convolution viz
|
| 78 |
+
- **Styling**: Tailwind CSS
|
| 79 |
+
- **Canvas**: HTML5 Canvas API (primary), WebGL fallback for performance
|
| 80 |
+
- **Image Processing**: Custom JavaScript implementation (no heavy deps)
|
| 81 |
+
|
| 82 |
+
### Component Structure
|
| 83 |
+
```
|
| 84 |
+
src/
|
| 85 |
+
├── app/ # Next.js App Router pages
|
| 86 |
+
├── components/
|
| 87 |
+
│ ├── canvas/ # Canvas-based visualization components
|
| 88 |
+
│ ├── controls/ # Playback, speed, zoom controls
|
| 89 |
+
│ ├── kernel/ # Kernel selector, editor, library
|
| 90 |
+
│ ├── comparison/ # Split view, side-by-side comparison
|
| 91 |
+
│ ├── education/ # Math display, kernel info panels
|
| 92 |
+
│ └── ui/ # Reusable UI components (buttons, sliders, etc.)
|
| 93 |
+
├── hooks/ # Custom React hooks
|
| 94 |
+
├── lib/
|
| 95 |
+
│ ├── convolution/ # Core convolution algorithms
|
| 96 |
+
│ ├── kernels/ # Predefined kernel definitions
|
| 97 |
+
│ ├── image/ # Image loading, processing utilities
|
| 98 |
+
│ └── export/ # Export functionality
|
| 99 |
+
├── store/ # State management
|
| 100 |
+
├── types/ # TypeScript definitions
|
| 101 |
+
└── utils/ # Helper functions
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Core Algorithms
|
| 105 |
+
|
| 106 |
+
#### Convolution Engine
|
| 107 |
+
```typescript
|
| 108 |
+
interface ConvolutionOptions {
|
| 109 |
+
kernel: number[][];
|
| 110 |
+
padding: 'zero' | 'reflect' | 'replicate' | 'wrap';
|
| 111 |
+
normalize: boolean;
|
| 112 |
+
stride: number;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function convolve(
|
| 116 |
+
imageData: ImageData,
|
| 117 |
+
kernel: number[][],
|
| 118 |
+
options: ConvolutionOptions,
|
| 119 |
+
onProgress?: (progress: ConvolutionProgress) => void
|
| 120 |
+
): Promise<ImageData>;
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
#### Step-by-step Generator
|
| 124 |
+
```typescript
|
| 125 |
+
interface ConvolutionStep {
|
| 126 |
+
x: number;
|
| 127 |
+
y: number;
|
| 128 |
+
inputPatch: number[][][];
|
| 129 |
+
kernel: number[][];
|
| 130 |
+
multiplications: number[][][];
|
| 131 |
+
sum: number;
|
| 132 |
+
outputValue: number;
|
| 133 |
+
channel: number;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function* convolutionSteps(
|
| 137 |
+
imageData: ImageData,
|
| 138 |
+
kernel: number[][],
|
| 139 |
+
options: ConvolutionOptions
|
| 140 |
+
): Generator<ConvolutionStep>;
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Animation System
|
| 144 |
+
- RequestAnimationFrame-based loop
|
| 145 |
+
- Configurable frame rate (target 60fps)
|
| 146 |
+
- Step batching for performance (process N pixels per frame)
|
| 147 |
+
- Pause/resume without losing state
|
| 148 |
+
- Step-by-step mode with manual advancement
|
| 149 |
+
|
| 150 |
+
### Performance Considerations
|
| 151 |
+
- Web Workers for heavy computation
|
| 152 |
+
- Canvas pooling to reduce GC pressure
|
| 153 |
+
- TypedArrays for numerical computation
|
| 154 |
+
- Progressive rendering (don't block UI)
|
| 155 |
+
- Lazy evaluation for comparison views
|
| 156 |
+
|
| 157 |
+
## Development Phases
|
| 158 |
+
|
| 159 |
+
### Phase 1: Foundation (Week 1-2)
|
| 160 |
+
- [ ] Project setup (Next.js, TypeScript, Tailwind, ESLint, Prettier)
|
| 161 |
+
- [ ] Basic project structure and component architecture
|
| 162 |
+
- [ ] Image upload and display component
|
| 163 |
+
- [ ] Basic Canvas rendering setup
|
| 164 |
+
- [ ] Kernel data structure and predefined kernels library
|
| 165 |
+
|
| 166 |
+
### Phase 2: Core Convolution (Week 2-3)
|
| 167 |
+
- [ ] Implement convolution algorithm (CPU)
|
| 168 |
+
- [ ] Implement padding strategies
|
| 169 |
+
- [ ] Create step-by-step generator
|
| 170 |
+
- [ ] Basic convolution visualization (static)
|
| 171 |
+
- [ ] Output image generation
|
| 172 |
+
|
| 173 |
+
### Phase 3: Animation & Visualization (Week 3-4)
|
| 174 |
+
- [ ] Animation loop with play/pause/step controls
|
| 175 |
+
- [ ] Speed control implementation
|
| 176 |
+
- [ ] Current patch highlighting
|
| 177 |
+
- [ ] Element-wise multiplication visualization
|
| 178 |
+
- [ ] Summation process display
|
| 179 |
+
- [ ] Progressive output rendering
|
| 180 |
+
|
| 181 |
+
### Phase 4: Interactive Controls (Week 4-5)
|
| 182 |
+
- [ ] Zoom/pan functionality
|
| 183 |
+
- [ ] Overlay toggles (kernel boundary, values, math)
|
| 184 |
+
- [ ] Kernel selector UI with categories
|
| 185 |
+
- [ ] Custom kernel editor
|
| 186 |
+
- [ ] Padding strategy selector with visualization
|
| 187 |
+
|
| 188 |
+
### Phase 5: Comparison & Analysis (Week 5-6)
|
| 189 |
+
- [ ] Side-by-side comparison view
|
| 190 |
+
- [ ] Split slider component
|
| 191 |
+
- [ ] Synchronized zoom/pan
|
| 192 |
+
- [ ] Multi-kernel comparison tabs
|
| 193 |
+
- [ ] Statistics panel (time, ops, memory)
|
| 194 |
+
|
| 195 |
+
### Phase 6: Educational Features (Week 6-7)
|
| 196 |
+
- [ ] Real-time equation display
|
| 197 |
+
- [ ] Kernel information panels
|
| 198 |
+
- [ ] Channel processing visualization
|
| 199 |
+
- [ ] Tooltip/explanation system
|
| 200 |
+
- [ ] Learning resource links
|
| 201 |
+
|
| 202 |
+
### Phase 7: Export & Polish (Week 7-8)
|
| 203 |
+
- [ ] Save processed image
|
| 204 |
+
- [ ] Export animation (GIF/Video)
|
| 205 |
+
- [ ] Shareable state URLs
|
| 206 |
+
- [ ] Performance optimization
|
| 207 |
+
- [ ] Mobile responsiveness
|
| 208 |
+
- [ ] Accessibility improvements
|
| 209 |
+
- [ ] Documentation & help system
|
| 210 |
+
|
| 211 |
+
### Phase 8: Advanced Features (Week 8+)
|
| 212 |
+
- [ ] WebGL acceleration option
|
| 213 |
+
- [ ] Web Worker for computation
|
| 214 |
+
- [ ] Batch processing multiple images
|
| 215 |
+
- [ ] Kernel composition (sequential filters)
|
| 216 |
+
- [ ] Frequency domain visualization (FFT)
|
| 217 |
+
- [ ] 3D kernel visualization
|
| 218 |
+
|
| 219 |
+
## Data Structures
|
| 220 |
+
|
| 221 |
+
### Kernel Definition
|
| 222 |
+
```typescript
|
| 223 |
+
interface Kernel {
|
| 224 |
+
id: string;
|
| 225 |
+
name: string;
|
| 226 |
+
category: 'blur' | 'sharpen' | 'edge' | 'emboss' | 'custom' | 'other';
|
| 227 |
+
matrix: number[][];
|
| 228 |
+
size: number;
|
| 229 |
+
normalize: boolean;
|
| 230 |
+
description: string;
|
| 231 |
+
formula?: string;
|
| 232 |
+
reference?: string;
|
| 233 |
+
}
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
### Convolution State
|
| 237 |
+
```typescript
|
| 238 |
+
interface ConvolutionState {
|
| 239 |
+
status: 'idle' | 'running' | 'paused' | 'completed' | 'error';
|
| 240 |
+
currentStep: ConvolutionStep | null;
|
| 241 |
+
progress: number; // 0-1
|
| 242 |
+
startTime: number;
|
| 243 |
+
elapsedTime: number;
|
| 244 |
+
processedPixels: number;
|
| 245 |
+
totalPixels: number;
|
| 246 |
+
outputImageData: ImageData | null;
|
| 247 |
+
}
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
### App Settings
|
| 251 |
+
```typescript
|
| 252 |
+
interface AppSettings {
|
| 253 |
+
animationSpeed: number; // 0.5 - 10
|
| 254 |
+
showOverlay: {
|
| 255 |
+
kernelBoundary: boolean;
|
| 256 |
+
pixelValues: boolean;
|
| 257 |
+
multiplications: boolean;
|
| 258 |
+
summation: boolean;
|
| 259 |
+
};
|
| 260 |
+
padding: 'zero' | 'reflect' | 'replicate' | 'wrap';
|
| 261 |
+
zoomLevel: number;
|
| 262 |
+
panOffset: { x: number; y: number };
|
| 263 |
+
comparisonMode: 'side-by-side' | 'split' | 'overlay';
|
| 264 |
+
}
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
## Testing Strategy
|
| 268 |
+
|
| 269 |
+
### Unit Tests
|
| 270 |
+
- Convolution algorithm correctness
|
| 271 |
+
- Padding strategy implementations
|
| 272 |
+
- Kernel normalization
|
| 273 |
+
- Step generator accuracy
|
| 274 |
+
- Export functionality
|
| 275 |
+
|
| 276 |
+
### Integration Tests
|
| 277 |
+
- Image upload → convolution → output pipeline
|
| 278 |
+
- Animation playback controls
|
| 279 |
+
- Comparison view synchronization
|
| 280 |
+
- State persistence/restoration
|
| 281 |
+
|
| 282 |
+
### Visual Regression Tests
|
| 283 |
+
- Canvas rendering at different zoom levels
|
| 284 |
+
- Overlay accuracy
|
| 285 |
+
- Split slider positions
|
| 286 |
+
- Kernel matrix display
|
| 287 |
+
|
| 288 |
+
### Performance Benchmarks
|
| 289 |
+
- Convolution speed (pixels/second)
|
| 290 |
+
- Animation frame rate
|
| 291 |
+
- Memory usage during large image processing
|
| 292 |
+
- Web Worker vs main thread performance
|
| 293 |
+
|
| 294 |
+
## Accessibility
|
| 295 |
+
- Keyboard navigation for all controls
|
| 296 |
+
- Screen reader announcements for animation state
|
| 297 |
+
- High contrast mode support
|
| 298 |
+
- Reduced motion preference respect
|
| 299 |
+
- Focus indicators
|
| 300 |
+
- Alt text for all visual elements
|
| 301 |
+
|
| 302 |
+
## Browser Support
|
| 303 |
+
- Chrome 90+
|
| 304 |
+
- Firefox 88+
|
| 305 |
+
- Safari 14+
|
| 306 |
+
- Edge 90+
|
| 307 |
+
- Mobile browsers (iOS Safari, Chrome Android)
|
| 308 |
+
|
| 309 |
+
## Deployment
|
| 310 |
+
- Static export to Vercel/Netlify
|
| 311 |
+
- CDN for kernel definitions and assets
|
| 312 |
+
- PWA support for offline use
|
| 313 |
+
- Analytics (privacy-friendly)
|
| 314 |
+
|
| 315 |
+
## Success Metrics
|
| 316 |
+
- Frame rate ≥ 30fps during animation
|
| 317 |
+
- Convolution of 1MP image < 2 seconds
|
| 318 |
+
- Time to interactive < 3 seconds
|
| 319 |
+
- Lighthouse score > 90
|
| 320 |
+
- Zero critical accessibility violations
|
| 321 |
+
|
| 322 |
+
## Risk Mitigation
|
| 323 |
+
| Risk | Mitigation |
|
| 324 |
+
|------|------------|
|
| 325 |
+
| Performance on large images | Web Workers, progressive rendering, WebGL fallback |
|
| 326 |
+
| Animation smoothness | RequestAnimationFrame, frame budgeting, step batching |
|
| 327 |
+
| Memory leaks | Canvas pooling, proper cleanup, WeakRef for caches |
|
| 328 |
+
| Cross-browser canvas differences | Feature detection, polyfills, fallback rendering |
|
| 329 |
+
| Complex state management | Zustand with devtools, immutable updates |
|
| 330 |
+
|
| 331 |
+
## Future Extensions
|
| 332 |
+
- Video convolution visualization
|
| 333 |
+
- 3D convolution (volumetric data)
|
| 334 |
+
- CNN layer visualization (multiple kernels, feature maps)
|
| 335 |
+
- Interactive kernel design with real-time preview
|
| 336 |
+
- Frequency domain analysis
|
| 337 |
+
- Integration with ML frameworks (TensorFlow.js, ONNX.js)
|
| 338 |
+
- Collaborative annotation/sharing
|
| 339 |
+
- Plugin system for custom kernels/effects
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/favicon.ico
DELETED
|
Binary file (3.87 kB)
|
|
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/index.html
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8" />
|
| 5 |
-
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
-
<meta name="theme-color" content="#000000" />
|
| 8 |
-
<meta
|
| 9 |
-
name="description"
|
| 10 |
-
content="Web site created using create-react-app"
|
| 11 |
-
/>
|
| 12 |
-
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
| 13 |
-
<!--
|
| 14 |
-
manifest.json provides metadata used when your web app is installed on a
|
| 15 |
-
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
| 16 |
-
-->
|
| 17 |
-
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
| 18 |
-
<!--
|
| 19 |
-
Notice the use of %PUBLIC_URL% in the tags above.
|
| 20 |
-
It will be replaced with the URL of the `public` folder during the build.
|
| 21 |
-
Only files inside the `public` folder can be referenced from the HTML.
|
| 22 |
-
|
| 23 |
-
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
| 24 |
-
work correctly both with client-side routing and a non-root public URL.
|
| 25 |
-
Learn how to configure a non-root public URL by running `npm run build`.
|
| 26 |
-
-->
|
| 27 |
-
<title>React App</title>
|
| 28 |
-
</head>
|
| 29 |
-
<body>
|
| 30 |
-
<noscript>You need to enable JavaScript to run this app.</noscript>
|
| 31 |
-
<div id="root"></div>
|
| 32 |
-
<!--
|
| 33 |
-
This HTML file is a template.
|
| 34 |
-
If you open it directly in the browser, you will see an empty page.
|
| 35 |
-
|
| 36 |
-
You can add webfonts, meta tags, or analytics to this file.
|
| 37 |
-
The build step will place the bundled scripts into the <body> tag.
|
| 38 |
-
|
| 39 |
-
To begin the development, run `npm start` or `yarn start`.
|
| 40 |
-
To create a production bundle, use `npm run build` or `yarn build`.
|
| 41 |
-
-->
|
| 42 |
-
</body>
|
| 43 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/logo192.png
DELETED
|
Binary file (5.35 kB)
|
|
|
public/logo512.png
DELETED
|
Binary file (9.66 kB)
|
|
|
public/manifest.json
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"short_name": "React App",
|
| 3 |
-
"name": "Create React App Sample",
|
| 4 |
-
"icons": [
|
| 5 |
-
{
|
| 6 |
-
"src": "favicon.ico",
|
| 7 |
-
"sizes": "64x64 32x32 24x24 16x16",
|
| 8 |
-
"type": "image/x-icon"
|
| 9 |
-
},
|
| 10 |
-
{
|
| 11 |
-
"src": "logo192.png",
|
| 12 |
-
"type": "image/png",
|
| 13 |
-
"sizes": "192x192"
|
| 14 |
-
},
|
| 15 |
-
{
|
| 16 |
-
"src": "logo512.png",
|
| 17 |
-
"type": "image/png",
|
| 18 |
-
"sizes": "512x512"
|
| 19 |
-
}
|
| 20 |
-
],
|
| 21 |
-
"start_url": ".",
|
| 22 |
-
"display": "standalone",
|
| 23 |
-
"theme_color": "#000000",
|
| 24 |
-
"background_color": "#ffffff"
|
| 25 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/next.svg
ADDED
|
|
public/robots.txt
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
# https://www.robotstxt.org/robotstxt.html
|
| 2 |
-
User-agent: *
|
| 3 |
-
Disallow:
|
|
|
|
|
|
|
|
|
|
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/App.css
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
.App {
|
| 2 |
-
text-align: center;
|
| 3 |
-
}
|
| 4 |
-
|
| 5 |
-
.App-logo {
|
| 6 |
-
height: 40vmin;
|
| 7 |
-
pointer-events: none;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
@media (prefers-reduced-motion: no-preference) {
|
| 11 |
-
.App-logo {
|
| 12 |
-
animation: App-logo-spin infinite 20s linear;
|
| 13 |
-
}
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
.App-header {
|
| 17 |
-
background-color: #282c34;
|
| 18 |
-
min-height: 100vh;
|
| 19 |
-
display: flex;
|
| 20 |
-
flex-direction: column;
|
| 21 |
-
align-items: center;
|
| 22 |
-
justify-content: center;
|
| 23 |
-
font-size: calc(10px + 2vmin);
|
| 24 |
-
color: white;
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
.App-link {
|
| 28 |
-
color: #61dafb;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
@keyframes App-logo-spin {
|
| 32 |
-
from {
|
| 33 |
-
transform: rotate(0deg);
|
| 34 |
-
}
|
| 35 |
-
to {
|
| 36 |
-
transform: rotate(360deg);
|
| 37 |
-
}
|
| 38 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
import logo from './logo.svg';
|
| 2 |
-
import './App.css';
|
| 3 |
-
|
| 4 |
-
function App() {
|
| 5 |
-
return (
|
| 6 |
-
<div className="App">
|
| 7 |
-
<header className="App-header">
|
| 8 |
-
<img src={logo} className="App-logo" alt="logo" />
|
| 9 |
-
<p>
|
| 10 |
-
Edit <code>src/App.js</code> and save to reload.
|
| 11 |
-
</p>
|
| 12 |
-
<a
|
| 13 |
-
className="App-link"
|
| 14 |
-
href="https://reactjs.org"
|
| 15 |
-
target="_blank"
|
| 16 |
-
rel="noopener noreferrer"
|
| 17 |
-
>
|
| 18 |
-
Learn React
|
| 19 |
-
</a>
|
| 20 |
-
</header>
|
| 21 |
-
</div>
|
| 22 |
-
);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
export default App;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.test.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
import { render, screen } from '@testing-library/react';
|
| 2 |
-
import App from './App';
|
| 3 |
-
|
| 4 |
-
test('renders learn react link', () => {
|
| 5 |
-
render(<App />);
|
| 6 |
-
const linkElement = screen.getByText(/learn react/i);
|
| 7 |
-
expect(linkElement).toBeInTheDocument();
|
| 8 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/favicon.ico
ADDED
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--background: #f9fafb;
|
| 5 |
+
--foreground: #111827;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@theme inline {
|
| 9 |
+
--color-background: var(--background);
|
| 10 |
+
--color-foreground: var(--foreground);
|
| 11 |
+
--font-sans: var(--font-geist-sans);
|
| 12 |
+
--font-mono: var(--font-geist-mono);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
@media (prefers-color-scheme: dark) {
|
| 16 |
+
:root {
|
| 17 |
+
--background: #111827;
|
| 18 |
+
--foreground: #f9fafb;
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* {
|
| 23 |
+
box-sizing: border-box;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
body {
|
| 27 |
+
background: var(--background);
|
| 28 |
+
color: var(--foreground);
|
| 29 |
+
font-family: var(--font-sans);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* Custom scrollbar */
|
| 33 |
+
::-webkit-scrollbar {
|
| 34 |
+
width: 8px;
|
| 35 |
+
height: 8px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
::-webkit-scrollbar-track {
|
| 39 |
+
background: transparent;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
::-webkit-scrollbar-thumb {
|
| 43 |
+
background: #9ca3af;
|
| 44 |
+
border-radius: 4px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
::-webkit-scrollbar-thumb:hover {
|
| 48 |
+
background: #6b7280;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* Focus visible styles */
|
| 52 |
+
:focus-visible {
|
| 53 |
+
outline: 2px solid #3b82f6;
|
| 54 |
+
outline-offset: 2px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* Animations */
|
| 58 |
+
@keyframes slide-in {
|
| 59 |
+
from {
|
| 60 |
+
opacity: 0;
|
| 61 |
+
transform: translateY(10px);
|
| 62 |
+
}
|
| 63 |
+
to {
|
| 64 |
+
opacity: 1;
|
| 65 |
+
transform: translateY(0);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.animate-slide-in {
|
| 70 |
+
animation: slide-in 0.3s ease-out;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* Canvas styles */
|
| 74 |
+
canvas {
|
| 75 |
+
display: block;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Selection styles */
|
| 79 |
+
::selection {
|
| 80 |
+
background: #3b82f6;
|
| 81 |
+
color: white;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Input range styles */
|
| 85 |
+
input[type="range"] {
|
| 86 |
+
-webkit-appearance: none;
|
| 87 |
+
appearance: none;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
input[type="range"]::-webkit-slider-thumb {
|
| 91 |
+
-webkit-appearance: none;
|
| 92 |
+
appearance: none;
|
| 93 |
+
width: 16px;
|
| 94 |
+
height: 16px;
|
| 95 |
+
border-radius: 50%;
|
| 96 |
+
background: #3b82f6;
|
| 97 |
+
cursor: pointer;
|
| 98 |
+
border: 2px solid white;
|
| 99 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
input[type="range"]::-moz-range-thumb {
|
| 103 |
+
width: 16px;
|
| 104 |
+
height: 16px;
|
| 105 |
+
border-radius: 50%;
|
| 106 |
+
background: #3b82f6;
|
| 107 |
+
cursor: pointer;
|
| 108 |
+
border: 2px solid white;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/* Number input arrows */
|
| 112 |
+
input[type="number"]::-webkit-inner-spin-button,
|
| 113 |
+
input[type="number"]::-webkit-outer-spin-button {
|
| 114 |
+
opacity: 1;
|
| 115 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
|
| 5 |
+
const geistSans = Geist({
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const geistMono = Geist_Mono({
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "Interactive Kernel Convolution Visualizer",
|
| 17 |
+
description: "Learn how image convolution works with interactive step-by-step visualization",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
| 27 |
+
<body className="min-h-full flex flex-col">{children}</body>
|
| 28 |
+
</html>
|
| 29 |
+
);
|
| 30 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useCallback } from 'react'
|
| 4 |
+
import { useAppStore, useImageStore, useUIStore } from '@/store'
|
| 5 |
+
import { KernelSelector } from '@/components/kernel/KernelSelector'
|
| 6 |
+
import { PlaybackControls } from '@/components/controls/PlaybackControls'
|
| 7 |
+
import { OverlayControls, PaddingControls, KernelInfoPanel } from '@/components/controls/OverlayControls'
|
| 8 |
+
import { ConvolutionCanvas } from '@/components/canvas/ConvolutionCanvas'
|
| 9 |
+
import { SplitView } from '@/components/canvas/SplitView'
|
| 10 |
+
import { ImageCanvas } from '@/components/canvas/ImageCanvas'
|
| 11 |
+
import { Button, Card, CardContent, Badge } from '@/components/ui'
|
| 12 |
+
import { useImageLoader, useDragAndDrop } from '@/hooks/useImageLoader'
|
| 13 |
+
import { useConvolution } from '@/hooks/useConvolution'
|
| 14 |
+
import { cn, formatNumber } from '@/utils/helpers'
|
| 15 |
+
import {
|
| 16 |
+
Upload,
|
| 17 |
+
Image as ImageIcon,
|
| 18 |
+
Download,
|
| 19 |
+
RotateCcw,
|
| 20 |
+
HelpCircle,
|
| 21 |
+
Settings,
|
| 22 |
+
ChevronLeft,
|
| 23 |
+
ChevronRight,
|
| 24 |
+
} from 'lucide-react'
|
| 25 |
+
|
| 26 |
+
export default function Home() {
|
| 27 |
+
const {
|
| 28 |
+
originalImageData,
|
| 29 |
+
processedImageData,
|
| 30 |
+
selectedKernel,
|
| 31 |
+
convolution,
|
| 32 |
+
settings,
|
| 33 |
+
isLoading,
|
| 34 |
+
error,
|
| 35 |
+
showHelp,
|
| 36 |
+
showSettings,
|
| 37 |
+
setOriginalImage,
|
| 38 |
+
setError,
|
| 39 |
+
toggleHelp,
|
| 40 |
+
toggleSettings,
|
| 41 |
+
resetConvolution,
|
| 42 |
+
} = useAppStore()
|
| 43 |
+
|
| 44 |
+
const { runConvolution, pause, resume, stop, step, reset, isRunning, isPaused, canRun } = useConvolution()
|
| 45 |
+
const { fileInputRef, openFileDialog, handleFileSelect } = useImageLoader()
|
| 46 |
+
const { handleDragOver, handleDragLeave, handleDrop, isDragging } = useDragAndDrop((image, imageData) => {
|
| 47 |
+
setOriginalImage(image, imageData)
|
| 48 |
+
resetConvolution()
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
const [showComparison, setShowComparison] = useState(false)
|
| 52 |
+
const [sidebarWidth, setSidebarWidth] = useState(320)
|
| 53 |
+
|
| 54 |
+
const handleImageLoad = useCallback((data: { image: HTMLImageElement; imageData: ImageData }) => {
|
| 55 |
+
setOriginalImage(data.image, data.imageData)
|
| 56 |
+
resetConvolution()
|
| 57 |
+
}, [setOriginalImage, resetConvolution])
|
| 58 |
+
|
| 59 |
+
const handleDownload = useCallback(() => {
|
| 60 |
+
if (!processedImageData) return
|
| 61 |
+
const canvas = document.createElement('canvas')
|
| 62 |
+
canvas.width = processedImageData.width
|
| 63 |
+
canvas.height = processedImageData.height
|
| 64 |
+
const ctx = canvas.getContext('2d')
|
| 65 |
+
if (!ctx) return
|
| 66 |
+
ctx.putImageData(processedImageData, 0, 0)
|
| 67 |
+
const link = document.createElement('a')
|
| 68 |
+
link.download = 'convolved-image.png'
|
| 69 |
+
link.href = canvas.toDataURL('image/png')
|
| 70 |
+
link.click()
|
| 71 |
+
}, [processedImageData])
|
| 72 |
+
|
| 73 |
+
const handleReset = useCallback(() => {
|
| 74 |
+
reset()
|
| 75 |
+
resetConvolution()
|
| 76 |
+
}, [reset, resetConvolution])
|
| 77 |
+
|
| 78 |
+
if (isLoading) {
|
| 79 |
+
return (
|
| 80 |
+
<div className="fixed inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-50">
|
| 81 |
+
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent" />
|
| 82 |
+
</div>
|
| 83 |
+
)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div
|
| 88 |
+
className="h-screen w-screen flex flex-col bg-gray-50 dark:bg-gray-900 overflow-hidden"
|
| 89 |
+
onDragOver={handleDragOver}
|
| 90 |
+
onDragLeave={handleDragLeave}
|
| 91 |
+
onDrop={handleDrop}
|
| 92 |
+
>
|
| 93 |
+
{/* Header */}
|
| 94 |
+
<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">
|
| 95 |
+
<div className="flex items-center gap-4">
|
| 96 |
+
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
| 97 |
+
Kernel Convolution Visualizer
|
| 98 |
+
</h1>
|
| 99 |
+
<Badge variant="outline" className="text-xs">
|
| 100 |
+
Educational Tool
|
| 101 |
+
</Badge>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div className="flex items-center gap-2">
|
| 105 |
+
<Button variant="ghost" size="icon" onClick={toggleHelp} className="relative">
|
| 106 |
+
<HelpCircle className="h-5 w-5" />
|
| 107 |
+
</Button>
|
| 108 |
+
<Button variant="ghost" size="icon" onClick={toggleSettings}>
|
| 109 |
+
<Settings className="h-5 w-5" />
|
| 110 |
+
</Button>
|
| 111 |
+
</div>
|
| 112 |
+
</header>
|
| 113 |
+
|
| 114 |
+
{/* Main Content */}
|
| 115 |
+
<main className="flex-1 flex overflow-hidden">
|
| 116 |
+
{/* Left Sidebar - Controls */}
|
| 117 |
+
<aside
|
| 118 |
+
className={cn(
|
| 119 |
+
'flex flex-col bg-white dark:bg-gray-800 border-r dark:border-gray-700 overflow-y-auto transition-all duration-200',
|
| 120 |
+
'min-w-[280px] max-w-[400px]'
|
| 121 |
+
)}
|
| 122 |
+
style={{ width: sidebarWidth }}
|
| 123 |
+
>
|
| 124 |
+
{/* Image Upload */}
|
| 125 |
+
<Card className="m-3">
|
| 126 |
+
<CardContent className="p-4">
|
| 127 |
+
<div
|
| 128 |
+
className={cn(
|
| 129 |
+
'relative border-2 dashed rounded-lg p-6 text-center transition-colors',
|
| 130 |
+
'cursor-pointer',
|
| 131 |
+
isDragging
|
| 132 |
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
| 133 |
+
: 'border-gray-300 dark:border-gray-600 hover:border-blue-400'
|
| 134 |
+
)}
|
| 135 |
+
onClick={openFileDialog}
|
| 136 |
+
>
|
| 137 |
+
<input
|
| 138 |
+
ref={fileInputRef}
|
| 139 |
+
type="file"
|
| 140 |
+
accept="image/*"
|
| 141 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 142 |
+
onChange={(e) => handleFileSelect(e, handleImageLoad)}
|
| 143 |
+
/>
|
| 144 |
+
<ImageIcon className="h-12 w-12 mx-auto text-gray-400 mb-3" />
|
| 145 |
+
<p className="text-gray-600 dark:text-gray-400">
|
| 146 |
+
{originalImageData ? 'Click to change image' : 'Drag & drop or click to upload'}
|
| 147 |
+
</p>
|
| 148 |
+
<p className="text-sm text-gray-400 mt-1">PNG, JPG, WebP, BMP</p>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{originalImageData && (
|
| 152 |
+
<div className="mt-3 flex items-center justify-between text-sm">
|
| 153 |
+
<span className="text-gray-600 dark:text-gray-400">
|
| 154 |
+
{originalImageData.width} × {originalImageData.height}
|
| 155 |
+
</span>
|
| 156 |
+
<div className="flex gap-2">
|
| 157 |
+
<Button variant="outline" size="sm" onClick={handleDownload}>
|
| 158 |
+
<Download className="h-4 w-4" />
|
| 159 |
+
Save
|
| 160 |
+
</Button>
|
| 161 |
+
<Button variant="outline" size="sm" onClick={handleReset}>
|
| 162 |
+
<RotateCcw className="h-4 w-4" />
|
| 163 |
+
Reset
|
| 164 |
+
</Button>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
)}
|
| 168 |
+
</CardContent>
|
| 169 |
+
</Card>
|
| 170 |
+
|
| 171 |
+
{/* Kernel Selector */}
|
| 172 |
+
<div className="flex-1 m-3 min-h-0">
|
| 173 |
+
<KernelSelector />
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Playback Controls */}
|
| 177 |
+
<div className="m-3">
|
| 178 |
+
<PlaybackControls />
|
| 179 |
+
</div>
|
| 180 |
+
</aside>
|
| 181 |
+
|
| 182 |
+
{/* Resizer */}
|
| 183 |
+
<div
|
| 184 |
+
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"
|
| 185 |
+
onMouseDown={(e) => {
|
| 186 |
+
const startX = e.clientX
|
| 187 |
+
const startWidth = sidebarWidth
|
| 188 |
+
const onMouseMove = (e: MouseEvent) => {
|
| 189 |
+
setSidebarWidth(Math.max(280, Math.min(400, startWidth + (e.clientX - startX))))
|
| 190 |
+
}
|
| 191 |
+
const onMouseUp = () => {
|
| 192 |
+
document.removeEventListener('mousemove', onMouseMove)
|
| 193 |
+
document.removeEventListener('mouseup', onMouseUp)
|
| 194 |
+
}
|
| 195 |
+
document.addEventListener('mousemove', onMouseMove)
|
| 196 |
+
document.addEventListener('mouseup', onMouseUp)
|
| 197 |
+
}}
|
| 198 |
+
>
|
| 199 |
+
<div className="w-px h-8 bg-gray-300 dark:bg-gray-600 rounded" />
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
{/* Center - Visualization */}
|
| 203 |
+
<section className="flex-1 flex flex-col overflow-hidden p-4 min-w-0">
|
| 204 |
+
{originalImageData ? (
|
| 205 |
+
<>
|
| 206 |
+
{/* Convolution Visualization */}
|
| 207 |
+
<div className="flex-1 min-h-0 relative">
|
| 208 |
+
<ConvolutionCanvas
|
| 209 |
+
width={originalImageData.width}
|
| 210 |
+
height={originalImageData.height}
|
| 211 |
+
className="w-full h-full"
|
| 212 |
+
/>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* Comparison View Toggle */}
|
| 216 |
+
<div className="flex items-center justify-center gap-4 mt-4">
|
| 217 |
+
<Button
|
| 218 |
+
variant={showComparison ? 'primary' : 'outline'}
|
| 219 |
+
onClick={() => setShowComparison(!showComparison)}
|
| 220 |
+
>
|
| 221 |
+
{showComparison ? <ChevronLeft className="h-4 w-4 mr-2" /> : <ChevronRight className="h-4 w-4 mr-2" />}
|
| 222 |
+
{showComparison ? 'Hide Comparison' : 'Show Comparison'}
|
| 223 |
+
</Button>
|
| 224 |
+
|
| 225 |
+
{selectedKernel && (
|
| 226 |
+
<Badge variant="secondary">
|
| 227 |
+
{selectedKernel.name} ({selectedKernel.size}×{selectedKernel.size})
|
| 228 |
+
</Badge>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
{convolution.status !== 'idle' && (
|
| 232 |
+
<Badge variant={convolution.status === 'completed' ? 'success' : 'default'}>
|
| 233 |
+
{convolution.status}
|
| 234 |
+
</Badge>
|
| 235 |
+
)}
|
| 236 |
+
</div>
|
| 237 |
+
</>
|
| 238 |
+
) : (
|
| 239 |
+
<div className="flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
| 240 |
+
<div className="text-center text-gray-500">
|
| 241 |
+
<ImageIcon className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
| 242 |
+
<p className="text-lg">Upload an image to begin</p>
|
| 243 |
+
<p className="text-sm mt-2">Drag & drop or click the upload area</p>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
)}
|
| 247 |
+
|
| 248 |
+
{/* Comparison View */}
|
| 249 |
+
{showComparison && processedImageData && (
|
| 250 |
+
<div className="mt-4 h-96 flex-1">
|
| 251 |
+
<SplitView
|
| 252 |
+
width={originalImageData?.width || 800}
|
| 253 |
+
height={originalImageData?.height || 600}
|
| 254 |
+
className="w-full h-full"
|
| 255 |
+
/>
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
+
</section>
|
| 259 |
+
|
| 260 |
+
{/* Right Sidebar - Info Panels */}
|
| 261 |
+
<aside className="w-80 flex flex-col bg-white dark:bg-gray-800 border-l dark:border-gray-700 overflow-y-auto">
|
| 262 |
+
<div className="p-4 space-y-4">
|
| 263 |
+
<KernelInfoPanel />
|
| 264 |
+
<OverlayControls />
|
| 265 |
+
<PaddingControls />
|
| 266 |
+
</div>
|
| 267 |
+
</aside>
|
| 268 |
+
</main>
|
| 269 |
+
|
| 270 |
+
{/* Error Toast */}
|
| 271 |
+
{error && (
|
| 272 |
+
<div className="fixed bottom-4 right-4 z-50 animate-slide-in">
|
| 273 |
+
<div className="bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-md">
|
| 274 |
+
<span className="flex-1 text-sm">{error}</span>
|
| 275 |
+
<Button variant="ghost" size="sm" onClick={() => setError(null)}>
|
| 276 |
+
Dismiss
|
| 277 |
+
</Button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
+
|
| 282 |
+
{/* Help Modal */}
|
| 283 |
+
{showHelp && (
|
| 284 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
| 285 |
+
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
| 286 |
+
<div className="p-6">
|
| 287 |
+
<h2 className="text-xl font-bold mb-4">How to Use</h2>
|
| 288 |
+
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
|
| 289 |
+
<div>
|
| 290 |
+
<h3 className="font-medium mb-2">1. Upload an Image</h3>
|
| 291 |
+
<p>Drag & drop or click the upload area in the left sidebar.</p>
|
| 292 |
+
</div>
|
| 293 |
+
<div>
|
| 294 |
+
<h3 className="font-medium mb-2">2. Select a Kernel</h3>
|
| 295 |
+
<p>Choose from predefined kernels (Blur, Sharpen, Edge Detection, etc.) or create your own custom kernel.</p>
|
| 296 |
+
</div>
|
| 297 |
+
<div>
|
| 298 |
+
<h3 className="font-medium mb-2">3. Watch the Convolution</h3>
|
| 299 |
+
<p>Press Play to see the kernel move across the image pixel by pixel. The visualization shows:</p>
|
| 300 |
+
<ul className="list-disc list-inside mt-1 space-y-1">
|
| 301 |
+
<li>Current image patch under the kernel</li>
|
| 302 |
+
<li>Element-wise multiplication of kernel and pixel values</li>
|
| 303 |
+
<li>Summation that produces the output pixel</li>
|
| 304 |
+
</ul>
|
| 305 |
+
</div>
|
| 306 |
+
<div>
|
| 307 |
+
<h3 className="font-medium mb-2">4. Control Playback</h3>
|
| 308 |
+
<ul className="list-disc list-inside mt-1 space-y-1">
|
| 309 |
+
<li>Play/Pause/Step through the animation</li>
|
| 310 |
+
<li>Adjust animation speed</li>
|
| 311 |
+
<li>Zoom and pan for pixel-level inspection</li>
|
| 312 |
+
</ul>
|
| 313 |
+
</div>
|
| 314 |
+
<div>
|
| 315 |
+
<h3 className="font-medium mb-2">5. Compare Results</h3>
|
| 316 |
+
<p>Click “Show Comparison” to see side-by-side or split view of original vs processed image.</p>
|
| 317 |
+
</div>
|
| 318 |
+
<div>
|
| 319 |
+
<h3 className="font-medium mb-2">6. Experiment</h3>
|
| 320 |
+
<ul className="list-disc list-inside mt-1 space-y-1">
|
| 321 |
+
<li>Try different padding strategies (Zero, Reflect, Replicate, Wrap)</li>
|
| 322 |
+
<li>Toggle overlays to see different visualization aspects</li>
|
| 323 |
+
<li>Create custom kernels with the editor</li>
|
| 324 |
+
<li>Download the processed image</li>
|
| 325 |
+
</ul>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
<div className="mt-6 flex justify-end">
|
| 329 |
+
<Button onClick={toggleHelp}>Got it</Button>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
|
| 336 |
+
{/* Settings Modal */}
|
| 337 |
+
{showSettings && (
|
| 338 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
| 339 |
+
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full mx-4">
|
| 340 |
+
<div className="p-6">
|
| 341 |
+
<h2 className="text-xl font-bold mb-4">Settings</h2>
|
| 342 |
+
<div className="space-y-4">
|
| 343 |
+
<label className="flex items-center gap-3 cursor-pointer">
|
| 344 |
+
<input
|
| 345 |
+
type="checkbox"
|
| 346 |
+
checked={settings.showOverlay.kernelBoundary}
|
| 347 |
+
onChange={() => useAppStore.getState().toggleOverlay('kernelBoundary')}
|
| 348 |
+
className="rounded border-gray-300"
|
| 349 |
+
/>
|
| 350 |
+
<span>Show Kernel Boundary</span>
|
| 351 |
+
</label>
|
| 352 |
+
<label className="flex items-center gap-3 cursor-pointer">
|
| 353 |
+
<input
|
| 354 |
+
type="checkbox"
|
| 355 |
+
checked={settings.showOverlay.pixelValues}
|
| 356 |
+
onChange={() => useAppStore.getState().toggleOverlay('pixelValues')}
|
| 357 |
+
className="rounded border-gray-300"
|
| 358 |
+
/>
|
| 359 |
+
<span>Show Pixel Values</span>
|
| 360 |
+
</label>
|
| 361 |
+
<label className="flex items-center gap-3 cursor-pointer">
|
| 362 |
+
<input
|
| 363 |
+
type="checkbox"
|
| 364 |
+
checked={settings.showOverlay.multiplications}
|
| 365 |
+
onChange={() => useAppStore.getState().toggleOverlay('multiplications')}
|
| 366 |
+
className="rounded border-gray-300"
|
| 367 |
+
/>
|
| 368 |
+
<span>Show Multiplications</span>
|
| 369 |
+
</label>
|
| 370 |
+
<label className="flex items-center gap-3 cursor-pointer">
|
| 371 |
+
<input
|
| 372 |
+
type="checkbox"
|
| 373 |
+
checked={settings.showOverlay.summation}
|
| 374 |
+
onChange={() => useAppStore.getState().toggleOverlay('summation')}
|
| 375 |
+
className="rounded border-gray-300"
|
| 376 |
+
/>
|
| 377 |
+
<span>Show Summation</span>
|
| 378 |
+
</label>
|
| 379 |
+
</div>
|
| 380 |
+
<div className="mt-6 flex justify-end">
|
| 381 |
+
<Button onClick={toggleSettings}>Close</Button>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
)}
|
| 387 |
+
</div>
|
| 388 |
+
)
|
| 389 |
+
}
|
src/components/canvas/ConvolutionCanvas.tsx
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useRef, useEffect, useCallback, useMemo } from 'react'
|
| 4 |
+
import { useAppStore, useConvolutionStore, useSettingsStore } from '@/store'
|
| 5 |
+
import type { ConvolutionStep } from '@/types'
|
| 6 |
+
import { cn } from '@/utils/helpers'
|
| 7 |
+
|
| 8 |
+
interface ConvolutionCanvasProps {
|
| 9 |
+
width?: number
|
| 10 |
+
height?: number
|
| 11 |
+
className?: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function ConvolutionCanvas({ width = 800, height = 600, className }: ConvolutionCanvasProps) {
|
| 15 |
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 16 |
+
const animationRef = useRef<number | null>(null)
|
| 17 |
+
|
| 18 |
+
const { originalImageData, processedImageData, convolution } = useAppStore()
|
| 19 |
+
const { showOverlay, zoomLevel, panOffset, padding } = useSettingsStore()
|
| 20 |
+
const currentStep = convolution.currentStep
|
| 21 |
+
|
| 22 |
+
const drawCanvas = useCallback(() => {
|
| 23 |
+
const canvas = canvasRef.current
|
| 24 |
+
const ctx = canvas?.getContext('2d', { willReadFrequently: true })
|
| 25 |
+
if (!canvas || !ctx) return
|
| 26 |
+
|
| 27 |
+
const displayWidth = Math.round(width * zoomLevel)
|
| 28 |
+
const displayHeight = Math.round(height * zoomLevel)
|
| 29 |
+
|
| 30 |
+
canvas.width = displayWidth
|
| 31 |
+
canvas.height = displayHeight
|
| 32 |
+
|
| 33 |
+
ctx.clearRect(0, 0, displayWidth, displayHeight)
|
| 34 |
+
ctx.imageSmoothingEnabled = false
|
| 35 |
+
|
| 36 |
+
// Draw original image as background (dimmed)
|
| 37 |
+
if (originalImageData) {
|
| 38 |
+
const tempCanvas = document.createElement('canvas')
|
| 39 |
+
tempCanvas.width = originalImageData.width
|
| 40 |
+
tempCanvas.height = originalImageData.height
|
| 41 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 42 |
+
if (tempCtx) {
|
| 43 |
+
tempCtx.putImageData(originalImageData, 0, 0)
|
| 44 |
+
ctx.globalAlpha = 0.3
|
| 45 |
+
ctx.drawImage(
|
| 46 |
+
tempCanvas,
|
| 47 |
+
0,
|
| 48 |
+
0,
|
| 49 |
+
originalImageData.width,
|
| 50 |
+
originalImageData.height,
|
| 51 |
+
panOffset.x,
|
| 52 |
+
panOffset.y,
|
| 53 |
+
originalImageData.width * zoomLevel,
|
| 54 |
+
originalImageData.height * zoomLevel
|
| 55 |
+
)
|
| 56 |
+
ctx.globalAlpha = 1
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Draw processed image progressively
|
| 61 |
+
if (processedImageData) {
|
| 62 |
+
const tempCanvas = document.createElement('canvas')
|
| 63 |
+
tempCanvas.width = processedImageData.width
|
| 64 |
+
tempCanvas.height = processedImageData.height
|
| 65 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 66 |
+
if (tempCtx) {
|
| 67 |
+
tempCtx.putImageData(processedImageData, 0, 0)
|
| 68 |
+
ctx.drawImage(
|
| 69 |
+
tempCanvas,
|
| 70 |
+
0,
|
| 71 |
+
0,
|
| 72 |
+
processedImageData.width,
|
| 73 |
+
processedImageData.height,
|
| 74 |
+
panOffset.x,
|
| 75 |
+
panOffset.y,
|
| 76 |
+
processedImageData.width * zoomLevel,
|
| 77 |
+
processedImageData.height * zoomLevel
|
| 78 |
+
)
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Draw convolution visualization overlays
|
| 83 |
+
if (currentStep && currentStep.x >= 0 && currentStep.y >= 0) {
|
| 84 |
+
drawConvolutionOverlay(ctx, currentStep, zoomLevel, panOffset, showOverlay)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Draw progress indicator
|
| 88 |
+
if (convolution.status === 'running' || convolution.status === 'paused') {
|
| 89 |
+
drawProgressBar(ctx, displayWidth, displayHeight, convolution.progress)
|
| 90 |
+
}
|
| 91 |
+
}, [
|
| 92 |
+
originalImageData,
|
| 93 |
+
processedImageData,
|
| 94 |
+
currentStep,
|
| 95 |
+
convolution.status,
|
| 96 |
+
convolution.progress,
|
| 97 |
+
zoomLevel,
|
| 98 |
+
panOffset,
|
| 99 |
+
showOverlay,
|
| 100 |
+
width,
|
| 101 |
+
height,
|
| 102 |
+
])
|
| 103 |
+
|
| 104 |
+
useEffect(() => {
|
| 105 |
+
drawCanvas()
|
| 106 |
+
}, [drawCanvas])
|
| 107 |
+
|
| 108 |
+
// Animation loop for smooth rendering
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
const animate = () => {
|
| 111 |
+
drawCanvas()
|
| 112 |
+
animationRef.current = requestAnimationFrame(animate)
|
| 113 |
+
}
|
| 114 |
+
animationRef.current = requestAnimationFrame(animate)
|
| 115 |
+
return () => {
|
| 116 |
+
if (animationRef.current) cancelAnimationFrame(animationRef.current)
|
| 117 |
+
}
|
| 118 |
+
}, [drawCanvas])
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div className={cn('relative overflow-hidden bg-gray-100 dark:bg-gray-900 rounded-lg', className)}>
|
| 122 |
+
<canvas
|
| 123 |
+
ref={canvasRef}
|
| 124 |
+
width={width}
|
| 125 |
+
height={height}
|
| 126 |
+
className="block"
|
| 127 |
+
style={{ touchAction: 'none' }}
|
| 128 |
+
/>
|
| 129 |
+
</div>
|
| 130 |
+
)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function drawConvolutionOverlay(
|
| 134 |
+
ctx: CanvasRenderingContext2D,
|
| 135 |
+
step: ConvolutionStep,
|
| 136 |
+
zoom: number,
|
| 137 |
+
pan: { x: number; y: number },
|
| 138 |
+
showOverlay: {
|
| 139 |
+
kernelBoundary: boolean
|
| 140 |
+
pixelValues: boolean
|
| 141 |
+
multiplications: boolean
|
| 142 |
+
summation: boolean
|
| 143 |
+
}
|
| 144 |
+
) {
|
| 145 |
+
const { x, y, inputPatch, kernel, multiplications, sum, normalizedValue, channel } = step
|
| 146 |
+
const kernelSize = kernel.length
|
| 147 |
+
const halfKernel = Math.floor(kernelSize / 2)
|
| 148 |
+
const cellSize = Math.max(20, Math.min(60, 40 * zoom))
|
| 149 |
+
|
| 150 |
+
const centerX = pan.x + x * zoom
|
| 151 |
+
const centerY = pan.y + y * zoom
|
| 152 |
+
|
| 153 |
+
// Draw kernel boundary
|
| 154 |
+
if (showOverlay.kernelBoundary) {
|
| 155 |
+
ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)'
|
| 156 |
+
ctx.lineWidth = 2
|
| 157 |
+
ctx.setLineDash([5, 5])
|
| 158 |
+
ctx.strokeRect(
|
| 159 |
+
centerX - halfKernel * cellSize * zoom,
|
| 160 |
+
centerY - halfKernel * cellSize * zoom,
|
| 161 |
+
kernelSize * cellSize * zoom,
|
| 162 |
+
kernelSize * cellSize * zoom
|
| 163 |
+
)
|
| 164 |
+
ctx.setLineDash([])
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Draw kernel matrix with values
|
| 168 |
+
if (showOverlay.pixelValues || showOverlay.multiplications) {
|
| 169 |
+
const fontSize = Math.max(10, Math.min(14, 12 * zoom))
|
| 170 |
+
ctx.font = `${fontSize}px monospace`
|
| 171 |
+
ctx.textAlign = 'center'
|
| 172 |
+
ctx.textBaseline = 'middle'
|
| 173 |
+
|
| 174 |
+
for (let ky = 0; ky < kernelSize; ky++) {
|
| 175 |
+
for (let kx = 0; kx < kernelSize; kx++) {
|
| 176 |
+
const cellX = centerX + (kx - halfKernel) * cellSize * zoom
|
| 177 |
+
const cellY = centerY + (ky - halfKernel) * cellSize * zoom
|
| 178 |
+
|
| 179 |
+
// Cell background
|
| 180 |
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
|
| 181 |
+
ctx.fillRect(cellX - cellSize / 2, cellY - cellSize / 2, cellSize, cellSize)
|
| 182 |
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'
|
| 183 |
+
ctx.strokeRect(cellX - cellSize / 2, cellY - cellSize / 2, cellSize, cellSize)
|
| 184 |
+
|
| 185 |
+
if (showOverlay.multiplications && multiplications[ky]?.[kx] !== undefined) {
|
| 186 |
+
// Show multiplication: pixel × kernel
|
| 187 |
+
const pixelVal = inputPatch[ky]?.[kx] ?? 0
|
| 188 |
+
const kernelVal = kernel[ky]?.[kx] ?? 0
|
| 189 |
+
const mult = multiplications[ky][kx]
|
| 190 |
+
|
| 191 |
+
ctx.fillStyle = '#1e40af'
|
| 192 |
+
ctx.fillText(`${pixelVal.toFixed(2)}×${kernelVal.toFixed(2)}`, cellX, cellY - fontSize / 2)
|
| 193 |
+
ctx.fillStyle = '#dc2626'
|
| 194 |
+
ctx.fillText(`=${mult.toFixed(3)}`, cellX, cellY + fontSize / 2)
|
| 195 |
+
} else if (showOverlay.pixelValues) {
|
| 196 |
+
// Show kernel value
|
| 197 |
+
const kernelVal = kernel[ky]?.[kx] ?? 0
|
| 198 |
+
ctx.fillStyle = '#1e40af'
|
| 199 |
+
ctx.fillText(kernelVal.toFixed(2), cellX, cellY)
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Draw summation display
|
| 206 |
+
if (showOverlay.summation) {
|
| 207 |
+
const fontSize = Math.max(12, Math.min(16, 14 * zoom))
|
| 208 |
+
ctx.font = `bold ${fontSize}px monospace`
|
| 209 |
+
ctx.textAlign = 'left'
|
| 210 |
+
ctx.textBaseline = 'top'
|
| 211 |
+
|
| 212 |
+
const sumX = centerX + (halfKernel + 1) * cellSize * zoom
|
| 213 |
+
const sumY = centerY - halfKernel * cellSize * zoom
|
| 214 |
+
|
| 215 |
+
// Background
|
| 216 |
+
const padding = 8
|
| 217 |
+
const textWidth = ctx.measureText(`Σ = ${sum.toFixed(3)}`).width + padding * 2
|
| 218 |
+
const textHeight = fontSize * 3 + padding * 2
|
| 219 |
+
|
| 220 |
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'
|
| 221 |
+
ctx.fillRect(sumX - padding, sumY - padding, textWidth, textHeight)
|
| 222 |
+
ctx.strokeStyle = '#3b82f6'
|
| 223 |
+
ctx.lineWidth = 2
|
| 224 |
+
ctx.strokeRect(sumX - padding, sumY - padding, textWidth, textHeight)
|
| 225 |
+
|
| 226 |
+
ctx.fillStyle = '#1e40af'
|
| 227 |
+
ctx.fillText(`Channel: ${['R', 'G', 'B', 'A'][channel] ?? channel}`, sumX, sumY)
|
| 228 |
+
ctx.fillText(`Sum: ${sum.toFixed(3)}`, sumX, sumY + fontSize + 4)
|
| 229 |
+
ctx.fillStyle = '#16a34a'
|
| 230 |
+
ctx.fillText(`Output: ${normalizedValue.toFixed(3)}`, sumX, sumY + (fontSize + 4) * 2)
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function drawProgressBar(
|
| 235 |
+
ctx: CanvasRenderingContext2D,
|
| 236 |
+
width: number,
|
| 237 |
+
height: number,
|
| 238 |
+
progress: number
|
| 239 |
+
) {
|
| 240 |
+
const barHeight = 4
|
| 241 |
+
const barWidth = width * 0.8
|
| 242 |
+
const x = (width - barWidth) / 2
|
| 243 |
+
const y = height - 20
|
| 244 |
+
|
| 245 |
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
|
| 246 |
+
ctx.fillRect(x, y, barWidth, barHeight)
|
| 247 |
+
|
| 248 |
+
ctx.fillStyle = '#3b82f6'
|
| 249 |
+
ctx.fillRect(x, y, barWidth * progress, barHeight)
|
| 250 |
+
|
| 251 |
+
ctx.fillStyle = '#fff'
|
| 252 |
+
ctx.font = '12px monospace'
|
| 253 |
+
ctx.textAlign = 'center'
|
| 254 |
+
ctx.fillText(`${Math.round(progress * 100)}%`, width / 2, y - 4)
|
| 255 |
+
}
|
src/components/canvas/ImageCanvas.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useRef, useEffect, useCallback } from 'react'
|
| 4 |
+
import { useAppStore } from '@/store'
|
| 5 |
+
import { cn } from '@/utils/helpers'
|
| 6 |
+
|
| 7 |
+
interface ImageCanvasProps {
|
| 8 |
+
imageData: ImageData | null
|
| 9 |
+
title: string
|
| 10 |
+
width?: number
|
| 11 |
+
height?: number
|
| 12 |
+
className?: string
|
| 13 |
+
onWheel?: (e: React.WheelEvent) => void
|
| 14 |
+
onMouseDown?: (e: React.MouseEvent) => void
|
| 15 |
+
onMouseMove?: (e: React.MouseEvent) => void
|
| 16 |
+
onMouseUp?: (e: React.MouseEvent) => void
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function ImageCanvas({
|
| 20 |
+
imageData,
|
| 21 |
+
title,
|
| 22 |
+
width = 400,
|
| 23 |
+
height = 300,
|
| 24 |
+
className,
|
| 25 |
+
onWheel,
|
| 26 |
+
onMouseDown,
|
| 27 |
+
onMouseMove,
|
| 28 |
+
onMouseUp,
|
| 29 |
+
}: ImageCanvasProps) {
|
| 30 |
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 31 |
+
const { settings } = useAppStore()
|
| 32 |
+
|
| 33 |
+
const drawCanvas = useCallback(() => {
|
| 34 |
+
const canvas = canvasRef.current
|
| 35 |
+
const ctx = canvas?.getContext('2d', { willReadFrequently: true })
|
| 36 |
+
if (!canvas || !ctx || !imageData) return
|
| 37 |
+
|
| 38 |
+
const { zoomLevel, panOffset } = settings
|
| 39 |
+
const displayWidth = Math.round(imageData.width * zoomLevel)
|
| 40 |
+
const displayHeight = Math.round(imageData.height * zoomLevel)
|
| 41 |
+
|
| 42 |
+
canvas.width = displayWidth
|
| 43 |
+
canvas.height = displayHeight
|
| 44 |
+
|
| 45 |
+
ctx.clearRect(0, 0, displayWidth, displayHeight)
|
| 46 |
+
ctx.imageSmoothingEnabled = zoomLevel < 2
|
| 47 |
+
|
| 48 |
+
const tempCanvas = document.createElement('canvas')
|
| 49 |
+
tempCanvas.width = imageData.width
|
| 50 |
+
tempCanvas.height = imageData.height
|
| 51 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 52 |
+
if (tempCtx) {
|
| 53 |
+
tempCtx.putImageData(imageData, 0, 0)
|
| 54 |
+
ctx.drawImage(
|
| 55 |
+
tempCanvas,
|
| 56 |
+
0,
|
| 57 |
+
0,
|
| 58 |
+
imageData.width,
|
| 59 |
+
imageData.height,
|
| 60 |
+
panOffset.x,
|
| 61 |
+
panOffset.y,
|
| 62 |
+
displayWidth,
|
| 63 |
+
displayHeight
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Draw grid at high zoom
|
| 68 |
+
if (zoomLevel >= 4) {
|
| 69 |
+
drawGrid(ctx, imageData.width, imageData.height, zoomLevel, panOffset)
|
| 70 |
+
}
|
| 71 |
+
}, [imageData, settings.zoomLevel, settings.panOffset])
|
| 72 |
+
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
drawCanvas()
|
| 75 |
+
}, [drawCanvas])
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className={cn('relative bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden', className)}>
|
| 79 |
+
<div className="absolute top-2 left-2 z-10 px-2 py-1 bg-black/50 text-white text-xs rounded">
|
| 80 |
+
{title}
|
| 81 |
+
</div>
|
| 82 |
+
<canvas
|
| 83 |
+
ref={canvasRef}
|
| 84 |
+
width={width}
|
| 85 |
+
height={height}
|
| 86 |
+
className="block cursor-grab active:cursor-grabbing"
|
| 87 |
+
style={{ touchAction: 'none' }}
|
| 88 |
+
onWheel={onWheel}
|
| 89 |
+
onMouseDown={onMouseDown}
|
| 90 |
+
onMouseMove={onMouseMove}
|
| 91 |
+
onMouseUp={onMouseUp}
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function drawGrid(
|
| 98 |
+
ctx: CanvasRenderingContext2D,
|
| 99 |
+
imgWidth: number,
|
| 100 |
+
imgHeight: number,
|
| 101 |
+
zoom: number,
|
| 102 |
+
pan: { x: number; y: number }
|
| 103 |
+
) {
|
| 104 |
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'
|
| 105 |
+
ctx.lineWidth = 0.5
|
| 106 |
+
|
| 107 |
+
for (let x = 0; x <= imgWidth; x++) {
|
| 108 |
+
const px = pan.x + x * zoom
|
| 109 |
+
ctx.beginPath()
|
| 110 |
+
ctx.moveTo(px, 0)
|
| 111 |
+
ctx.lineTo(px, imgHeight * zoom)
|
| 112 |
+
ctx.stroke()
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
for (let y = 0; y <= imgHeight; y++) {
|
| 116 |
+
const py = pan.y + y * zoom
|
| 117 |
+
ctx.beginPath()
|
| 118 |
+
ctx.moveTo(0, py)
|
| 119 |
+
ctx.lineTo(imgWidth * zoom, py)
|
| 120 |
+
ctx.stroke()
|
| 121 |
+
}
|
| 122 |
+
}
|
src/components/canvas/SplitView.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useRef, useEffect, useCallback, useState } from 'react'
|
| 4 |
+
import { useAppStore } from '@/store'
|
| 5 |
+
import { cn } from '@/utils/helpers'
|
| 6 |
+
|
| 7 |
+
interface SplitViewProps {
|
| 8 |
+
width?: number
|
| 9 |
+
height?: number
|
| 10 |
+
className?: string
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function SplitView({ width = 800, height = 600, className }: SplitViewProps) {
|
| 14 |
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 15 |
+
const { originalImageData, processedImageData } = useAppStore()
|
| 16 |
+
const { splitPosition, mode, setComparisonViewMode, setSplitPosition } = useAppStore((state) => ({
|
| 17 |
+
splitPosition: state.comparison.splitPosition,
|
| 18 |
+
mode: state.comparison.mode,
|
| 19 |
+
setComparisonViewMode: state.setComparisonViewMode,
|
| 20 |
+
setSplitPosition: state.setSplitPosition,
|
| 21 |
+
}))
|
| 22 |
+
const [isDragging, setIsDragging] = useState(false)
|
| 23 |
+
|
| 24 |
+
const drawCanvas = useCallback(() => {
|
| 25 |
+
const canvas = canvasRef.current
|
| 26 |
+
const ctx = canvas?.getContext('2d', { willReadFrequently: true })
|
| 27 |
+
if (!canvas || !ctx || !originalImageData) return
|
| 28 |
+
|
| 29 |
+
const displayWidth = width
|
| 30 |
+
const displayHeight = height
|
| 31 |
+
|
| 32 |
+
canvas.width = displayWidth
|
| 33 |
+
canvas.height = displayHeight
|
| 34 |
+
ctx.clearRect(0, 0, displayWidth, displayHeight)
|
| 35 |
+
|
| 36 |
+
const splitX = displayWidth * splitPosition
|
| 37 |
+
|
| 38 |
+
if (mode === 'split' || mode === 'overlay') {
|
| 39 |
+
// Draw original on left
|
| 40 |
+
if (originalImageData) {
|
| 41 |
+
const tempCanvas = document.createElement('canvas')
|
| 42 |
+
tempCanvas.width = originalImageData.width
|
| 43 |
+
tempCanvas.height = originalImageData.height
|
| 44 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 45 |
+
if (tempCtx) {
|
| 46 |
+
tempCtx.putImageData(originalImageData, 0, 0)
|
| 47 |
+
ctx.drawImage(
|
| 48 |
+
tempCanvas,
|
| 49 |
+
0,
|
| 50 |
+
0,
|
| 51 |
+
originalImageData.width,
|
| 52 |
+
originalImageData.height,
|
| 53 |
+
0,
|
| 54 |
+
0,
|
| 55 |
+
splitX,
|
| 56 |
+
displayHeight
|
| 57 |
+
)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Draw processed on right
|
| 62 |
+
if (processedImageData) {
|
| 63 |
+
const tempCanvas = document.createElement('canvas')
|
| 64 |
+
tempCanvas.width = processedImageData.width
|
| 65 |
+
tempCanvas.height = processedImageData.height
|
| 66 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 67 |
+
if (tempCtx) {
|
| 68 |
+
tempCtx.putImageData(processedImageData, 0, 0)
|
| 69 |
+
ctx.drawImage(
|
| 70 |
+
tempCanvas,
|
| 71 |
+
splitX * (originalImageData.width / displayWidth),
|
| 72 |
+
0,
|
| 73 |
+
processedImageData.width - splitX * (originalImageData.width / displayWidth),
|
| 74 |
+
processedImageData.height,
|
| 75 |
+
splitX,
|
| 76 |
+
0,
|
| 77 |
+
displayWidth - splitX,
|
| 78 |
+
displayHeight
|
| 79 |
+
)
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Draw divider
|
| 84 |
+
ctx.strokeStyle = '#fff'
|
| 85 |
+
ctx.lineWidth = 3
|
| 86 |
+
ctx.setLineDash([10, 5])
|
| 87 |
+
ctx.beginPath()
|
| 88 |
+
ctx.moveTo(splitX, 0)
|
| 89 |
+
ctx.lineTo(splitX, displayHeight)
|
| 90 |
+
ctx.stroke()
|
| 91 |
+
ctx.setLineDash([])
|
| 92 |
+
|
| 93 |
+
// Draw handle
|
| 94 |
+
ctx.fillStyle = '#3b82f6'
|
| 95 |
+
ctx.beginPath()
|
| 96 |
+
ctx.arc(splitX, displayHeight / 2, 12, 0, Math.PI * 2)
|
| 97 |
+
ctx.fill()
|
| 98 |
+
ctx.strokeStyle = '#fff'
|
| 99 |
+
ctx.lineWidth = 2
|
| 100 |
+
ctx.stroke()
|
| 101 |
+
} else if (mode === 'side-by-side') {
|
| 102 |
+
// Side by side view
|
| 103 |
+
const halfWidth = displayWidth / 2
|
| 104 |
+
if (originalImageData) {
|
| 105 |
+
const tempCanvas = document.createElement('canvas')
|
| 106 |
+
tempCanvas.width = originalImageData.width
|
| 107 |
+
tempCanvas.height = originalImageData.height
|
| 108 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 109 |
+
if (tempCtx) {
|
| 110 |
+
tempCtx.putImageData(originalImageData, 0, 0)
|
| 111 |
+
ctx.drawImage(tempCanvas, 0, 0, halfWidth, displayHeight)
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
if (processedImageData) {
|
| 115 |
+
const tempCanvas = document.createElement('canvas')
|
| 116 |
+
tempCanvas.width = processedImageData.width
|
| 117 |
+
tempCanvas.height = processedImageData.height
|
| 118 |
+
const tempCtx = tempCanvas.getContext('2d')
|
| 119 |
+
if (tempCtx) {
|
| 120 |
+
tempCtx.putImageData(processedImageData, 0, 0)
|
| 121 |
+
ctx.drawImage(tempCanvas, halfWidth, 0, halfWidth, displayHeight)
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
// Draw center line
|
| 125 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
| 126 |
+
ctx.lineWidth = 1
|
| 127 |
+
ctx.beginPath()
|
| 128 |
+
ctx.moveTo(halfWidth, 0)
|
| 129 |
+
ctx.lineTo(halfWidth, displayHeight)
|
| 130 |
+
ctx.stroke()
|
| 131 |
+
}
|
| 132 |
+
}, [originalImageData, processedImageData, splitPosition, mode, width, height])
|
| 133 |
+
|
| 134 |
+
useEffect(() => {
|
| 135 |
+
drawCanvas()
|
| 136 |
+
}, [drawCanvas])
|
| 137 |
+
|
| 138 |
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
| 139 |
+
if (mode !== 'split') return
|
| 140 |
+
const rect = canvasRef.current?.getBoundingClientRect()
|
| 141 |
+
if (!rect) return
|
| 142 |
+
const x = e.clientX - rect.left
|
| 143 |
+
if (Math.abs(x - width * splitPosition) < 20) {
|
| 144 |
+
setIsDragging(true)
|
| 145 |
+
}
|
| 146 |
+
}, [mode, splitPosition, width])
|
| 147 |
+
|
| 148 |
+
const handleMouseMove = useCallback(
|
| 149 |
+
(e: React.MouseEvent) => {
|
| 150 |
+
if (!isDragging || mode !== 'split') return
|
| 151 |
+
const rect = canvasRef.current?.getBoundingClientRect()
|
| 152 |
+
if (!rect) return
|
| 153 |
+
const x = e.clientX - rect.left
|
| 154 |
+
const newPosition = Math.max(0.1, Math.min(0.9, x / width))
|
| 155 |
+
setSplitPosition(newPosition)
|
| 156 |
+
},
|
| 157 |
+
[isDragging, mode, width, setSplitPosition]
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
const handleMouseUp = useCallback(() => {
|
| 161 |
+
setIsDragging(false)
|
| 162 |
+
}, [])
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
<div className={cn('relative bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden', className)}>
|
| 166 |
+
<canvas
|
| 167 |
+
ref={canvasRef}
|
| 168 |
+
width={width}
|
| 169 |
+
height={height}
|
| 170 |
+
className="block"
|
| 171 |
+
style={{ cursor: isDragging ? 'ew-resize' : mode === 'split' ? 'ew-resize' : 'default' }}
|
| 172 |
+
onMouseDown={handleMouseDown}
|
| 173 |
+
onMouseMove={handleMouseMove}
|
| 174 |
+
onMouseUp={handleMouseUp}
|
| 175 |
+
onMouseLeave={handleMouseUp}
|
| 176 |
+
/>
|
| 177 |
+
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-2">
|
| 178 |
+
<button
|
| 179 |
+
className={cn('px-3 py-1 text-xs rounded', mode === 'split' && 'bg-blue-600 text-white')}
|
| 180 |
+
onClick={() => setComparisonViewMode('split')}
|
| 181 |
+
>
|
| 182 |
+
Split
|
| 183 |
+
</button>
|
| 184 |
+
<button
|
| 185 |
+
className={cn('px-3 py-1 text-xs rounded', mode === 'side-by-side' && 'bg-blue-600 text-white')}
|
| 186 |
+
onClick={() => setComparisonViewMode('side-by-side')}
|
| 187 |
+
>
|
| 188 |
+
Side by Side
|
| 189 |
+
</button>
|
| 190 |
+
<button
|
| 191 |
+
className={cn('px-3 py-1 text-xs rounded', mode === 'overlay' && 'bg-blue-600 text-white')}
|
| 192 |
+
onClick={() => setComparisonViewMode('overlay')}
|
| 193 |
+
>
|
| 194 |
+
Overlay
|
| 195 |
+
</button>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
)
|
| 199 |
+
}
|
src/components/canvas/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { ConvolutionCanvas } from './ConvolutionCanvas'
|
| 2 |
+
export { ImageCanvas } from './ImageCanvas'
|
| 3 |
+
export { SplitView } from './SplitView'
|
src/components/controls/OverlayControls.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAppStore } from '@/store'
|
| 4 |
+
import { PADDING_MODES } from '@/lib/kernels'
|
| 5 |
+
import { Select, Card, CardContent, Slider, Button } from '@/components/ui'
|
| 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()
|
| 11 |
+
const { showOverlay, padding } = settings
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<Card className="w-full">
|
| 15 |
+
<CardContent className="p-4 space-y-4">
|
| 16 |
+
<h3 className="font-medium flex items-center gap-2">
|
| 17 |
+
<Settings className="h-4 w-4" />
|
| 18 |
+
Visualization Overlays
|
| 19 |
+
</h3>
|
| 20 |
+
|
| 21 |
+
<div className="grid grid-cols-2 gap-2">
|
| 22 |
+
{Object.entries(showOverlay).map(([key, value]) => (
|
| 23 |
+
<label key={key} className="flex items-center gap-2 cursor-pointer">
|
| 24 |
+
<input
|
| 25 |
+
type="checkbox"
|
| 26 |
+
checked={value}
|
| 27 |
+
onChange={() => toggleOverlay(key as keyof typeof showOverlay)}
|
| 28 |
+
className="rounded border-gray-300"
|
| 29 |
+
/>
|
| 30 |
+
<span className="text-sm capitalize">{key.replace(/([A-Z])/g, ' $1')}</span>
|
| 31 |
+
</label>
|
| 32 |
+
))}
|
| 33 |
+
</div>
|
| 34 |
+
</CardContent>
|
| 35 |
+
</Card>
|
| 36 |
+
)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function PaddingControls() {
|
| 40 |
+
const { settings, setPadding } = useAppStore()
|
| 41 |
+
const { padding } = settings
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<Card className="w-full">
|
| 45 |
+
<CardContent className="p-4 space-y-4">
|
| 46 |
+
<h3 className="font-medium flex items-center gap-2">
|
| 47 |
+
<Grid className="h-4 w-4" />
|
| 48 |
+
Padding Strategy
|
| 49 |
+
</h3>
|
| 50 |
+
|
| 51 |
+
<Select
|
| 52 |
+
value={padding}
|
| 53 |
+
onChange={(e) => setPadding(e.target.value as typeof padding)}
|
| 54 |
+
options={PADDING_MODES.map((p) => ({ value: p.value, label: p.label }))}
|
| 55 |
+
placeholder="Select padding mode"
|
| 56 |
+
/>
|
| 57 |
+
|
| 58 |
+
<div className="text-sm text-gray-500 dark:text-gray-400">
|
| 59 |
+
{PADDING_MODES.find((p) => p.value === padding)?.description}
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
{/* Padding visualization would go here */}
|
| 63 |
+
</CardContent>
|
| 64 |
+
</Card>
|
| 65 |
+
)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function KernelInfoPanel() {
|
| 69 |
+
const { selectedKernel } = useAppStore()
|
| 70 |
+
|
| 71 |
+
if (!selectedKernel) {
|
| 72 |
+
return (
|
| 73 |
+
<Card className="w-full">
|
| 74 |
+
<CardContent className="p-4 text-center text-gray-500">
|
| 75 |
+
No kernel selected
|
| 76 |
+
</CardContent>
|
| 77 |
+
</Card>
|
| 78 |
+
)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const { name, category, matrix, size, normalize, description, formula, reference } = selectedKernel
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<Card className="w-full">
|
| 85 |
+
<CardContent className="p-4 space-y-4">
|
| 86 |
+
<div className="flex items-start justify-between gap-4">
|
| 87 |
+
<div>
|
| 88 |
+
<h3 className="font-medium">{name}</h3>
|
| 89 |
+
<div className="flex items-center gap-2 mt-1">
|
| 90 |
+
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded dark:bg-blue-900/30 dark:text-blue-300">
|
| 91 |
+
{category}
|
| 92 |
+
</span>
|
| 93 |
+
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-800 rounded dark:bg-gray-800 dark:text-gray-300">
|
| 94 |
+
{size}×{size}
|
| 95 |
+
</span>
|
| 96 |
+
{normalize && (
|
| 97 |
+
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded dark:bg-green-900/30 dark:text-green-300">
|
| 98 |
+
Normalized
|
| 99 |
+
</span>
|
| 100 |
+
)}
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<p className="text-sm text-gray-600 dark:text-gray-400">{description}</p>
|
| 106 |
+
|
| 107 |
+
{formula && (
|
| 108 |
+
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded font-mono text-sm">
|
| 109 |
+
<span className="text-gray-500">Formula: </span>
|
| 110 |
+
<span>{formula}</span>
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
|
| 114 |
+
<div className="space-y-2">
|
| 115 |
+
<h4 className="text-sm font-medium">Kernel Matrix</h4>
|
| 116 |
+
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${size}, 1fr)` }}>
|
| 117 |
+
{matrix.map((row, i) =>
|
| 118 |
+
row.map((val, j) => (
|
| 119 |
+
<div
|
| 120 |
+
key={`${i}-${j}`}
|
| 121 |
+
className={cn(
|
| 122 |
+
'p-2 text-center text-sm font-mono rounded',
|
| 123 |
+
val > 0 ? 'bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
| 124 |
+
)}
|
| 125 |
+
>
|
| 126 |
+
{val.toFixed(val === Math.round(val) ? 0 : 2)}
|
| 127 |
+
</div>
|
| 128 |
+
))
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{reference && (
|
| 134 |
+
<a
|
| 135 |
+
href={reference}
|
| 136 |
+
target="_blank"
|
| 137 |
+
rel="noopener noreferrer"
|
| 138 |
+
className="text-sm text-blue-600 hover:underline flex items-center gap-1"
|
| 139 |
+
>
|
| 140 |
+
<Layers className="h-3 w-3" />
|
| 141 |
+
Reference
|
| 142 |
+
</a>
|
| 143 |
+
)}
|
| 144 |
+
</CardContent>
|
| 145 |
+
</Card>
|
| 146 |
+
)
|
| 147 |
+
}
|
src/components/controls/PlaybackControls.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAppStore } from '@/store'
|
| 4 |
+
import { useConvolution } from '@/hooks/useConvolution'
|
| 5 |
+
import { Button, Slider, Card, CardContent } from '@/components/ui'
|
| 6 |
+
import { cn } from '@/utils/helpers'
|
| 7 |
+
import { Play, Pause, RotateCcw, StepForward, ZoomIn, ZoomOut, Maximize } from 'lucide-react'
|
| 8 |
+
|
| 9 |
+
export function PlaybackControls() {
|
| 10 |
+
const {
|
| 11 |
+
convolution,
|
| 12 |
+
settings,
|
| 13 |
+
startConvolution,
|
| 14 |
+
pauseConvolution,
|
| 15 |
+
resumeConvolution,
|
| 16 |
+
stopConvolution,
|
| 17 |
+
updateConvolutionStep,
|
| 18 |
+
updateConvolutionProgress,
|
| 19 |
+
completeConvolution,
|
| 20 |
+
setConvolutionError,
|
| 21 |
+
resetConvolution,
|
| 22 |
+
setAnimationSpeed,
|
| 23 |
+
setZoomLevel,
|
| 24 |
+
setPanOffset,
|
| 25 |
+
toggleOverlay,
|
| 26 |
+
setPadding,
|
| 27 |
+
setComparisonMode,
|
| 28 |
+
setStepMode,
|
| 29 |
+
} = useAppStore()
|
| 30 |
+
|
| 31 |
+
const { runConvolution, pause, resume, stop, step, reset, isRunning, isPaused, canRun, toggleStepMode } = useConvolution()
|
| 32 |
+
|
| 33 |
+
const { status, progress, elapsedTime } = convolution
|
| 34 |
+
const { animationSpeed, zoomLevel, stepMode } = settings
|
| 35 |
+
|
| 36 |
+
const isCompleted = status === 'completed'
|
| 37 |
+
const isActive = isRunning || isPaused || isCompleted
|
| 38 |
+
|
| 39 |
+
const formatTime = (ms: number) => {
|
| 40 |
+
if (ms < 1000) return `${ms}ms`
|
| 41 |
+
return `${(ms / 1000).toFixed(1)}s`
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<Card className="w-full">
|
| 46 |
+
<CardContent className="p-4 space-y-4">
|
| 47 |
+
{/* Main Controls */}
|
| 48 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 49 |
+
<Button
|
| 50 |
+
variant={isRunning ? 'secondary' : 'primary'}
|
| 51 |
+
size="lg"
|
| 52 |
+
onClick={isRunning ? pause : runConvolution}
|
| 53 |
+
disabled={!canRun}
|
| 54 |
+
className="min-w-[100px]"
|
| 55 |
+
>
|
| 56 |
+
{isRunning ? (
|
| 57 |
+
<>
|
| 58 |
+
<Pause className="h-4 w-4" />
|
| 59 |
+
Pause
|
| 60 |
+
</>
|
| 61 |
+
) : (
|
| 62 |
+
<>
|
| 63 |
+
<Play className="h-4 w-4" />
|
| 64 |
+
{isPaused ? 'Resume' : 'Play'}
|
| 65 |
+
</>
|
| 66 |
+
)}
|
| 67 |
+
</Button>
|
| 68 |
+
|
| 69 |
+
<Button variant="outline" size="lg" onClick={step} disabled={isRunning || isCompleted}>
|
| 70 |
+
<StepForward className="h-4 w-4" />
|
| 71 |
+
Step
|
| 72 |
+
</Button>
|
| 73 |
+
|
| 74 |
+
<Button variant="outline" size="lg" onClick={stop} disabled={status === 'idle'}>
|
| 75 |
+
<RotateCcw className="h-4 w-4" />
|
| 76 |
+
Stop
|
| 77 |
+
</Button>
|
| 78 |
+
|
| 79 |
+
<Button variant="outline" size="lg" onClick={reset} disabled={status === 'idle'}>
|
| 80 |
+
<RotateCcw className="h-4 w-4" />
|
| 81 |
+
Reset
|
| 82 |
+
</Button>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Speed Control */}
|
| 86 |
+
<div>
|
| 87 |
+
<label className="flex items-center justify-between text-sm mb-1">
|
| 88 |
+
<span>Animation Speed</span>
|
| 89 |
+
<span className="font-mono text-blue-600">{animationSpeed.toFixed(1)}x</span>
|
| 90 |
+
</label>
|
| 91 |
+
<Slider
|
| 92 |
+
min={0.1}
|
| 93 |
+
max={10}
|
| 94 |
+
step={0.1}
|
| 95 |
+
value={animationSpeed}
|
| 96 |
+
onValueChange={setAnimationSpeed}
|
| 97 |
+
showValue
|
| 98 |
+
valueFormat={(v: number) => `${v.toFixed(1)}x`}
|
| 99 |
+
/>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Zoom Controls */}
|
| 103 |
+
<div>
|
| 104 |
+
<label className="flex items-center justify-between text-sm mb-1">
|
| 105 |
+
<span>Zoom</span>
|
| 106 |
+
<span className="font-mono text-blue-600">{Math.round(zoomLevel * 100)}%</span>
|
| 107 |
+
</label>
|
| 108 |
+
<Slider
|
| 109 |
+
min={0.1}
|
| 110 |
+
max={20}
|
| 111 |
+
step={0.1}
|
| 112 |
+
value={zoomLevel}
|
| 113 |
+
onValueChange={setZoomLevel}
|
| 114 |
+
showValue
|
| 115 |
+
valueFormat={(v: number) => `${Math.round(v * 100)}%`}
|
| 116 |
+
/>
|
| 117 |
+
<div className="flex gap-2 mt-2">
|
| 118 |
+
<Button variant="outline" size="sm" onClick={() => setZoomLevel(Math.min(20, zoomLevel * 1.2))}>
|
| 119 |
+
<ZoomIn className="h-4 w-4" />
|
| 120 |
+
</Button>
|
| 121 |
+
<Button variant="outline" size="sm" onClick={() => setZoomLevel(Math.max(0.1, zoomLevel / 1.2))}>
|
| 122 |
+
<ZoomOut className="h-4 w-4" />
|
| 123 |
+
</Button>
|
| 124 |
+
<Button variant="outline" size="sm" onClick={() => { setZoomLevel(1); setPanOffset({ x: 0, y: 0 }) }}>
|
| 125 |
+
<Maximize className="h-4 w-4" />
|
| 126 |
+
Fit
|
| 127 |
+
</Button>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* Progress */}
|
| 132 |
+
{isActive && (
|
| 133 |
+
<div className="space-y-2 pt-2 border-t dark:border-gray-700">
|
| 134 |
+
<div className="flex items-center justify-between text-sm">
|
| 135 |
+
<span>Progress</span>
|
| 136 |
+
<span className="font-mono">{Math.round(progress * 100)}%</span>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-gray-700">
|
| 139 |
+
<div
|
| 140 |
+
className="h-full bg-blue-600 transition-all duration-300"
|
| 141 |
+
style={{ width: `${progress * 100}%` }}
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
<div className="flex justify-between text-xs text-gray-500">
|
| 145 |
+
<span>Elapsed: {formatTime(elapsedTime)}</span>
|
| 146 |
+
<span>{status === 'running' ? 'Running' : status === 'paused' ? 'Paused' : 'Completed'}</span>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
|
| 151 |
+
{/* Step Mode Toggle */}
|
| 152 |
+
<div className="flex items-center justify-between pt-2 border-t dark:border-gray-700">
|
| 153 |
+
<label className="flex items-center gap-2 cursor-pointer">
|
| 154 |
+
<input
|
| 155 |
+
type="checkbox"
|
| 156 |
+
checked={stepMode}
|
| 157 |
+
onChange={(e) => toggleStepMode(e.target.checked)}
|
| 158 |
+
className="rounded border-gray-300"
|
| 159 |
+
/>
|
| 160 |
+
<span className="text-sm">Step Mode (manual advance)</span>
|
| 161 |
+
</label>
|
| 162 |
+
</div>
|
| 163 |
+
</CardContent>
|
| 164 |
+
</Card>
|
| 165 |
+
)
|
| 166 |
+
}
|
src/components/controls/index.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { PlaybackControls } from './PlaybackControls'
|
| 2 |
+
export { OverlayControls, PaddingControls, KernelInfoPanel } from './OverlayControls'
|
src/components/kernel/KernelSelector.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
const filteredKernels = PREDEFINED_KERNELS.filter(
|
| 18 |
+
(k) =>
|
| 19 |
+
k.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 20 |
+
k.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 21 |
+
k.description.toLowerCase().includes(searchQuery.toLowerCase())
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
const kernelsByCategory = KERNEL_CATEGORIES.map((cat) => ({
|
| 25 |
+
...cat,
|
| 26 |
+
kernels: getKernelsByCategory(cat.value).filter((k) =>
|
| 27 |
+
k.name.toLowerCase().includes(searchQuery.toLowerCase())
|
| 28 |
+
),
|
| 29 |
+
}))
|
| 30 |
+
|
| 31 |
+
const handleKernelSelect = (kernel: typeof PREDEFINED_KERNELS[0]) => {
|
| 32 |
+
setSelectedKernel(kernel)
|
| 33 |
+
setShowCustom(false)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const handleCustomKernelSubmit = () => {
|
| 37 |
+
const kernel = {
|
| 38 |
+
id: `custom-${Date.now()}`,
|
| 39 |
+
name: `Custom ${customKernelSize}×${customKernelSize}`,
|
| 40 |
+
category: 'custom' as KernelCategory,
|
| 41 |
+
matrix: customKernelMatrix,
|
| 42 |
+
size: customKernelSize,
|
| 43 |
+
normalize: customKernelNormalize,
|
| 44 |
+
description: 'User-defined custom kernel',
|
| 45 |
+
}
|
| 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">
|
| 52 |
+
<h2 className="text-lg font-semibold">Kernel Selector</h2>
|
| 53 |
+
<Button variant="ghost" size="icon" onClick={() => setShowCustom(!showCustom)}>
|
| 54 |
+
<Plus className="h-4 w-4" />
|
| 55 |
+
</Button>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<CardContent className="p-4">
|
| 59 |
+
{/* Search */}
|
| 60 |
+
<div className="relative mb-4">
|
| 61 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
| 62 |
+
<input
|
| 63 |
+
type="text"
|
| 64 |
+
placeholder="Search kernels..."
|
| 65 |
+
value={searchQuery}
|
| 66 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 67 |
+
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Category Tabs */}
|
| 72 |
+
<div className="flex gap-1 mb-4 overflow-x-auto pb-2">
|
| 73 |
+
{KERNEL_CATEGORIES.map((cat) => (
|
| 74 |
+
<button
|
| 75 |
+
key={cat.value}
|
| 76 |
+
onClick={() => setActiveCategory(cat.value)}
|
| 77 |
+
className={cn(
|
| 78 |
+
'px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
|
| 79 |
+
activeCategory === cat.value
|
| 80 |
+
? 'bg-blue-600 text-white'
|
| 81 |
+
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'
|
| 82 |
+
)}
|
| 83 |
+
>
|
| 84 |
+
{cat.label}
|
| 85 |
+
</button>
|
| 86 |
+
))}
|
| 87 |
+
</div>
|
| 88 |
+
|
| 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 |
+
<h3 className="font-medium mb-3">Custom Kernel Editor</h3>
|
| 93 |
+
<div className="flex items-center gap-4 mb-3">
|
| 94 |
+
<Input
|
| 95 |
+
type="number"
|
| 96 |
+
label="Size"
|
| 97 |
+
value={customKernelSize}
|
| 98 |
+
onChange={(e) => setCustomKernelSize(Math.max(3, Math.min(9, parseInt(e.target.value) || 3)))}
|
| 99 |
+
min={3}
|
| 100 |
+
max={9}
|
| 101 |
+
step={2}
|
| 102 |
+
className="w-24"
|
| 103 |
+
/>
|
| 104 |
+
<label className="flex items-center gap-2 text-sm">
|
| 105 |
+
<input
|
| 106 |
+
type="checkbox"
|
| 107 |
+
checked={customKernelNormalize}
|
| 108 |
+
onChange={(e) => setCustomKernelNormalize(e.target.checked)}
|
| 109 |
+
className="rounded border-gray-300"
|
| 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 |
+
className="w-full h-8 text-center text-sm border border-gray-300 rounded dark:border-gray-600 dark:bg-gray-700"
|
| 124 |
+
/>
|
| 125 |
+
))
|
| 126 |
+
)}
|
| 127 |
+
</div>
|
| 128 |
+
<Button onClick={handleCustomKernelSubmit} className="w-full">
|
| 129 |
+
Apply Custom Kernel
|
| 130 |
+
</Button>
|
| 131 |
+
</div>
|
| 132 |
+
)}
|
| 133 |
+
|
| 134 |
+
{/* Kernel List */}
|
| 135 |
+
<div className="space-y-3 max-h-96 overflow-y-auto">
|
| 136 |
+
{kernelsByCategory
|
| 137 |
+
.filter((cat) => cat.kernels.length > 0)
|
| 138 |
+
.map((cat) => (
|
| 139 |
+
<div key={cat.value} className="space-y-2">
|
| 140 |
+
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
| 141 |
+
{cat.label}
|
| 142 |
+
</h4>
|
| 143 |
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
| 144 |
+
{cat.kernels.map((kernel) => (
|
| 145 |
+
<button
|
| 146 |
+
key={kernel.id}
|
| 147 |
+
onClick={() => handleKernelSelect(kernel)}
|
| 148 |
+
className={cn(
|
| 149 |
+
'p-3 text-left rounded-lg border transition-all',
|
| 150 |
+
'hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20',
|
| 151 |
+
selectedKernel?.id === kernel.id
|
| 152 |
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
| 153 |
+
: 'border-gray-200 dark:border-gray-700'
|
| 154 |
+
)}
|
| 155 |
+
>
|
| 156 |
+
<div className="font-medium text-sm">{kernel.name}</div>
|
| 157 |
+
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
| 158 |
+
{kernel.description}
|
| 159 |
+
</div>
|
| 160 |
+
<div className="flex items-center gap-1 mt-2">
|
| 161 |
+
<Badge variant="outline" className="text-xs">
|
| 162 |
+
{kernel.size}×{kernel.size}
|
| 163 |
+
</Badge>
|
| 164 |
+
{kernel.normalize && (
|
| 165 |
+
<Badge variant="secondary" className="text-xs">
|
| 166 |
+
Normalized
|
| 167 |
+
</Badge>
|
| 168 |
+
)}
|
| 169 |
+
</div>
|
| 170 |
+
</button>
|
| 171 |
+
))}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
))}
|
| 175 |
+
{filteredKernels.length === 0 && (
|
| 176 |
+
<div className="text-center py-8 text-gray-500">
|
| 177 |
+
No kernels found matching “{searchQuery}”
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
</div>
|
| 181 |
+
</CardContent>
|
| 182 |
+
</Card>
|
| 183 |
+
)
|
| 184 |
+
}
|
src/components/ui/Badge.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, HTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
| 7 |
+
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'danger' | 'outline'
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
| 11 |
+
({ className, variant = 'default', children, ...props }, ref) => {
|
| 12 |
+
const variants = {
|
| 13 |
+
default: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
| 14 |
+
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
| 15 |
+
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
| 16 |
+
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
| 17 |
+
danger: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
| 18 |
+
outline: 'border border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300',
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<span
|
| 23 |
+
ref={ref}
|
| 24 |
+
className={cn(
|
| 25 |
+
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
| 26 |
+
variants[variant],
|
| 27 |
+
className
|
| 28 |
+
)}
|
| 29 |
+
{...props}
|
| 30 |
+
>
|
| 31 |
+
{children}
|
| 32 |
+
</span>
|
| 33 |
+
)
|
| 34 |
+
}
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
Badge.displayName = 'Badge'
|
src/components/ui/Button.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, ButtonHTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
| 7 |
+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
| 8 |
+
size?: 'sm' | 'md' | 'lg' | 'icon'
|
| 9 |
+
loading?: boolean
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
| 13 |
+
({ className, variant = 'primary', size = 'md', loading, disabled, children, ...props }, ref) => {
|
| 14 |
+
const baseStyles =
|
| 15 |
+
'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'
|
| 16 |
+
|
| 17 |
+
const variants = {
|
| 18 |
+
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500',
|
| 19 |
+
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700',
|
| 20 |
+
outline: 'border border-gray-300 bg-transparent hover:bg-gray-100 focus-visible:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800',
|
| 21 |
+
ghost: 'bg-transparent hover:bg-gray-100 focus-visible:ring-gray-500 dark:hover:bg-gray-800',
|
| 22 |
+
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const sizes = {
|
| 26 |
+
sm: 'h-8 px-3 text-sm gap-1.5',
|
| 27 |
+
md: 'h-10 px-4 text-sm gap-2',
|
| 28 |
+
lg: 'h-12 px-6 text-base gap-2',
|
| 29 |
+
icon: 'h-10 w-10',
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<button
|
| 34 |
+
ref={ref}
|
| 35 |
+
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
| 36 |
+
disabled={disabled || loading}
|
| 37 |
+
{...props}
|
| 38 |
+
>
|
| 39 |
+
{loading && (
|
| 40 |
+
<svg
|
| 41 |
+
className="animate-spin h-4 w-4"
|
| 42 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 43 |
+
fill="none"
|
| 44 |
+
viewBox="0 0 24 24"
|
| 45 |
+
>
|
| 46 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 47 |
+
<path
|
| 48 |
+
className="opacity-75"
|
| 49 |
+
fill="currentColor"
|
| 50 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
| 51 |
+
/>
|
| 52 |
+
</svg>
|
| 53 |
+
)}
|
| 54 |
+
{children}
|
| 55 |
+
</button>
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
Button.displayName = 'Button'
|
src/components/ui/Card.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, HTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
| 7 |
+
variant?: 'default' | 'outlined' | 'elevated'
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
| 11 |
+
({ className, variant = 'default', children, ...props }, ref) => {
|
| 12 |
+
const variants = {
|
| 13 |
+
default: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700',
|
| 14 |
+
outlined: 'bg-transparent border-2 border-gray-300 dark:border-gray-600',
|
| 15 |
+
elevated: 'bg-white dark:bg-gray-800 shadow-lg border-none',
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div
|
| 20 |
+
ref={ref}
|
| 21 |
+
className={cn('rounded-lg', variants[variant], className)}
|
| 22 |
+
{...props}
|
| 23 |
+
>
|
| 24 |
+
{children}
|
| 25 |
+
</div>
|
| 26 |
+
)
|
| 27 |
+
}
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
Card.displayName = 'Card'
|
| 31 |
+
|
| 32 |
+
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
| 33 |
+
({ className, children, ...props }, ref) => (
|
| 34 |
+
<div ref={ref} className={cn('px-6 py-4 border-b border-gray-200 dark:border-gray-700', className)} {...props}>
|
| 35 |
+
{children}
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
CardHeader.displayName = 'CardHeader'
|
| 41 |
+
|
| 42 |
+
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
| 43 |
+
({ className, children, ...props }, ref) => (
|
| 44 |
+
<h3 ref={ref} className={cn('text-lg font-semibold text-gray-900 dark:text-gray-100', className)} {...props}>
|
| 45 |
+
{children}
|
| 46 |
+
</h3>
|
| 47 |
+
)
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
CardTitle.displayName = 'CardTitle'
|
| 51 |
+
|
| 52 |
+
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
| 53 |
+
({ className, children, ...props }, ref) => (
|
| 54 |
+
<p ref={ref} className={cn('text-sm text-gray-500 dark:text-gray-400 mt-1', className)} {...props}>
|
| 55 |
+
{children}
|
| 56 |
+
</p>
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
CardDescription.displayName = 'CardDescription'
|
| 61 |
+
|
| 62 |
+
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
| 63 |
+
({ className, children, ...props }, ref) => (
|
| 64 |
+
<div ref={ref} className={cn('px-6 py-4', className)} {...props}>
|
| 65 |
+
{children}
|
| 66 |
+
</div>
|
| 67 |
+
)
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
CardContent.displayName = 'CardContent'
|
| 71 |
+
|
| 72 |
+
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
| 73 |
+
({ className, children, ...props }, ref) => (
|
| 74 |
+
<div ref={ref} className={cn('px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-2', className)} {...props}>
|
| 75 |
+
{children}
|
| 76 |
+
</div>
|
| 77 |
+
)
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
CardFooter.displayName = 'CardFooter'
|
src/components/ui/Input.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, InputHTMLAttributes, LabelHTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
| 7 |
+
label?: string
|
| 8 |
+
error?: string
|
| 9 |
+
helperText?: string
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
| 13 |
+
({ className, label, error, helperText, id, ...props }, ref) => {
|
| 14 |
+
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="w-full">
|
| 18 |
+
{label && (
|
| 19 |
+
<label
|
| 20 |
+
htmlFor={inputId}
|
| 21 |
+
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
| 22 |
+
>
|
| 23 |
+
{label}
|
| 24 |
+
</label>
|
| 25 |
+
)}
|
| 26 |
+
<input
|
| 27 |
+
ref={ref}
|
| 28 |
+
id={inputId}
|
| 29 |
+
className={cn(
|
| 30 |
+
'flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm',
|
| 31 |
+
'placeholder:text-gray-400',
|
| 32 |
+
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
| 33 |
+
'disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-100',
|
| 34 |
+
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500',
|
| 35 |
+
error && 'border-red-500 focus:ring-red-500',
|
| 36 |
+
className
|
| 37 |
+
)}
|
| 38 |
+
aria-invalid={error ? 'true' : 'false'}
|
| 39 |
+
aria-describedby={error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
|
| 40 |
+
{...props}
|
| 41 |
+
/>
|
| 42 |
+
{error && (
|
| 43 |
+
<p id={`${inputId}-error`} className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">
|
| 44 |
+
{error}
|
| 45 |
+
</p>
|
| 46 |
+
)}
|
| 47 |
+
{helperText && !error && (
|
| 48 |
+
<p id={`${inputId}-helper`} className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
| 49 |
+
{helperText}
|
| 50 |
+
</p>
|
| 51 |
+
)}
|
| 52 |
+
</div>
|
| 53 |
+
)
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
Input.displayName = 'Input'
|
| 58 |
+
|
| 59 |
+
// Removed empty LabelProps interface - using LabelHTMLAttributes directly
|
src/components/ui/Select.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, SelectHTMLAttributes, OptionHTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
| 7 |
+
label?: string
|
| 8 |
+
error?: string
|
| 9 |
+
options: { value: string; label: string }[]
|
| 10 |
+
placeholder?: string
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
| 14 |
+
({ className, label, error, options, placeholder, id, ...props }, ref) => {
|
| 15 |
+
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div className="w-full">
|
| 19 |
+
{label && (
|
| 20 |
+
<label
|
| 21 |
+
htmlFor={selectId}
|
| 22 |
+
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
| 23 |
+
>
|
| 24 |
+
{label}
|
| 25 |
+
</label>
|
| 26 |
+
)}
|
| 27 |
+
<select
|
| 28 |
+
ref={ref}
|
| 29 |
+
id={selectId}
|
| 30 |
+
className={cn(
|
| 31 |
+
'flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm',
|
| 32 |
+
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
| 33 |
+
'disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-100',
|
| 34 |
+
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
|
| 35 |
+
error && 'border-red-500 focus:ring-red-500',
|
| 36 |
+
className
|
| 37 |
+
)}
|
| 38 |
+
aria-invalid={error ? 'true' : 'false'}
|
| 39 |
+
{...props}
|
| 40 |
+
>
|
| 41 |
+
{placeholder && (
|
| 42 |
+
<option value="" disabled>
|
| 43 |
+
{placeholder}
|
| 44 |
+
</option>
|
| 45 |
+
)}
|
| 46 |
+
{options.map((option) => (
|
| 47 |
+
<option key={option.value} value={option.value}>
|
| 48 |
+
{option.label}
|
| 49 |
+
</option>
|
| 50 |
+
))}
|
| 51 |
+
</select>
|
| 52 |
+
{error && (
|
| 53 |
+
<p id={`${selectId}-error`} className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">
|
| 54 |
+
{error}
|
| 55 |
+
</p>
|
| 56 |
+
)}
|
| 57 |
+
</div>
|
| 58 |
+
)
|
| 59 |
+
}
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
Select.displayName = 'Select'
|
src/components/ui/Slider.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, InputHTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface SliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
| 7 |
+
label?: string
|
| 8 |
+
min: number
|
| 9 |
+
max: number
|
| 10 |
+
step?: number
|
| 11 |
+
value: number
|
| 12 |
+
onValueChange: (value: number) => void
|
| 13 |
+
showValue?: boolean
|
| 14 |
+
valueFormat?: (value: number) => string
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const Slider = forwardRef<HTMLInputElement, SliderProps>(
|
| 18 |
+
({ className, label, min, max, step = 1, value, onValueChange, showValue = false, valueFormat, ...props }, ref) => {
|
| 19 |
+
const sliderId = label?.toLowerCase().replace(/\s+/g, '-')
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className={cn('w-full', className)}>
|
| 23 |
+
{label && (
|
| 24 |
+
<div className="flex items-center justify-between mb-1">
|
| 25 |
+
<label htmlFor={sliderId} className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
| 26 |
+
{label}
|
| 27 |
+
</label>
|
| 28 |
+
{showValue && (
|
| 29 |
+
<span className="text-sm text-gray-500 dark:text-gray-400 font-mono">
|
| 30 |
+
{valueFormat ? valueFormat(value) : value}
|
| 31 |
+
</span>
|
| 32 |
+
)}
|
| 33 |
+
</div>
|
| 34 |
+
)}
|
| 35 |
+
<input
|
| 36 |
+
ref={ref}
|
| 37 |
+
id={sliderId}
|
| 38 |
+
type="range"
|
| 39 |
+
min={min}
|
| 40 |
+
max={max}
|
| 41 |
+
step={step}
|
| 42 |
+
value={value}
|
| 43 |
+
onChange={(e) => onValueChange(Number(e.target.value))}
|
| 44 |
+
className={cn(
|
| 45 |
+
'w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer',
|
| 46 |
+
'accent-blue-600 dark:bg-gray-700',
|
| 47 |
+
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
| 48 |
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
| 49 |
+
)}
|
| 50 |
+
{...props}
|
| 51 |
+
/>
|
| 52 |
+
</div>
|
| 53 |
+
)
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
Slider.displayName = 'Slider'
|
src/components/ui/Tooltip.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { forwardRef, HTMLAttributes } from 'react'
|
| 4 |
+
import { cn } from '@/utils/helpers'
|
| 5 |
+
|
| 6 |
+
export interface TooltipProps extends HTMLAttributes<HTMLDivElement> {
|
| 7 |
+
content: string
|
| 8 |
+
position?: 'top' | 'bottom' | 'left' | 'right'
|
| 9 |
+
children: React.ReactElement
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
|
| 13 |
+
({ className, content, position = 'top', children, ...props }, ref) => {
|
| 14 |
+
if (!React.isValidElement(children)) {
|
| 15 |
+
return <>{children}</>
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const positions = {
|
| 19 |
+
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
| 20 |
+
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
| 21 |
+
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
| 22 |
+
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div ref={ref} className={cn('relative inline-block', className)} {...props}>
|
| 27 |
+
{children}
|
| 28 |
+
<div
|
| 29 |
+
className={cn(
|
| 30 |
+
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded shadow-lg',
|
| 31 |
+
'opacity-0 invisible transition-all duration-200',
|
| 32 |
+
'group-hover:opacity-100 group-hover:visible',
|
| 33 |
+
'whitespace-nowrap',
|
| 34 |
+
positions[position]
|
| 35 |
+
)}
|
| 36 |
+
role="tooltip"
|
| 37 |
+
>
|
| 38 |
+
{content}
|
| 39 |
+
<div
|
| 40 |
+
className={cn(
|
| 41 |
+
'absolute w-0 h-0 border-4 border-transparent',
|
| 42 |
+
position === 'top' && 'bottom-[-8px] left-1/2 -translate-x-1/2 border-t-gray-900',
|
| 43 |
+
position === 'bottom' && 'top-[-8px] left-1/2 -translate-x-1/2 border-b-gray-900',
|
| 44 |
+
position === 'left' && 'right-[-8px] top-1/2 -translate-y-1/2 border-l-gray-900',
|
| 45 |
+
position === 'right' && 'left-[-8px] top-1/2 -translate-y-1/2 border-r-gray-900'
|
| 46 |
+
)}
|
| 47 |
+
/>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
)
|
| 51 |
+
}
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
Tooltip.displayName = 'Tooltip'
|
| 55 |
+
|
| 56 |
+
// Need to import React
|
| 57 |
+
import React from 'react'
|
src/components/ui/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { Button } from './Button'
|
| 2 |
+
export { Input } from './Input'
|
| 3 |
+
export { Select } from './Select'
|
| 4 |
+
export { Slider } from './Slider'
|
| 5 |
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
|
| 6 |
+
export { Badge } from './Badge'
|
| 7 |
+
export { Tooltip } from './Tooltip'
|
src/hooks/useCanvas.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useEffect } from 'react'
|
| 2 |
+
import { useAppStore } from '@/store'
|
| 3 |
+
|
| 4 |
+
export function useCanvas() {
|
| 5 |
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 6 |
+
const contextRef = useRef<CanvasRenderingContext2D | null>(null)
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
if (canvasRef.current) {
|
| 10 |
+
contextRef.current = canvasRef.current.getContext('2d', { willReadFrequently: true })
|
| 11 |
+
}
|
| 12 |
+
}, [])
|
| 13 |
+
|
| 14 |
+
const drawImage = useCallback((imageData: ImageData) => {
|
| 15 |
+
const ctx = contextRef.current
|
| 16 |
+
const canvas = canvasRef.current
|
| 17 |
+
if (!ctx || !canvas) return
|
| 18 |
+
|
| 19 |
+
canvas.width = imageData.width
|
| 20 |
+
canvas.height = imageData.height
|
| 21 |
+
ctx.putImageData(imageData, 0, 0)
|
| 22 |
+
}, [])
|
| 23 |
+
|
| 24 |
+
const drawImageScaled = useCallback(
|
| 25 |
+
(imageData: ImageData, scale: number, offsetX: number, offsetY: number) => {
|
| 26 |
+
const ctx = contextRef.current
|
| 27 |
+
const canvas = canvasRef.current
|
| 28 |
+
if (!ctx || !canvas) return
|
| 29 |
+
|
| 30 |
+
const displayWidth = Math.round(imageData.width * scale)
|
| 31 |
+
const displayHeight = Math.round(imageData.height * scale)
|
| 32 |
+
|
| 33 |
+
canvas.width = displayWidth
|
| 34 |
+
canvas.height = displayHeight
|
| 35 |
+
|
| 36 |
+
ctx.imageSmoothingEnabled = false
|
| 37 |
+
ctx.drawImage(
|
| 38 |
+
createImageBitmapFromImageData(imageData),
|
| 39 |
+
0,
|
| 40 |
+
0,
|
| 41 |
+
imageData.width,
|
| 42 |
+
imageData.height,
|
| 43 |
+
offsetX,
|
| 44 |
+
offsetY,
|
| 45 |
+
displayWidth,
|
| 46 |
+
displayHeight
|
| 47 |
+
)
|
| 48 |
+
},
|
| 49 |
+
[]
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
const clear = useCallback(() => {
|
| 53 |
+
const ctx = contextRef.current
|
| 54 |
+
const canvas = canvasRef.current
|
| 55 |
+
if (!ctx || !canvas) return
|
| 56 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
| 57 |
+
}, [])
|
| 58 |
+
|
| 59 |
+
const getCanvas = useCallback(() => canvasRef.current, [])
|
| 60 |
+
const getContext = useCallback(() => contextRef.current, [])
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
canvasRef,
|
| 64 |
+
drawImage,
|
| 65 |
+
drawImageScaled,
|
| 66 |
+
clear,
|
| 67 |
+
getCanvas,
|
| 68 |
+
getContext,
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function createImageBitmapFromImageData(imageData: ImageData): ImageBitmap {
|
| 73 |
+
const canvas = document.createElement('canvas')
|
| 74 |
+
canvas.width = imageData.width
|
| 75 |
+
canvas.height = imageData.height
|
| 76 |
+
const ctx = canvas.getContext('2d')
|
| 77 |
+
if (!ctx) throw new Error('Failed to create canvas context')
|
| 78 |
+
ctx.putImageData(imageData, 0, 0)
|
| 79 |
+
// Note: createImageBitmap is async, this is a synchronous fallback
|
| 80 |
+
return canvas as unknown as ImageBitmap
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export function useZoomPan() {
|
| 84 |
+
const { settings, setZoomLevel, setPanOffset } = useAppStore()
|
| 85 |
+
const zoomRef = useRef(settings.zoomLevel)
|
| 86 |
+
const panRef = useRef(settings.panOffset)
|
| 87 |
+
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
zoomRef.current = settings.zoomLevel
|
| 90 |
+
panRef.current = settings.panOffset
|
| 91 |
+
}, [settings.zoomLevel, settings.panOffset])
|
| 92 |
+
|
| 93 |
+
const zoomIn = useCallback(() => {
|
| 94 |
+
const newZoom = Math.min(zoomRef.current * 1.2, 20)
|
| 95 |
+
setZoomLevel(newZoom)
|
| 96 |
+
}, [setZoomLevel])
|
| 97 |
+
|
| 98 |
+
const zoomOut = useCallback(() => {
|
| 99 |
+
const newZoom = Math.max(zoomRef.current / 1.2, 0.1)
|
| 100 |
+
setZoomLevel(newZoom)
|
| 101 |
+
}, [setZoomLevel])
|
| 102 |
+
|
| 103 |
+
const resetZoom = useCallback(() => {
|
| 104 |
+
setZoomLevel(1)
|
| 105 |
+
setPanOffset({ x: 0, y: 0 })
|
| 106 |
+
}, [setZoomLevel, setPanOffset])
|
| 107 |
+
|
| 108 |
+
const setZoom = useCallback(
|
| 109 |
+
(zoom: number) => {
|
| 110 |
+
setZoomLevel(Math.max(0.1, Math.min(20, zoom)))
|
| 111 |
+
},
|
| 112 |
+
[setZoomLevel]
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
const pan = useCallback(
|
| 116 |
+
(deltaX: number, deltaY: number) => {
|
| 117 |
+
const newPan = {
|
| 118 |
+
x: panRef.current.x + deltaX,
|
| 119 |
+
y: panRef.current.y + deltaY,
|
| 120 |
+
}
|
| 121 |
+
setPanOffset(newPan)
|
| 122 |
+
},
|
| 123 |
+
[setPanOffset]
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
const panTo = useCallback(
|
| 127 |
+
(x: number, y: number) => {
|
| 128 |
+
setPanOffset({ x, y })
|
| 129 |
+
},
|
| 130 |
+
[setPanOffset]
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return {
|
| 134 |
+
zoom: settings.zoomLevel,
|
| 135 |
+
panOffset: settings.panOffset,
|
| 136 |
+
zoomIn,
|
| 137 |
+
zoomOut,
|
| 138 |
+
resetZoom,
|
| 139 |
+
setZoom,
|
| 140 |
+
pan,
|
| 141 |
+
panTo,
|
| 142 |
+
}
|
| 143 |
+
}
|
src/hooks/useConvolution.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useEffect } from 'react'
|
| 2 |
+
import { useAppStore } from '@/store'
|
| 3 |
+
import { convolutionSteps, convolveImage } from '@/lib/convolution/engine'
|
| 4 |
+
import type { PaddingMode, ConvolutionStep } from '@/types'
|
| 5 |
+
|
| 6 |
+
export function useConvolution() {
|
| 7 |
+
const {
|
| 8 |
+
originalImageData,
|
| 9 |
+
selectedKernel,
|
| 10 |
+
customKernelMatrix,
|
| 11 |
+
customKernelNormalize,
|
| 12 |
+
settings,
|
| 13 |
+
convolution,
|
| 14 |
+
startConvolution,
|
| 15 |
+
pauseConvolution,
|
| 16 |
+
resumeConvolution,
|
| 17 |
+
stopConvolution,
|
| 18 |
+
updateConvolutionStep,
|
| 19 |
+
updateConvolutionProgress,
|
| 20 |
+
completeConvolution,
|
| 21 |
+
setConvolutionError,
|
| 22 |
+
resetConvolution,
|
| 23 |
+
} = useAppStore()
|
| 24 |
+
|
| 25 |
+
const animationRef = useRef<number | null>(null)
|
| 26 |
+
const generatorRef = useRef<Generator<ConvolutionStep> | null>(null)
|
| 27 |
+
const isPausedRef = useRef(false)
|
| 28 |
+
const stepModeRef = useRef(false)
|
| 29 |
+
|
| 30 |
+
const kernel = selectedKernel?.matrix ?? customKernelMatrix
|
| 31 |
+
const normalize = selectedKernel?.normalize ?? customKernelNormalize
|
| 32 |
+
const padding = settings.padding
|
| 33 |
+
|
| 34 |
+
const runConvolution = useCallback(async () => {
|
| 35 |
+
if (!originalImageData || !kernel) return
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
startConvolution()
|
| 39 |
+
|
| 40 |
+
const extendedImage = {
|
| 41 |
+
data: originalImageData.data,
|
| 42 |
+
width: originalImageData.width,
|
| 43 |
+
height: originalImageData.height,
|
| 44 |
+
channels: 4,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const gen = convolutionSteps(extendedImage, kernel, padding, normalize, (progress) => {
|
| 48 |
+
const totalPixels = originalImageData.width * originalImageData.height * 4
|
| 49 |
+
const processedPixels = Math.round(progress * totalPixels)
|
| 50 |
+
const elapsedTime = Date.now() - convolution.startTime
|
| 51 |
+
updateConvolutionProgress(progress, processedPixels, elapsedTime)
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
generatorRef.current = gen
|
| 55 |
+
|
| 56 |
+
const processBatch = () => {
|
| 57 |
+
if (isPausedRef.current || stepModeRef.current) {
|
| 58 |
+
animationRef.current = requestAnimationFrame(processBatch)
|
| 59 |
+
return
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const batchSize = Math.max(1, Math.floor(1000 * settings.animationSpeed))
|
| 63 |
+
let lastStep: ConvolutionStep | null = null
|
| 64 |
+
|
| 65 |
+
for (let i = 0; i < batchSize; i++) {
|
| 66 |
+
const result = gen.next()
|
| 67 |
+
if (result.done) {
|
| 68 |
+
if (lastStep && lastStep.x === -1) {
|
| 69 |
+
// Final step with output image
|
| 70 |
+
const outputImageData = new ImageData(
|
| 71 |
+
new Uint8ClampedArray(originalImageData.data.length),
|
| 72 |
+
originalImageData.width,
|
| 73 |
+
originalImageData.height
|
| 74 |
+
)
|
| 75 |
+
// We need to reconstruct from the generator's final output
|
| 76 |
+
// For now, use the direct convolution
|
| 77 |
+
const output = convolveImage(originalImageData, kernel, padding, normalize)
|
| 78 |
+
completeConvolution(output)
|
| 79 |
+
}
|
| 80 |
+
return
|
| 81 |
+
}
|
| 82 |
+
lastStep = result.value
|
| 83 |
+
updateConvolutionStep(result.value)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
animationRef.current = requestAnimationFrame(processBatch)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
animationRef.current = requestAnimationFrame(processBatch)
|
| 90 |
+
} catch (error) {
|
| 91 |
+
setConvolutionError(error instanceof Error ? error.message : 'Convolution failed')
|
| 92 |
+
}
|
| 93 |
+
}, [
|
| 94 |
+
originalImageData,
|
| 95 |
+
kernel,
|
| 96 |
+
normalize,
|
| 97 |
+
padding,
|
| 98 |
+
settings.animationSpeed,
|
| 99 |
+
convolution.startTime,
|
| 100 |
+
startConvolution,
|
| 101 |
+
updateConvolutionStep,
|
| 102 |
+
updateConvolutionProgress,
|
| 103 |
+
completeConvolution,
|
| 104 |
+
setConvolutionError,
|
| 105 |
+
])
|
| 106 |
+
|
| 107 |
+
const pause = useCallback(() => {
|
| 108 |
+
isPausedRef.current = true
|
| 109 |
+
pauseConvolution()
|
| 110 |
+
}, [pauseConvolution])
|
| 111 |
+
|
| 112 |
+
const resume = useCallback(() => {
|
| 113 |
+
isPausedRef.current = false
|
| 114 |
+
resumeConvolution()
|
| 115 |
+
}, [resumeConvolution])
|
| 116 |
+
|
| 117 |
+
const stop = useCallback(() => {
|
| 118 |
+
if (animationRef.current) {
|
| 119 |
+
cancelAnimationFrame(animationRef.current)
|
| 120 |
+
animationRef.current = null
|
| 121 |
+
}
|
| 122 |
+
isPausedRef.current = false
|
| 123 |
+
stepModeRef.current = false
|
| 124 |
+
generatorRef.current = null
|
| 125 |
+
stopConvolution()
|
| 126 |
+
}, [stopConvolution])
|
| 127 |
+
|
| 128 |
+
const step = useCallback(() => {
|
| 129 |
+
if (!generatorRef.current) return
|
| 130 |
+
stepModeRef.current = true
|
| 131 |
+
const result = generatorRef.current.next()
|
| 132 |
+
if (!result.done) {
|
| 133 |
+
updateConvolutionStep(result.value)
|
| 134 |
+
} else if (result.value && result.value.x === -1) {
|
| 135 |
+
const output = convolveImage(originalImageData!, kernel, padding, normalize)
|
| 136 |
+
completeConvolution(output)
|
| 137 |
+
}
|
| 138 |
+
}, [originalImageData, kernel, padding, normalize, updateConvolutionStep, completeConvolution])
|
| 139 |
+
|
| 140 |
+
const reset = useCallback(() => {
|
| 141 |
+
stop()
|
| 142 |
+
resetConvolution()
|
| 143 |
+
}, [stop, resetConvolution])
|
| 144 |
+
|
| 145 |
+
const toggleStepMode = useCallback((enabled: boolean) => {
|
| 146 |
+
stepModeRef.current = enabled
|
| 147 |
+
}, [])
|
| 148 |
+
|
| 149 |
+
useEffect(() => {
|
| 150 |
+
return () => {
|
| 151 |
+
if (animationRef.current) {
|
| 152 |
+
cancelAnimationFrame(animationRef.current)
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
}, [])
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
convolution,
|
| 159 |
+
runConvolution,
|
| 160 |
+
pause,
|
| 161 |
+
resume,
|
| 162 |
+
stop,
|
| 163 |
+
step,
|
| 164 |
+
reset,
|
| 165 |
+
toggleStepMode,
|
| 166 |
+
isRunning: convolution.status === 'running',
|
| 167 |
+
isPaused: convolution.status === 'paused',
|
| 168 |
+
isCompleted: convolution.status === 'completed',
|
| 169 |
+
canRun: !!originalImageData && !!kernel && convolution.status !== 'running',
|
| 170 |
+
}
|
| 171 |
+
}
|
src/hooks/useImageLoader.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useEffect, useState, useReducer } from 'react'
|
| 2 |
+
|
| 3 |
+
export function useImageLoader() {
|
| 4 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 5 |
+
|
| 6 |
+
const loadImage = useCallback(
|
| 7 |
+
(file: File): Promise<{ image: HTMLImageElement; imageData: ImageData }> => {
|
| 8 |
+
return new Promise((resolve, reject) => {
|
| 9 |
+
const url = URL.createObjectURL(file)
|
| 10 |
+
const img = new Image()
|
| 11 |
+
img.onload = () => {
|
| 12 |
+
const canvas = document.createElement('canvas')
|
| 13 |
+
canvas.width = img.width
|
| 14 |
+
canvas.height = img.height
|
| 15 |
+
const ctx = canvas.getContext('2d')
|
| 16 |
+
if (!ctx) {
|
| 17 |
+
URL.revokeObjectURL(url)
|
| 18 |
+
reject(new Error('Failed to get canvas context'))
|
| 19 |
+
return
|
| 20 |
+
}
|
| 21 |
+
ctx.drawImage(img, 0, 0)
|
| 22 |
+
const imageData = ctx.getImageData(0, 0, img.width, img.height)
|
| 23 |
+
URL.revokeObjectURL(url)
|
| 24 |
+
resolve({ image: img, imageData })
|
| 25 |
+
}
|
| 26 |
+
img.onerror = () => {
|
| 27 |
+
URL.revokeObjectURL(url)
|
| 28 |
+
reject(new Error('Failed to load image'))
|
| 29 |
+
}
|
| 30 |
+
img.src = url
|
| 31 |
+
})
|
| 32 |
+
},
|
| 33 |
+
[]
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
const openFileDialog = useCallback(() => {
|
| 37 |
+
fileInputRef.current?.click()
|
| 38 |
+
}, [])
|
| 39 |
+
|
| 40 |
+
const handleFileSelect = useCallback(
|
| 41 |
+
(event: React.ChangeEvent<HTMLInputElement>, onLoad: (data: { image: HTMLImageElement; imageData: ImageData }) => void) => {
|
| 42 |
+
const file = event.target.files?.[0]
|
| 43 |
+
if (!file) return
|
| 44 |
+
|
| 45 |
+
if (!file.type.startsWith('image/')) {
|
| 46 |
+
alert('Please select an image file')
|
| 47 |
+
return
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
loadImage(file).then(onLoad).catch((error) => {
|
| 51 |
+
alert(`Failed to load image: ${error.message}`)
|
| 52 |
+
})
|
| 53 |
+
},
|
| 54 |
+
[loadImage]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
fileInputRef,
|
| 59 |
+
loadImage,
|
| 60 |
+
openFileDialog,
|
| 61 |
+
handleFileSelect,
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function useDragAndDrop(onLoad: (image: HTMLImageElement, imageData: ImageData) => void) {
|
| 66 |
+
const [isDragging, setIsDragging] = useState(false)
|
| 67 |
+
|
| 68 |
+
const loadImageFromFile = useCallback(
|
| 69 |
+
(file: File): Promise<{ image: HTMLImageElement; imageData: ImageData }> => {
|
| 70 |
+
return new Promise((resolve, reject) => {
|
| 71 |
+
const url = URL.createObjectURL(file)
|
| 72 |
+
const img = new Image()
|
| 73 |
+
img.onload = () => {
|
| 74 |
+
const canvas = document.createElement('canvas')
|
| 75 |
+
canvas.width = img.width
|
| 76 |
+
canvas.height = img.height
|
| 77 |
+
const ctx = canvas.getContext('2d')
|
| 78 |
+
if (!ctx) {
|
| 79 |
+
URL.revokeObjectURL(url)
|
| 80 |
+
reject(new Error('Failed to get canvas context'))
|
| 81 |
+
return
|
| 82 |
+
}
|
| 83 |
+
ctx.drawImage(img, 0, 0)
|
| 84 |
+
const imageData = ctx.getImageData(0, 0, img.width, img.height)
|
| 85 |
+
URL.revokeObjectURL(url)
|
| 86 |
+
resolve({ image: img, imageData })
|
| 87 |
+
}
|
| 88 |
+
img.onerror = () => {
|
| 89 |
+
URL.revokeObjectURL(url)
|
| 90 |
+
reject(new Error('Failed to load image'))
|
| 91 |
+
}
|
| 92 |
+
img.src = url
|
| 93 |
+
})
|
| 94 |
+
},
|
| 95 |
+
[]
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
| 99 |
+
e.preventDefault()
|
| 100 |
+
e.stopPropagation()
|
| 101 |
+
setIsDragging(true)
|
| 102 |
+
}, [])
|
| 103 |
+
|
| 104 |
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
| 105 |
+
e.preventDefault()
|
| 106 |
+
e.stopPropagation()
|
| 107 |
+
setIsDragging(false)
|
| 108 |
+
}, [])
|
| 109 |
+
|
| 110 |
+
const handleDrop = useCallback(
|
| 111 |
+
async (e: React.DragEvent) => {
|
| 112 |
+
e.preventDefault()
|
| 113 |
+
e.stopPropagation()
|
| 114 |
+
setIsDragging(false)
|
| 115 |
+
|
| 116 |
+
const file = e.dataTransfer.files[0]
|
| 117 |
+
if (!file || !file.type.startsWith('image/')) return
|
| 118 |
+
|
| 119 |
+
try {
|
| 120 |
+
const { image, imageData } = await loadImageFromFile(file)
|
| 121 |
+
onLoad(image, imageData)
|
| 122 |
+
} catch (error) {
|
| 123 |
+
alert(`Failed to load image: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
[onLoad, loadImageFromFile]
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
handleDragOver,
|
| 131 |
+
handleDragLeave,
|
| 132 |
+
handleDrop,
|
| 133 |
+
isDragging,
|
| 134 |
+
}
|
| 135 |
+
}
|
src/index.css
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
body {
|
| 2 |
-
margin: 0;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 4 |
-
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 5 |
-
sans-serif;
|
| 6 |
-
-webkit-font-smoothing: antialiased;
|
| 7 |
-
-moz-osx-font-smoothing: grayscale;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
code {
|
| 11 |
-
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
| 12 |
-
monospace;
|
| 13 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/index.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
-
import ReactDOM from 'react-dom/client';
|
| 3 |
-
import './index.css';
|
| 4 |
-
import App from './App';
|
| 5 |
-
import reportWebVitals from './reportWebVitals';
|
| 6 |
-
|
| 7 |
-
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 8 |
-
root.render(
|
| 9 |
-
<React.StrictMode>
|
| 10 |
-
<App />
|
| 11 |
-
</React.StrictMode>
|
| 12 |
-
);
|
| 13 |
-
|
| 14 |
-
// If you want to start measuring performance in your app, pass a function
|
| 15 |
-
// to log results (for example: reportWebVitals(console.log))
|
| 16 |
-
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
| 17 |
-
reportWebVitals();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/convolution/engine.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PaddingMode, ConvolutionStep, ImageDataExtended } from '@/types'
|
| 2 |
+
|
| 3 |
+
export function getPixel(
|
| 4 |
+
image: ImageDataExtended,
|
| 5 |
+
x: number,
|
| 6 |
+
y: number,
|
| 7 |
+
channel: number,
|
| 8 |
+
padding: PaddingMode
|
| 9 |
+
): number {
|
| 10 |
+
const { width, height, channels, data } = image
|
| 11 |
+
|
| 12 |
+
let px = x
|
| 13 |
+
let py = y
|
| 14 |
+
|
| 15 |
+
switch (padding) {
|
| 16 |
+
case 'zero':
|
| 17 |
+
if (px < 0 || px >= width || py < 0 || py >= height) return 0
|
| 18 |
+
break
|
| 19 |
+
case 'replicate':
|
| 20 |
+
px = Math.max(0, Math.min(px, width - 1))
|
| 21 |
+
py = Math.max(0, Math.min(py, height - 1))
|
| 22 |
+
break
|
| 23 |
+
case 'reflect':
|
| 24 |
+
if (px < 0) px = -px
|
| 25 |
+
if (px >= width) px = 2 * width - px - 2
|
| 26 |
+
if (py < 0) py = -py
|
| 27 |
+
if (py >= height) py = 2 * height - py - 2
|
| 28 |
+
px = Math.max(0, Math.min(px, width - 1))
|
| 29 |
+
py = Math.max(0, Math.min(py, height - 1))
|
| 30 |
+
break
|
| 31 |
+
case 'wrap':
|
| 32 |
+
px = ((px % width) + width) % width
|
| 33 |
+
py = ((py % height) + height) % height
|
| 34 |
+
break
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const index = (py * width + px) * channels + channel
|
| 38 |
+
return data[index] / 255
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function setPixel(
|
| 42 |
+
image: ImageDataExtended,
|
| 43 |
+
x: number,
|
| 44 |
+
y: number,
|
| 45 |
+
channel: number,
|
| 46 |
+
value: number
|
| 47 |
+
): void {
|
| 48 |
+
const { width, channels, data } = image
|
| 49 |
+
if (x < 0 || x >= width || y < 0 || y >= image.height) return
|
| 50 |
+
const index = (y * width + x) * channels + channel
|
| 51 |
+
data[index] = Math.max(0, Math.min(255, Math.round(value * 255)))
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function* convolutionSteps(
|
| 55 |
+
image: ImageDataExtended,
|
| 56 |
+
kernel: number[][],
|
| 57 |
+
padding: PaddingMode,
|
| 58 |
+
normalize: boolean,
|
| 59 |
+
onProgress?: (progress: number) => void
|
| 60 |
+
): Generator<ConvolutionStep> {
|
| 61 |
+
const { width, height, channels } = image
|
| 62 |
+
const kernelSize = kernel.length
|
| 63 |
+
const halfKernel = Math.floor(kernelSize / 2)
|
| 64 |
+
|
| 65 |
+
const totalPixels = width * height * channels
|
| 66 |
+
let processedPixels = 0
|
| 67 |
+
|
| 68 |
+
const kernelSum = kernel.flat().reduce((a, b) => a + b, 0)
|
| 69 |
+
const normalizeFactor = normalize && kernelSum !== 0 ? 1 / kernelSum : 1
|
| 70 |
+
|
| 71 |
+
const outputData = new Float32Array(width * height * channels)
|
| 72 |
+
|
| 73 |
+
for (let y = 0; y < height; y++) {
|
| 74 |
+
for (let x = 0; x < width; x++) {
|
| 75 |
+
for (let c = 0; c < channels; c++) {
|
| 76 |
+
const inputPatch: number[][] = []
|
| 77 |
+
const multiplications: number[][] = []
|
| 78 |
+
let sum = 0
|
| 79 |
+
|
| 80 |
+
for (let ky = 0; ky < kernelSize; ky++) {
|
| 81 |
+
const patchRow: number[] = []
|
| 82 |
+
const multRow: number[] = []
|
| 83 |
+
for (let kx = 0; kx < kernelSize; kx++) {
|
| 84 |
+
const px = x + kx - halfKernel
|
| 85 |
+
const py = y + ky - halfKernel
|
| 86 |
+
const pixelValue = getPixel(image, px, py, c, padding)
|
| 87 |
+
const kernelValue = kernel[ky][kx]
|
| 88 |
+
patchRow.push(pixelValue)
|
| 89 |
+
const mult = pixelValue * kernelValue
|
| 90 |
+
multRow.push(mult)
|
| 91 |
+
sum += mult
|
| 92 |
+
}
|
| 93 |
+
inputPatch.push(patchRow)
|
| 94 |
+
multiplications.push(multRow)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const outputValue = sum
|
| 98 |
+
const normalizedValue = sum * normalizeFactor
|
| 99 |
+
|
| 100 |
+
const outputIndex = (y * width + x) * channels + c
|
| 101 |
+
outputData[outputIndex] = normalizedValue
|
| 102 |
+
|
| 103 |
+
processedPixels++
|
| 104 |
+
const progress = processedPixels / totalPixels
|
| 105 |
+
|
| 106 |
+
yield {
|
| 107 |
+
x,
|
| 108 |
+
y,
|
| 109 |
+
channel: c,
|
| 110 |
+
inputPatch,
|
| 111 |
+
kernel,
|
| 112 |
+
multiplications,
|
| 113 |
+
sum: outputValue,
|
| 114 |
+
outputValue: normalizedValue,
|
| 115 |
+
normalizedValue,
|
| 116 |
+
progress,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if (onProgress) onProgress(progress)
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Convert output to ImageData
|
| 125 |
+
const outputImageData = new ImageData(width, height)
|
| 126 |
+
for (let i = 0; i < outputData.length; i++) {
|
| 127 |
+
outputImageData.data[i] = Math.max(0, Math.min(255, Math.round(outputData[i] * 255)))
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Final yield with complete output
|
| 131 |
+
yield {
|
| 132 |
+
x: -1,
|
| 133 |
+
y: -1,
|
| 134 |
+
channel: -1,
|
| 135 |
+
inputPatch: [],
|
| 136 |
+
kernel,
|
| 137 |
+
multiplications: [],
|
| 138 |
+
sum: 0,
|
| 139 |
+
outputValue: 0,
|
| 140 |
+
normalizedValue: 0,
|
| 141 |
+
progress: 1,
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export function convolveImage(
|
| 146 |
+
imageData: ImageData,
|
| 147 |
+
kernel: number[][],
|
| 148 |
+
padding: PaddingMode = 'zero',
|
| 149 |
+
normalize: boolean = true
|
| 150 |
+
): ImageData {
|
| 151 |
+
const extended: ImageDataExtended = {
|
| 152 |
+
data: imageData.data,
|
| 153 |
+
width: imageData.width,
|
| 154 |
+
height: imageData.height,
|
| 155 |
+
channels: 4,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const { width, height } = imageData
|
| 159 |
+
const kernelSize = kernel.length
|
| 160 |
+
const halfKernel = Math.floor(kernelSize / 2)
|
| 161 |
+
const kernelSum = kernel.flat().reduce((a, b) => a + b, 0)
|
| 162 |
+
const normalizeFactor = normalize && kernelSum !== 0 ? 1 / kernelSum : 1
|
| 163 |
+
|
| 164 |
+
const outputData = new Uint8ClampedArray(imageData.data.length)
|
| 165 |
+
|
| 166 |
+
for (let y = 0; y < height; y++) {
|
| 167 |
+
for (let x = 0; x < width; x++) {
|
| 168 |
+
for (let c = 0; c < 3; c++) {
|
| 169 |
+
let sum = 0
|
| 170 |
+
for (let ky = 0; ky < kernelSize; ky++) {
|
| 171 |
+
for (let kx = 0; kx < kernelSize; kx++) {
|
| 172 |
+
const px = x + kx - halfKernel
|
| 173 |
+
const py = y + ky - halfKernel
|
| 174 |
+
const pixelValue = getPixel(extended, px, py, c, padding)
|
| 175 |
+
sum += pixelValue * kernel[ky][kx]
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
const outputIndex = (y * width + x) * 4 + c
|
| 179 |
+
outputData[outputIndex] = Math.max(0, Math.min(255, Math.round(sum * normalizeFactor * 255)))
|
| 180 |
+
}
|
| 181 |
+
outputData[(y * width + x) * 4 + 3] = 255
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return new ImageData(outputData, imageData.width, imageData.height)
|
| 186 |
+
}
|