{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "pixelated-canvas", "title": "Pixelated Canvas", "description": "Renders an image as a grid of dots with optional interactive pointer distortion (repel, attract, swirl) and jitter. Supports grayscale, tint, and object-fit. Use variant=\"static\" for non-interactive mode. | 画像をドットグリッドで描画。オプションでポインター変形・ジッター対応。", "files": [ { "path": "components/media/pixelated-canvas.tsx", "content": "\"use client\";\n/**\n * PixelatedCanvas: Renders an image as a grid of dots (circle or square) with optional\n * interactive pointer distortion (repel, attract, swirl) and jitter. Supports grayscale,\n * tint, dropout in low-contrast regions, and object-fit. Use variant=\"static\" for\n * non-interactive rendering.\n */\nimport React, { useEffect, useRef, useState } from \"react\";\n\nfunction parseTintColor(\n c: string\n): [number, number, number] | null {\n if (!c) return null;\n if (c.startsWith(\"#\")) {\n const hex = c.slice(1);\n if (hex.length === 3) {\n const r = parseInt(hex[0] + hex[0], 16);\n const g = parseInt(hex[1] + hex[1], 16);\n const b = parseInt(hex[2] + hex[2], 16);\n return [r, g, b];\n }\n const r = parseInt(hex.slice(0, 2), 16);\n const g = parseInt(hex.slice(2, 4), 16);\n const b = parseInt(hex.slice(4, 6), 16);\n return [r, g, b];\n }\n const m = c.match(/rgb\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)/i);\n if (m)\n return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];\n return null;\n}\n\ntype PixelatedCanvasProps = {\n src: string;\n width?: number;\n height?: number;\n /** \"static\" disables pointer interaction; \"interactive\" or undefined enables it. */\n variant?: \"static\" | \"interactive\";\n /** Size of each cell (in CSS pixels) used for sampling and spacing. */\n cellSize?: number;\n /** Dot size as a fraction of cell size (0..1). */\n dotScale?: number;\n /** Shape of the dot drawn for each sample. */\n shape?: \"circle\" | \"square\";\n /** Optional background color to clear the canvas with before drawing. */\n backgroundColor?: string;\n /** Convert to grayscale before drawing. */\n grayscale?: boolean;\n className?: string;\n /** Redraw on window resize using the provided width/height. */\n responsive?: boolean;\n /** 0..1. Higher value removes more dots in low-contrast regions. */\n dropoutStrength?: number;\n /** Enable interactive mouse distortion animation. Overridden by variant=\"static\". */\n interactive?: boolean;\n /** Max per-dot offset (px) due to distortion. */\n distortionStrength?: number;\n /** Radius (px) around pointer influencing distortion. */\n distortionRadius?: number;\n /** How pixels move near the pointer. */\n distortionMode?: \"repel\" | \"attract\" | \"swirl\";\n /** 0..1 smoothing factor for pointer follow. */\n followSpeed?: number;\n /** Average multiple samples per cell instead of single center sample. */\n sampleAverage?: boolean;\n /** Apply a color tint (e.g., \"#0ea5e9\" or \"rgb(14,165,233)\"). */\n tintColor?: string;\n /** 0..1 tint mix amount with original colors. */\n tintStrength?: number;\n /** Cap animation frame rate to improve perf on large canvases. */\n maxFps?: number;\n /** Object-fit behavior for the source image within the canvas. */\n objectFit?: \"cover\" | \"contain\" | \"fill\" | \"none\";\n /** Random motion amplitude for dots near the pointer. */\n jitterStrength?: number;\n /** Speed factor for the random motion. */\n jitterSpeed?: number;\n /** Smoothly fade the distortion when the pointer leaves. Overridden by variant=\"static\". */\n fadeOnLeave?: boolean;\n /** 0..1 smoothing factor for leave fade. Higher = faster fade. */\n fadeSpeed?: number;\n};\n\ntype Sample = {\n x: number;\n y: number;\n r: number;\n g: number;\n b: number;\n a: number;\n drop: boolean;\n seed: number;\n};\n\nexport const PixelatedCanvas: React.FC = ({\n src,\n width = 400,\n height = 500,\n variant,\n cellSize = 3,\n dotScale = 0.9,\n shape = \"square\",\n backgroundColor = \"#000000\",\n grayscale = false,\n className,\n responsive = false,\n dropoutStrength = 0.4,\n interactive = true,\n distortionStrength = 3,\n distortionRadius = 80,\n distortionMode = \"swirl\",\n followSpeed = 0.2,\n sampleAverage = true,\n tintColor = \"#FFFFFF\",\n tintStrength = 0.2,\n maxFps = 60,\n objectFit = \"cover\",\n jitterStrength = 4,\n jitterSpeed = 4,\n fadeOnLeave = true,\n fadeSpeed = 0.1,\n}) => {\n const isInteractive = variant === \"static\" ? false : interactive;\n const doFadeOnLeave = variant === \"static\" ? false : fadeOnLeave;\n\n const [isLoading, setIsLoading] = useState(true);\n const canvasRef = useRef(null);\n const samplesRef = useRef([]);\n const dimsRef = useRef<{\n width: number;\n height: number;\n dot: number;\n } | null>(null);\n const targetMouseRef = useRef({ x: -9999, y: -9999 });\n const animMouseRef = useRef({ x: -9999, y: -9999 });\n const rafRef = useRef(null);\n const lastFrameRef = useRef(0);\n const pointerInsideRef = useRef(false);\n const activityRef = useRef(0);\n const activityTargetRef = useRef(0);\n const cleanupRef = useRef<(() => void) | null>(null);\n\n useEffect(() => {\n let isCancelled = false;\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n setIsLoading(true);\n const img = new Image();\n img.crossOrigin = \"anonymous\";\n img.src = src;\n\n const onResize = () => {\n if (img.complete && img.naturalWidth) compute();\n };\n if (responsive) window.addEventListener(\"resize\", onResize);\n\n const compute = () => {\n if (!canvas) return;\n const dpr =\n typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n\n const displayWidth = width ?? img.naturalWidth;\n const displayHeight = height ?? img.naturalHeight;\n\n canvas.width = Math.max(1, Math.floor(displayWidth * dpr));\n canvas.height = Math.max(1, Math.floor(displayHeight * dpr));\n canvas.style.width = `${displayWidth}px`;\n canvas.style.height = `${displayHeight}px`;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n ctx.resetTransform();\n ctx.scale(dpr, dpr);\n\n if (backgroundColor) {\n ctx.fillStyle = backgroundColor;\n ctx.fillRect(0, 0, displayWidth, displayHeight);\n } else {\n ctx.clearRect(0, 0, displayWidth, displayHeight);\n }\n\n const offscreen = document.createElement(\"canvas\");\n offscreen.width = Math.max(1, Math.floor(displayWidth));\n offscreen.height = Math.max(1, Math.floor(displayHeight));\n const off = offscreen.getContext(\"2d\");\n if (!off) return;\n\n const iw = img.naturalWidth || displayWidth;\n const ih = img.naturalHeight || displayHeight;\n let dw = displayWidth;\n let dh = displayHeight;\n let dx = 0;\n let dy = 0;\n if (objectFit === \"cover\") {\n const scale = Math.max(displayWidth / iw, displayHeight / ih);\n dw = Math.ceil(iw * scale);\n dh = Math.ceil(ih * scale);\n dx = Math.floor((displayWidth - dw) / 2);\n dy = Math.floor((displayHeight - dh) / 2);\n } else if (objectFit === \"contain\") {\n const scale = Math.min(displayWidth / iw, displayHeight / ih);\n dw = Math.ceil(iw * scale);\n dh = Math.ceil(ih * scale);\n dx = Math.floor((displayWidth - dw) / 2);\n dy = Math.floor((displayHeight - dh) / 2);\n } else if (objectFit === \"fill\") {\n dw = displayWidth;\n dh = displayHeight;\n } else {\n dw = iw;\n dh = ih;\n dx = Math.floor((displayWidth - dw) / 2);\n dy = Math.floor((displayHeight - dh) / 2);\n }\n off.drawImage(img, dx, dy, dw, dh);\n\n let imageData: ImageData;\n try {\n imageData = off.getImageData(0, 0, offscreen.width, offscreen.height);\n } catch {\n ctx.drawImage(img, 0, 0, displayWidth, displayHeight);\n return;\n }\n\n const data = imageData.data;\n const stride = offscreen.width * 4;\n const effectiveDotSize = Math.max(1, Math.floor(cellSize * dotScale));\n dimsRef.current = {\n width: displayWidth,\n height: displayHeight,\n dot: effectiveDotSize,\n };\n\n const luminanceAt = (px: number, py: number) => {\n const ix = Math.max(0, Math.min(offscreen.width - 1, px));\n const iy = Math.max(0, Math.min(offscreen.height - 1, py));\n const i = iy * stride + ix * 4;\n const rr = data[i];\n const gg = data[i + 1];\n const bb = data[i + 2];\n return 0.2126 * rr + 0.7152 * gg + 0.0722 * bb;\n };\n\n const hash2D = (ix: number, iy: number) => {\n const s = Math.sin(ix * 12.9898 + iy * 78.233) * 43758.5453123;\n return s - Math.floor(s);\n };\n\n const tintRGB =\n tintColor && tintStrength > 0 ? parseTintColor(tintColor) : null;\n\n const samples: Sample[] = [];\n\n for (let y = 0; y < offscreen.height; y += cellSize) {\n const cy = Math.min(offscreen.height - 1, y + Math.floor(cellSize / 2));\n for (let x = 0; x < offscreen.width; x += cellSize) {\n const cx = Math.min(\n offscreen.width - 1,\n x + Math.floor(cellSize / 2),\n );\n let r = 0;\n let g = 0;\n let b = 0;\n let a = 0;\n if (!sampleAverage) {\n const idx = cy * stride + cx * 4;\n r = data[idx];\n g = data[idx + 1];\n b = data[idx + 2];\n a = data[idx + 3] / 255;\n } else {\n let count = 0;\n for (let oy = -1; oy <= 1; oy++) {\n for (let ox = -1; ox <= 1; ox++) {\n const sx = Math.max(0, Math.min(offscreen.width - 1, cx + ox));\n const sy = Math.max(0, Math.min(offscreen.height - 1, cy + oy));\n const sIdx = sy * stride + sx * 4;\n r += data[sIdx];\n g += data[sIdx + 1];\n b += data[sIdx + 2];\n a += data[sIdx + 3] / 255;\n count++;\n }\n }\n r = Math.round(r / count);\n g = Math.round(g / count);\n b = Math.round(b / count);\n a = a / count;\n }\n\n if (grayscale) {\n const L = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);\n r = L;\n g = L;\n b = L;\n } else if (tintRGB && tintStrength > 0) {\n const k = Math.max(0, Math.min(1, tintStrength));\n r = Math.round(r * (1 - k) + tintRGB[0] * k);\n g = Math.round(g * (1 - k) + tintRGB[1] * k);\n b = Math.round(b * (1 - k) + tintRGB[2] * k);\n }\n\n const Lc = luminanceAt(cx, cy);\n const Lx1 = luminanceAt(cx - 1, cy);\n const Lx2 = luminanceAt(cx + 1, cy);\n const Ly1 = luminanceAt(cx, cy - 1);\n const Ly2 = luminanceAt(cx, cy + 1);\n const grad =\n Math.abs(Lx2 - Lx1) +\n Math.abs(Ly2 - Ly1) +\n Math.abs(Lc - (Lx1 + Lx2 + Ly1 + Ly2) / 4);\n const gradientNorm = Math.max(0, Math.min(1, grad / 255));\n const dropoutProb = Math.max(\n 0,\n Math.min(1, (1 - gradientNorm) * dropoutStrength),\n );\n const drop = hash2D(cx, cy) < dropoutProb;\n const seed = hash2D(cx, cy);\n\n samples.push({ x, y, r, g, b, a, drop, seed });\n }\n }\n\n samplesRef.current = samples;\n };\n\n img.onload = () => {\n if (isCancelled) return;\n setIsLoading(false);\n compute();\n const canvasEl = canvasRef.current;\n if (!canvasEl) return;\n\n if (!isInteractive) {\n const ctx = canvasEl.getContext(\"2d\");\n const dims = dimsRef.current;\n const samples = samplesRef.current;\n if (!ctx || !dims || !samples) return;\n if (backgroundColor) {\n ctx.fillStyle = backgroundColor;\n ctx.fillRect(0, 0, dims.width, dims.height);\n } else {\n ctx.clearRect(0, 0, dims.width, dims.height);\n }\n for (const s of samples) {\n if (s.drop || s.a <= 0) continue;\n ctx.globalAlpha = s.a;\n ctx.fillStyle = `rgb(${s.r}, ${s.g}, ${s.b})`;\n if (shape === \"circle\") {\n const radius = dims.dot / 2;\n ctx.beginPath();\n ctx.arc(\n s.x + cellSize / 2,\n s.y + cellSize / 2,\n radius,\n 0,\n Math.PI * 2,\n );\n ctx.fill();\n } else {\n ctx.fillRect(\n s.x + cellSize / 2 - dims.dot / 2,\n s.y + cellSize / 2 - dims.dot / 2,\n dims.dot,\n dims.dot,\n );\n }\n }\n ctx.globalAlpha = 1;\n return;\n }\n\n const onPointerMove = (e: PointerEvent) => {\n const rect = canvasEl.getBoundingClientRect();\n targetMouseRef.current.x = e.clientX - rect.left;\n targetMouseRef.current.y = e.clientY - rect.top;\n pointerInsideRef.current = true;\n activityTargetRef.current = 1;\n };\n const onPointerEnter = () => {\n pointerInsideRef.current = true;\n activityTargetRef.current = 1;\n };\n const onPointerLeave = () => {\n pointerInsideRef.current = false;\n if (doFadeOnLeave) {\n activityTargetRef.current = 0;\n } else {\n targetMouseRef.current.x = -9999;\n targetMouseRef.current.y = -9999;\n }\n };\n canvasEl.addEventListener(\"pointermove\", onPointerMove);\n canvasEl.addEventListener(\"pointerenter\", onPointerEnter);\n canvasEl.addEventListener(\"pointerleave\", onPointerLeave);\n\n const animate = () => {\n const now = performance.now();\n const minDelta = 1000 / Math.max(1, maxFps);\n if (now - lastFrameRef.current < minDelta) {\n rafRef.current = requestAnimationFrame(animate);\n return;\n }\n lastFrameRef.current = now;\n const ctx = canvasEl.getContext(\"2d\");\n const dims = dimsRef.current;\n const samples = samplesRef.current;\n if (!ctx || !dims || !samples) {\n rafRef.current = requestAnimationFrame(animate);\n return;\n }\n\n animMouseRef.current.x =\n animMouseRef.current.x +\n (targetMouseRef.current.x - animMouseRef.current.x) * followSpeed;\n animMouseRef.current.y =\n animMouseRef.current.y +\n (targetMouseRef.current.y - animMouseRef.current.y) * followSpeed;\n\n if (doFadeOnLeave) {\n activityRef.current =\n activityRef.current +\n (activityTargetRef.current - activityRef.current) * fadeSpeed;\n } else {\n activityRef.current = pointerInsideRef.current ? 1 : 0;\n }\n\n if (backgroundColor) {\n ctx.fillStyle = backgroundColor;\n ctx.fillRect(0, 0, dims.width, dims.height);\n } else {\n ctx.clearRect(0, 0, dims.width, dims.height);\n }\n\n const mx = animMouseRef.current.x;\n const my = animMouseRef.current.y;\n const sigma = Math.max(1, distortionRadius * 0.5);\n const t = now * 0.001 * jitterSpeed;\n const activity = Math.max(0, Math.min(1, activityRef.current));\n\n for (const s of samples) {\n if (s.drop || s.a <= 0) continue;\n let drawX = s.x + cellSize / 2;\n let drawY = s.y + cellSize / 2;\n const dx = drawX - mx;\n const dy = drawY - my;\n const dist2 = dx * dx + dy * dy;\n const falloff = Math.exp(-dist2 / (2 * sigma * sigma));\n const influence = falloff * activity;\n if (influence > 0.0005) {\n if (distortionMode === \"repel\") {\n const dist = Math.sqrt(dist2) + 0.0001;\n drawX += (dx / dist) * distortionStrength * influence;\n drawY += (dy / dist) * distortionStrength * influence;\n } else if (distortionMode === \"attract\") {\n const dist = Math.sqrt(dist2) + 0.0001;\n drawX -= (dx / dist) * distortionStrength * influence;\n drawY -= (dy / dist) * distortionStrength * influence;\n } else if (distortionMode === \"swirl\") {\n const angle = distortionStrength * 0.05 * influence;\n const cosA = Math.cos(angle);\n const sinA = Math.sin(angle);\n const rx = cosA * dx - sinA * dy;\n const ry = sinA * dx + cosA * dy;\n drawX = mx + rx;\n drawY = my + ry;\n }\n\n if (jitterStrength > 0) {\n const k = s.seed * 43758.5453;\n const jx = Math.sin(t + k) * jitterStrength * influence;\n const jy = Math.cos(t + k * 1.13) * jitterStrength * influence;\n drawX += jx;\n drawY += jy;\n }\n }\n\n ctx.globalAlpha = s.a;\n ctx.fillStyle = `rgb(${s.r}, ${s.g}, ${s.b})`;\n if (shape === \"circle\") {\n const radius = dims.dot / 2;\n ctx.beginPath();\n ctx.arc(drawX, drawY, radius, 0, Math.PI * 2);\n ctx.fill();\n } else {\n ctx.fillRect(\n drawX - dims.dot / 2,\n drawY - dims.dot / 2,\n dims.dot,\n dims.dot,\n );\n }\n }\n ctx.globalAlpha = 1;\n\n rafRef.current = requestAnimationFrame(animate);\n };\n\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n rafRef.current = requestAnimationFrame(animate);\n\n cleanupRef.current = () => {\n canvasEl.removeEventListener(\"pointermove\", onPointerMove);\n canvasEl.removeEventListener(\"pointerenter\", onPointerEnter);\n canvasEl.removeEventListener(\"pointerleave\", onPointerLeave);\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n rafRef.current = null;\n };\n };\n\n img.onerror = () => {\n if (!isCancelled) setIsLoading(false);\n console.error(\"Failed to load image for PixelatedCanvas:\", src);\n };\n\n return () => {\n isCancelled = true;\n if (responsive) window.removeEventListener(\"resize\", onResize);\n cleanupRef.current?.();\n cleanupRef.current = null;\n };\n }, [\n src,\n width,\n height,\n cellSize,\n dotScale,\n shape,\n backgroundColor,\n grayscale,\n responsive,\n dropoutStrength,\n isInteractive,\n distortionStrength,\n distortionRadius,\n distortionMode,\n followSpeed,\n sampleAverage,\n tintColor,\n tintStrength,\n maxFps,\n objectFit,\n jitterStrength,\n jitterSpeed,\n doFadeOnLeave,\n fadeSpeed,\n ]);\n\n return (\n \n \n {isLoading && (\n \n
\n
\n )}\n \n );\n};\n", "type": "registry:ui" } ], "type": "registry:ui" }