{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "heatmap-canvas", "title": "Heatmap Canvas", "description": "Renders a source image with a heatmap visual effect by mapping pixel intensity to a color gradient (thermal, viridis, jet, grayscale). | ソース画像をピクセル強度に応じた色グラデーションでヒートマップ表示。", "files": [ { "path": "components/media/heatmap-canvas.tsx", "content": "\"use client\";\n/**\n * HeatmapCanvas: Renders a source image with a heatmap visual effect by mapping\n * pixel intensity (luminance or single channel) to a color gradient. Uses Canvas 2D\n * only; no 3D or extra libraries. Supports objectFit and device pixel ratio.\n */\nimport React, { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype ColormapPreset = \"thermal\" | \"viridis\" | \"jet\" | \"grayscale\";\ntype IntensitySource = \"luminance\" | \"r\" | \"g\" | \"b\";\n\ntype HeatmapCanvasProps = {\n /** Image URL (must be CORS-enabled for canvas read). */\n src: string;\n /** Display width in CSS pixels. */\n width?: number;\n /** Display height in CSS pixels. */\n height?: number;\n /** When true, the component fills its outer container; size is determined by ResizeObserver. */\n fill?: boolean;\n /** Color gradient used to map intensity to color. \"thermal | viridis | jet | grayscale\". */\n colormap?: ColormapPreset;\n /** Which value to use as intensity: luminance or single channel. \"luminance | r | g | b\". */\n intensitySource?: IntensitySource;\n /** How the image fits within the canvas. \"cover | contain | fill | none\". */\n objectFit?: \"cover\" | \"contain\" | \"fill\" | \"none\";\n className?: string;\n};\n\nfunction colormapThermal(t: number): [number, number, number] {\n const x = Math.max(0, Math.min(1, t));\n if (x < 1 / 3) {\n const k = x * 3;\n return [Math.round(k * 255), 0, 0];\n }\n if (x < 2 / 3) {\n const k = (x - 1 / 3) * 3;\n return [255, Math.round(k * 255), 0];\n }\n const k = (x - 2 / 3) * 3;\n return [255, 255, Math.round(k * 255)];\n}\n\nfunction colormapViridis(t: number): [number, number, number] {\n const x = Math.max(0, Math.min(1, t));\n // Simplified viridis-like: dark purple -> blue -> teal -> green -> yellow\n if (x < 0.25) {\n const k = x * 4;\n return [\n Math.round(68 + (59 - 68) * k),\n Math.round(1 + (82 - 1) * k),\n Math.round(84 + (139 - 84) * k),\n ];\n }\n if (x < 0.5) {\n const k = (x - 0.25) * 4;\n return [\n Math.round(59 + (33 - 59) * k),\n Math.round(82 + (145 - 82) * k),\n Math.round(139 + (140 - 139) * k),\n ];\n }\n if (x < 0.75) {\n const k = (x - 0.5) * 4;\n return [\n Math.round(33 + (94 - 33) * k),\n Math.round(145 + (201 - 145) * k),\n Math.round(140 + (98 - 140) * k),\n ];\n }\n const k = (x - 0.75) * 4;\n return [\n Math.round(94 + (253 - 94) * k),\n Math.round(201 + (231 - 201) * k),\n Math.round(98 + (36 - 98) * k),\n ];\n}\n\nfunction colormapJet(t: number): [number, number, number] {\n const x = Math.max(0, Math.min(1, t));\n if (x < 0.125) {\n const k = x * 8;\n return [0, 0, Math.round(128 + (255 - 128) * k)];\n }\n if (x < 0.375) {\n const k = (x - 0.125) * 4;\n return [0, Math.round(k * 255), 255];\n }\n if (x < 0.625) {\n const k = (x - 0.375) * 4;\n return [Math.round(k * 255), 255, Math.round(255 - k * 255)];\n }\n if (x < 0.875) {\n const k = (x - 0.625) * 4;\n return [255, Math.round(255 - k * 255), 0];\n }\n const k = (x - 0.875) * 8;\n return [Math.round(255 - k * 255), 0, 0];\n}\n\nfunction colormapGrayscale(t: number): [number, number, number] {\n const v = Math.round(Math.max(0, Math.min(1, t)) * 255);\n return [v, v, v];\n}\n\nconst COLORMAP_FNS: Record<\n ColormapPreset,\n (t: number) => [number, number, number]\n> = {\n thermal: colormapThermal,\n viridis: colormapViridis,\n jet: colormapJet,\n grayscale: colormapGrayscale,\n};\n\nfunction getIntensity(\n data: Uint8ClampedArray,\n i: number,\n source: IntensitySource,\n): number {\n const r = data[i];\n const g = data[i + 1];\n const b = data[i + 2];\n if (source === \"r\") return r;\n if (source === \"g\") return g;\n if (source === \"b\") return b;\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nexport const HeatmapCanvas: React.FC = ({\n src,\n width = 400,\n height = 300,\n fill = false,\n colormap = \"thermal\",\n intensitySource = \"luminance\",\n objectFit = \"cover\",\n className,\n}) => {\n const [isLoading, setIsLoading] = useState(true);\n const [containerSize, setContainerSize] = useState<{\n width: number;\n height: number;\n } | null>(null);\n const canvasRef = useRef(null);\n const containerRef = useRef(null);\n\n useEffect(() => {\n if (!fill) return;\n const container = containerRef.current;\n if (!container) return;\n\n const updateSize = () => {\n const w = container.clientWidth;\n const h = container.clientHeight;\n setContainerSize(w > 0 && h > 0 ? { width: w, height: h } : null);\n };\n\n updateSize();\n const ro = new ResizeObserver(updateSize);\n ro.observe(container);\n return () => ro.disconnect();\n }, [fill]);\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 img.onload = () => {\n if (isCancelled) return;\n const dpr =\n typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n const displayWidth =\n fill && containerSize && containerSize.width > 0 && containerSize.height > 0\n ? containerSize.width\n : width ?? img.naturalWidth;\n const displayHeight =\n fill && containerSize && containerSize.width > 0 && containerSize.height > 0\n ? containerSize.height\n : 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) {\n setIsLoading(false);\n return;\n }\n ctx.resetTransform();\n ctx.scale(dpr, dpr);\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) {\n setIsLoading(false);\n return;\n }\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.fillStyle = \"#000\";\n off.fillRect(0, 0, offscreen.width, offscreen.height);\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 setIsLoading(false);\n return;\n }\n\n const data = imageData.data;\n const outData = new Uint8ClampedArray(data.length);\n const mapFn = COLORMAP_FNS[colormap];\n\n for (let i = 0; i < data.length; i += 4) {\n const intensity = getIntensity(data, i, intensitySource);\n const t = intensity / 255;\n const [r, g, b] = mapFn(t);\n outData[i] = r;\n outData[i + 1] = g;\n outData[i + 2] = b;\n outData[i + 3] = data[i + 3];\n }\n\n const result = new ImageData(outData, offscreen.width, offscreen.height);\n off.putImageData(result, 0, 0);\n ctx.drawImage(offscreen, 0, 0, displayWidth, displayHeight);\n setIsLoading(false);\n };\n\n img.onerror = () => {\n if (!isCancelled) setIsLoading(false);\n };\n\n return () => {\n isCancelled = true;\n };\n }, [src, width, height, fill, containerSize, colormap, intensitySource, objectFit]);\n\n return (\n \n \n {isLoading && (\n \n Loading…\n \n )}\n \n );\n};\n", "type": "registry:ui" } ], "type": "registry:ui" }