Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion r/gradient.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"path": "components/dither-kit/gradient.tsx",
"type": "registry:component",
"target": "components/dither-kit/gradient.tsx",
"content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"./lib\"\nimport { rgb } from \"./palette\"\nimport {\n BAYER4,\n fillOf,\n type PixelBloom,\n type PixelColor,\n pixelBloomStyle,\n} from \"./pixel\"\n\n// Backing-resolution caps — a background wash never needs more cells than this.\nconst MAX_COLS = 960\nconst MAX_ROWS = 600\n\nexport type GradientDirection = \"up\" | \"down\" | \"left\" | \"right\"\n\nexport type DitherGradientProps = {\n /** The colour the gradient starts solid as — a palette name or a hue. */\n from: PixelColor\n /** What it dissolves into: another colour for a two-tone dither blend, or\n * \"transparent\" (default) so the background shows through. */\n to?: PixelColor | \"transparent\"\n /** Where `to` ends up — \"up\" reads as a glow rising from the bottom edge. */\n direction?: GradientDirection\n /** CSS px per dither cell — bigger is chunkier. */\n cell?: number\n /** Overall opacity multiplier. */\n opacity?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n className?: string\n}\n\ntype PaintSpec = {\n from: PixelColor\n to: PixelColor | \"transparent\"\n direction: GradientDirection\n cell: number\n opacity: number\n}\n\n/**\n * Paint the ordered-dither ramp onto a low-res backing canvas sized from the\n * wrapper's box. Static — one paint per prop/size change, no animation loop,\n * so it's free to use as a page-wide background.\n */\nfunction paintGradient(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n width: number,\n height: number,\n spec: PaintSpec\n): void {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx || width <= 0 || height <= 0) return\n const cols = Math.min(MAX_COLS, Math.max(4, Math.round(width / spec.cell)))\n const rows = Math.min(MAX_ROWS, Math.max(4, Math.round(height / spec.cell)))\n canvas.width = cols\n canvas.height = rows\n\n const fromFill = fillOf(spec.from)\n const toFill = spec.to === \"transparent\" ? null : fillOf(spec.to)\n const o = spec.opacity\n\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n // t runs 0 at the `from` edge → 1 at the `to` edge.\n const t =\n spec.direction === \"up\"\n ? 1 - (y + 0.5) / rows\n : spec.direction === \"down\"\n ? (y + 0.5) / rows\n : spec.direction === \"left\"\n ? 1 - (x + 0.5) / cols\n : (x + 0.5) / cols\n const density = 1 - t\n const lit = density > BAYER4[y & 3][x & 3]\n if (toFill) {\n // Two-tone: every cell is painted, the dither decides which colour.\n ctx.fillStyle = rgb(lit ? fromFill : toFill, 1, o)\n ctx.fillRect(x, y, 1, 1)\n } else {\n // Dissolve to transparent: lit cells carry the ramp, off cells keep a\n // faint tint that also fades out, so the falloff reads smooth.\n const alpha = (lit ? 0.35 + 0.65 * density : 0.12 * density) * o\n if (alpha <= 0.004) continue\n ctx.fillStyle = rgb(fromFill, 1, alpha)\n ctx.fillRect(x, y, 1, 1)\n }\n }\n }\n\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas && bloomCtx) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n bloomCtx.drawImage(canvas, 0, 0)\n }\n}\n\n/**\n * Dithered gradient wash — the charts' ordered-dither texture as a background.\n * Fills its nearest positioned ancestor (footer glows, section fades, card\n * backdrops). Dissolves to transparent by default, or dither-blends between\n * two colours when `to` is set.\n */\nexport function DitherGradient({\n from,\n to = \"transparent\",\n direction = \"up\",\n cell = 3,\n opacity = 1,\n bloom = \"off\",\n className,\n}: DitherGradientProps) {\n const wrapRef = useRef<HTMLDivElement>(null)\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const bloomRef = useRef<HTMLCanvasElement>(null)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n const paint = () => {\n const box = wrap.getBoundingClientRect()\n paintGradient(canvas, bloomRef.current, box.width, box.height, {\n from,\n to,\n direction,\n cell,\n opacity,\n })\n }\n paint()\n if (typeof ResizeObserver === \"undefined\") return\n const ro = new ResizeObserver(paint)\n ro.observe(wrap)\n return () => ro.disconnect()\n }, [from, to, direction, cell, opacity, bloom])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n return (\n <div\n ref={wrapRef}\n aria-hidden\n className={cn(\n \"pointer-events-none absolute inset-0 overflow-hidden\",\n className\n )}\n >\n <canvas\n ref={canvasRef}\n className=\"absolute inset-0 h-full w-full\"\n style={{ imageRendering: \"pixelated\" }}\n />\n {bloomStyle && (\n <canvas\n ref={bloomRef}\n className=\"absolute inset-0 h-full w-full\"\n style={bloomStyle}\n />\n )}\n </div>\n )\n}\n"
"content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { cn } from \"./lib\"\nimport { fillOf, type PixelBloom, type PixelColor, pixelBloomStyle } from \"./pixel\"\n\nexport type GradientDirection = \"up\" | \"down\" | \"left\" | \"right\" | number\n\nexport type DitherGradientProps = {\n /** The colour the gradient starts solid as — a palette name or a hue. */\n from: PixelColor\n /** What it dissolves into: another colour for a two-tone dither blend, or\n * \"transparent\" (default) so the background shows through. */\n to?: PixelColor | \"transparent\"\n /** Where `to` ends up — \"up\" reads as a glow rising from the bottom edge, or a number for a custom angle in degrees. */\n direction?: GradientDirection\n /** CSS px per dither cell — bigger is chunkier. */\n cell?: number\n /** Overall opacity multiplier. */\n opacity?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n /** Number of quantization steps (color levels). Defaults to 6. */\n levels?: number\n className?: string\n}\n\n/**\n * Normalizes the direction prop into a standard angle in degrees.\n * 0° points right, 90° points down, 180° points left, 270° points up.\n */\nfunction getAngle(direction: GradientDirection): number {\n if (typeof direction === \"number\") return direction\n switch (direction) {\n case \"right\":\n return 0\n case \"down\":\n return 90\n case \"left\":\n return 180\n case \"up\":\n return 270\n default:\n return 270\n }\n}\n\n/**\n * Generates a Base64 PNG data URL of the Bayer threshold matrix.\n * We draw the 4x4 matrix onto an in-memory canvas scaled by the `cell` size.\n * This PNG is then fed into the SVG filter chain to act as the threshold map.\n */\nfunction generateBayerDataUrl(n: number, cell: number): string {\n if (typeof window === \"undefined\") return \"\"\n \n // Standard 4x4 Bayer matrix\n const rawMatrix = [\n [0, 8, 2, 10],\n [12, 4, 14, 6],\n [3, 11, 1, 9],\n [15, 7, 13, 5],\n ]\n const n2 = n * n\n \n const canvas = document.createElement(\"canvas\")\n canvas.width = n * cell\n canvas.height = n * cell\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return \"\"\n \n const imgData = ctx.createImageData(canvas.width, canvas.height)\n const data = imgData.data\n \n // Paint each cell as a greyscale block based on its matrix threshold value\n for (let y = 0; y < canvas.height; y++) {\n const by = Math.floor(y / cell) % n\n for (let x = 0; x < canvas.width; x++) {\n const bx = Math.floor(x / cell) % n\n const val = Math.round((rawMatrix[by][bx] / n2) * 255)\n const idx = (y * canvas.width + x) * 4\n data[idx] = val // R\n data[idx + 1] = val // G\n data[idx + 2] = val // B\n data[idx + 3] = 255 // A (fully opaque)\n }\n }\n ctx.putImageData(imgData, 0, 0)\n return canvas.toDataURL(\"image/png\")\n}\n\n/**\n * Dithered gradient wash — the charts' ordered-dither texture as a background.\n * Uses a procedural SVG filter chain (GPU accelerated) to dither between colors\n * or fade to transparency across any 360-degree angle.\n */\nexport function DitherGradient({\n from,\n to = \"transparent\",\n direction = \"up\",\n cell = 3,\n opacity = 1,\n bloom = \"off\",\n levels = 6,\n className,\n}: DitherGradientProps) {\n const wrapRef = useRef<HTMLDivElement>(null)\n const [dims, setDims] = useState({ width: 0, height: 0 })\n \n // Unique IDs for SVG defs to prevent collisions when multiple gradients are mounted\n const baseId = useId()\n const cleanId = baseId.replace(/:/g, \"\")\n const gradientId = `g-${cleanId}`\n const filterId = `d-${cleanId}`\n\n // Observe the wrapper's dimensions. We need the exact pixel width/height \n // to correctly project the SVG linear-gradient coordinates (`userSpaceOnUse`).\n useEffect(() => {\n const wrap = wrapRef.current\n if (!wrap) return\n const update = () => {\n const box = wrap.getBoundingClientRect()\n setDims({ width: box.width, height: box.height })\n }\n update()\n if (typeof ResizeObserver === \"undefined\") return\n const ro = new ResizeObserver(update)\n ro.observe(wrap)\n return () => ro.disconnect()\n }, [])\n\n const bayerDataUrl = useMemo(() => {\n return generateBayerDataUrl(4, cell)\n }, [cell])\n\n // Calculate the x1, y1, x2, y2 coordinates for the SVG linearGradient.\n // By projecting the four corners of the container onto the angle vector,\n // we ensure the gradient perfectly stretches corner-to-corner without distortion.\n const { x1, y1, x2, y2 } = useMemo(() => {\n const angle = getAngle(direction)\n const rad = (angle * Math.PI) / 180\n const dx = Math.cos(rad)\n const dy = Math.sin(rad)\n const w = dims.width\n const h = dims.height\n\n if (w === 0 || h === 0) {\n return { x1: 0, y1: 0, x2: 0, y2: 0 }\n }\n\n const corners = [\n [0, 0],\n [w, 0],\n [0, h],\n [w, h],\n ]\n let minP = Infinity\n let maxP = -Infinity\n for (const [cx, cy] of corners) {\n // Dot product to project the corner onto the angle vector\n const p = cx * dx + cy * dy\n if (p < minP) minP = p\n if (p > maxP) maxP = p\n }\n\n return {\n x1: minP * dx,\n y1: minP * dy,\n x2: maxP * dx,\n y2: maxP * dy,\n }\n }, [direction, dims.width, dims.height])\n\n // Color Matrix: Maps the quantized, dithered grayscale values into the final output colors.\n const matrixValues = useMemo(() => {\n const r1 = fillOf(from)\n if (to === \"transparent\") {\n // Dissolve to transparent:\n // Keep R, G, B channels locked to the `from` color.\n // Pipe the dithered grayscale pattern (from the Red channel) into the Alpha channel, scaled by overall opacity.\n return [\n 0, 0, 0, 0, r1[0] / 255,\n 0, 0, 0, 0, r1[1] / 255,\n 0, 0, 0, 0, r1[2] / 255,\n opacity, 0, 0, 0, 0,\n ].join(\" \")\n } else {\n // Two-tone blend:\n // Interpolate R, G, B channels between the `from` and `to` colors based on the dithered grayscale value.\n // Set the Alpha channel to the constant overall opacity.\n const r2 = fillOf(to)\n return [\n (r1[0] - r2[0]) / 255, 0, 0, 0, r2[0] / 255,\n 0, (r1[1] - r2[1]) / 255, 0, 0, r2[1] / 255,\n 0, 0, (r1[2] - r2[2]) / 255, 0, r2[2] / 255,\n 0, 0, 0, 0, opacity,\n ].join(\" \")\n }\n }, [from, to, opacity])\n\n // Calculate threshold step arrays for the discrete component transfer\n const stepsString = useMemo(() => {\n const l = Math.max(2, levels)\n const arr = []\n for (let i = 0; i < l; i++) {\n arr.push((i / (l - 1)).toFixed(4))\n }\n return arr.join(\" \")\n }, [levels])\n\n // Calculate weights for the arithmetic composite step\n // k2 scales the underlying gradient, k3 adds the Bayer matrix noise\n const { k2, k3 } = useMemo(() => {\n const l = Math.max(2, levels)\n return {\n k2: ((l - 1) / l).toFixed(4),\n k3: (1 / l).toFixed(4),\n }\n }, [levels])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n // Avoid rendering filter elements before width/height are measured to prevent SSR layout shifts\n if (dims.width === 0 || dims.height === 0) {\n return <div ref={wrapRef} className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)} />\n }\n\n return (\n <div\n ref={wrapRef}\n aria-hidden\n className={cn(\n \"pointer-events-none absolute inset-0 overflow-hidden\",\n className\n )}\n >\n <svg\n className=\"absolute inset-0 h-full w-full\"\n xmlns=\"http://www.w3.org/2000/svg\"\n style={{ width: \"100%\", height: \"100%\" }}\n >\n <defs>\n {/* Base uniform grayscale gradient */}\n <linearGradient\n id={gradientId}\n gradientUnits=\"userSpaceOnUse\"\n x1={x1}\n y1={y1}\n x2={x2}\n y2={y2}\n >\n <stop offset=\"0\" stopColor=\"white\" />\n <stop offset=\"1\" stopColor=\"black\" />\n </linearGradient>\n\n {/* Core Procedural Filter Chain */}\n <filter\n id={filterId}\n x=\"0\"\n y=\"0\"\n width=\"100%\"\n height=\"100%\"\n colorInterpolationFilters=\"sRGB\"\n >\n {/* 1. Import the Base64 Bayer matrix */}\n {bayerDataUrl && (\n <feImage\n href={bayerDataUrl}\n result=\"b\"\n width={4 * cell}\n height={4 * cell}\n preserveAspectRatio=\"none\"\n />\n )}\n {/* 2. Tile the matrix across the entire container */}\n <feTile in=\"b\" result=\"t\" />\n \n {/* 3. Mathematically add the tiled matrix noise to the smooth grayscale gradient */}\n <feComposite\n in=\"SourceGraphic\"\n in2=\"t\"\n operator=\"arithmetic\"\n k1=\"0\"\n k2={k2}\n k3={k3}\n k4=\"0\"\n result=\"c\"\n />\n \n {/* 4. Quantize the result into discrete steps (thresholding) */}\n <feComponentTransfer in=\"c\" result=\"q\">\n <feFuncR type=\"discrete\" tableValues={stepsString} />\n <feFuncG type=\"discrete\" tableValues={stepsString} />\n <feFuncB type=\"discrete\" tableValues={stepsString} />\n <feFuncA type=\"discrete\" tableValues={stepsString} />\n </feComponentTransfer>\n \n {/* 5. Remap the dithered grayscale pattern into the final colors and alpha */}\n <feColorMatrix type=\"matrix\" values={matrixValues} />\n </filter>\n </defs>\n \n {/* Render the actual element using the gradient and filter */}\n <rect\n width=\"100%\"\n height=\"100%\"\n fill={`url(#${gradientId})`}\n filter={`url(#${filterId})`}\n />\n \n {/* Render a duplicated layer for bloom/glow effects if requested */}\n {bloomStyle && (\n <rect\n width=\"100%\"\n height=\"100%\"\n fill={`url(#${gradientId})`}\n filter={`url(#${filterId})`}\n style={{\n ...bloomStyle,\n opacity: bloomStyle.opacity,\n }}\n />\n )}\n </svg>\n </div>\n )\n}\n"
},
{
"path": "components/dither-kit/pixel.ts",
Expand Down
Loading