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
5 changes: 3 additions & 2 deletions r/dither-kit.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
"https://tripwire.sh/r/radar-chart.json",
"https://tripwire.sh/r/avatar.json",
"https://tripwire.sh/r/button.json",
"https://tripwire.sh/r/gradient.json"
"https://tripwire.sh/r/gradient.json",
"https://tripwire.sh/r/icon.json"
],
"files": [
{
"path": "components/dither-kit/index.ts",
"type": "registry:component",
"target": "components/dither-kit/index.ts",
"content": "export { Area, type AreaProps, Line, type SeriesProps } from \"./area\"\nexport { AreaChart, type AreaChartProps, LineChart } from \"./area-chart\"\nexport {\n type AvatarMirror,\n DitherAvatar,\n type DitherAvatarProps,\n} from \"./avatar\"\nexport { Bar, type BarProps } from \"./bar\"\nexport { BarChart } from \"./bar-chart\"\nexport {\n type ButtonVariant,\n DitherButton,\n type DitherButtonProps,\n} from \"./button\"\nexport type { CartesianChartProps } from \"./cartesian-root\"\nexport type {\n AreaVariant,\n ChartConfig,\n ChartType,\n Margins,\n SeriesKind,\n StrokeVariant,\n} from \"./chart-context\"\nexport type {\n BloomBlend,\n BloomConfig,\n BloomInput,\n BloomLevel,\n} from \"./dither-paint\"\nexport { ActiveDot, Dot, type DotVariant } from \"./dot\"\nexport {\n DitherGradient,\n type DitherGradientProps,\n type GradientDirection,\n} from \"./gradient\"\nexport { Grid } from \"./grid\"\nexport { Legend } from \"./legend\"\nexport type { DitherColor } from \"./palette\"\nexport type { PixelBloom, PixelColor } from \"./pixel\"\nexport { Pie, type PieProps } from \"./pie\"\nexport { PieChart, type PieChartProps } from \"./pie-chart\"\nexport { Radar, type RadarProps } from \"./radar\"\nexport { RadarChart, type RadarChartProps } from \"./radar-chart\"\nexport type { StackType } from \"./scales\"\nexport { Sparkline, type SparklineProps } from \"./sparkline\"\nexport { Tooltip, type TooltipVariant } from \"./tooltip\"\nexport { XAxis } from \"./x-axis\"\nexport { YAxis } from \"./y-axis\"\n"
"content": "export { Area, type AreaProps, Line, type SeriesProps } from \"./area\"\nexport { AreaChart, type AreaChartProps, LineChart } from \"./area-chart\"\nexport {\n type AvatarMirror,\n DitherAvatar,\n type DitherAvatarProps,\n} from \"./avatar\"\nexport { Bar, type BarProps } from \"./bar\"\nexport { BarChart } from \"./bar-chart\"\nexport {\n type ButtonVariant,\n DitherButton,\n type DitherButtonProps,\n} from \"./button\"\nexport type { CartesianChartProps } from \"./cartesian-root\"\nexport type {\n AreaVariant,\n ChartConfig,\n ChartType,\n Margins,\n SeriesKind,\n StrokeVariant,\n} from \"./chart-context\"\nexport type {\n BloomBlend,\n BloomConfig,\n BloomInput,\n BloomLevel,\n} from \"./dither-paint\"\nexport { ActiveDot, Dot, type DotVariant } from \"./dot\"\nexport {\n DitherGradient,\n type DitherGradientProps,\n type GradientDirection,\n} from \"./gradient\"\nexport { Grid } from \"./grid\"\nexport { DitherIcon, type DitherIconProps } from \"./icon\"\nexport { Legend } from \"./legend\"\nexport type { DitherColor } from \"./palette\"\nexport type { PixelBloom, PixelColor } from \"./pixel\"\nexport { Pie, type PieProps } from \"./pie\"\nexport { PieChart, type PieChartProps } from \"./pie-chart\"\nexport { Radar, type RadarProps } from \"./radar\"\nexport { RadarChart, type RadarChartProps } from \"./radar-chart\"\nexport type { StackType } from \"./scales\"\nexport { Sparkline, type SparklineProps } from \"./sparkline\"\nexport { Tooltip, type TooltipVariant } from \"./tooltip\"\nexport { XAxis } from \"./x-axis\"\nexport { YAxis } from \"./y-axis\"\n"
}
]
}
44 changes: 44 additions & 0 deletions r/icon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "icon",
"type": "registry:component",
"title": "Dither Icon",
"description": "SVG-to-dither compiler - takes any SVG icon component, rasterizes it and re-emits it through the ordered-dither texture, with the Bayer materialize entrance and a controlled progress for scroll-linked reveals. Standalone: installs without the chart engine.",
"author": "ripgrim",
"categories": [
"icons"
],
"version": "0.1.0",
"dependencies": [
"clsx",
"tailwind-merge"
],
"devDependencies": [],
"registryDependencies": [],
"files": [
{
"path": "components/dither-kit/icon.tsx",
"type": "registry:component",
"target": "components/dither-kit/icon.tsx",
"content": "\"use client\"\n\nimport { type ComponentType, useEffect, useRef } from \"react\"\nimport { cn } from \"./lib\"\nimport { rgb, type Rgb } from \"./palette\"\nimport {\n BAYER4,\n clamp01,\n fillOf,\n type PixelBloom,\n type PixelColor,\n pixelBloomStyle,\n pixelPrefersReducedMotion,\n} from \"./pixel\"\n\nexport type DitherIconProps = {\n /** The SVG icon component to compile (e.g. any lucide-react icon). */\n icon: ComponentType<{ strokeWidth?: number }>\n color?: PixelColor\n /** Square size in px. */\n size?: number\n /** CSS px per dither cell — 1 is crisp, 2+ reads chunkier/pixel-art. */\n cell?: number\n /** Stroke width passed to the source icon — thicken for coarse cells. */\n strokeWidth?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n /** Play the Bayer-ordered materialize entrance. */\n animate?: boolean\n animationDuration?: number\n /** Controlled reveal (0–1), e.g. scroll-linked. Overrides `animate`. */\n progress?: number\n /** Bump to replay the entrance. */\n replayToken?: number\n /** Descriptive label, or null for decorative (aria-hidden). */\n ariaLabel?: string | null\n className?: string\n}\n\n/** Sampled alpha (0–255) → cell dither density. Below the floor the cell is\n * off; above it density spans ~0.37–0.85, so even solid fills keep the\n * texture (a density of 1 would paint every cell and lose the dither). */\nexport function alphaToDensity(alpha: number): number {\n if (alpha < 24) return 0\n return 0.35 + 0.5 * (alpha / 255)\n}\n\ntype IconModel = {\n density: number[] // cols×rows, row-major\n cols: number\n rows: number\n}\n\n/**\n * Rasterize the icon's `<svg>` at cell resolution and read the alpha channel\n * — the \"compile\" step. Colours don't matter (only coverage does), so\n * currentColor is pinned to an opaque ink before serializing.\n */\nfunction sampleSvg(\n svg: SVGElement,\n cols: number,\n rows: number,\n onModel: (model: IconModel) => void\n): () => void {\n const clone = svg.cloneNode(true) as SVGElement\n clone.setAttribute(\"xmlns\", \"http://www.w3.org/2000/svg\")\n const markup = new XMLSerializer()\n .serializeToString(clone)\n .replaceAll(\"currentColor\", \"#fff\")\n\n let cancelled = false\n const img = new Image()\n img.onload = () => {\n if (cancelled) return\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const sctx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!sctx) return\n sctx.drawImage(img, 0, 0, cols, rows)\n const data = sctx.getImageData(0, 0, cols, rows).data\n const density = new Array<number>(cols * rows)\n for (let i = 0; i < cols * rows; i++) {\n density[i] = alphaToDensity(data[i * 4 + 3])\n }\n onModel({ density, cols, rows })\n }\n img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`\n return () => {\n cancelled = true\n }\n}\n\n/** Size the backing stores once per compiled model — resizing resets the\n * canvas, so the per-frame painter must never touch width/height. */\nfunction sizeIconCanvases(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n model: IconModel\n): void {\n canvas.width = model.cols\n canvas.height = model.rows\n if (bloomCanvas) {\n bloomCanvas.width = model.cols\n bloomCanvas.height = model.rows\n }\n}\n\n/** Paint one frame at reveal `progress`, cells materializing in Bayer order\n * (same entrance as the avatar/charts). */\nfunction paintIcon(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n model: IconModel,\n fill: Rgb,\n progress: number\n): void {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n const { density, cols, rows } = model\n ctx.clearRect(0, 0, cols, rows)\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n const d = density[r * cols + c]\n if (d <= 0) continue\n const start = BAYER4[r % 4][c % 4] * 0.7\n const cellAlpha = clamp01((progress - start) / 0.3)\n if (cellAlpha <= 0) continue\n const lit = d > BAYER4[r & 3][c & 3]\n const alpha = (lit ? 0.35 + 0.65 * d : 0.12 * d) * cellAlpha\n ctx.fillStyle = rgb(fill, 1, alpha)\n ctx.fillRect(c, r, 1, 1)\n }\n }\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas && bloomCtx) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n}\n\n/**\n * SVG→dither compiler — takes any SVG icon component, rasterizes it at cell\n * resolution, and re-emits it through the ordered-dither texture the rest of\n * the kit is made of. Reveals with the Bayer materialize entrance, or under\n * a controlled `progress` for scroll-linked reveals.\n */\nexport function DitherIcon({\n icon: Icon,\n color = \"grey\",\n size = 24,\n cell = 1,\n strokeWidth = 2,\n bloom = \"off\",\n animate = true,\n animationDuration = 500,\n progress,\n replayToken = 0,\n ariaLabel = null,\n className,\n}: DitherIconProps) {\n const holderRef = useRef<HTMLSpanElement>(null)\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const bloomRef = useRef<HTMLCanvasElement>(null)\n const modelRef = useRef<IconModel | null>(null)\n // The sampling effect must not re-run when only `progress` scrubs — read it\n // through a ref there instead of listing it as a dep.\n const progressRef = useRef(progress)\n progressRef.current = progress\n\n // Compile: sample the hidden SVG, then reveal (entrance loop, controlled\n // progress, or a single full paint).\n useEffect(() => {\n const holder = holderRef.current\n const canvas = canvasRef.current\n const svg = holder?.querySelector(\"svg\")\n if (!holder || !canvas || !svg) return\n\n const fill = fillOf(color)\n const cells = Math.max(4, Math.round(size / cell))\n let raf = 0\n const cancelSample = sampleSvg(svg, cells, cells, (model) => {\n modelRef.current = model\n sizeIconCanvases(canvas, bloomRef.current, model)\n const controlled = progressRef.current\n if (controlled != null) {\n paintIcon(canvas, bloomRef.current, model, fill, clamp01(controlled))\n return\n }\n if (!animate || pixelPrefersReducedMotion()) {\n paintIcon(canvas, bloomRef.current, model, fill, 1)\n return\n }\n const start = performance.now()\n const tick = (now: number) => {\n // A controlled `progress` arriving mid-entrance takes over — stop the\n // loop instead of overpainting the host's frame.\n if (progressRef.current != null) return\n const t = clamp01((now - start) / animationDuration)\n paintIcon(canvas, bloomRef.current, model, fill, 1 - (1 - t) ** 3)\n if (t < 1) raf = requestAnimationFrame(tick)\n }\n raf = requestAnimationFrame(tick)\n })\n\n return () => {\n cancelSample()\n if (raf) cancelAnimationFrame(raf)\n }\n }, [Icon, color, size, cell, strokeWidth, bloom, animate, animationDuration, replayToken])\n\n // Controlled scrub: repaint on progress once the model is compiled.\n useEffect(() => {\n const canvas = canvasRef.current\n const model = modelRef.current\n if (progress == null || !canvas || !model) return\n paintIcon(canvas, bloomRef.current, model, fillOf(color), clamp01(progress))\n }, [progress, color])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n return (\n <span\n role={ariaLabel == null ? undefined : \"img\"}\n aria-label={ariaLabel ?? undefined}\n aria-hidden={ariaLabel == null || undefined}\n className={cn(\"relative inline-block align-middle\", className)}\n style={{ width: size, height: size }}\n >\n {/* Hidden source SVG — the thing we compile. */}\n <span\n ref={holderRef}\n aria-hidden\n className=\"pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0\"\n >\n <Icon strokeWidth={strokeWidth} />\n </span>\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=\"pointer-events-none absolute inset-0 h-full w-full\"\n style={bloomStyle}\n />\n )}\n </span>\n )\n}\n"
},
{
"path": "components/dither-kit/pixel.ts",
"type": "registry:component",
"target": "components/dither-kit/pixel.ts",
"content": "// Standalone pixel primitives for the non-chart Dither Kit pieces (avatar,\n// gradient). Deliberately free of the chart engine so those items install\n// without `core` — only palette.ts is shared. The Bayer matrix and bloom\n// presets mirror dither-paint.ts so everything reads as one texture.\n\nimport { type DitherColor, PALETTE, type Rgb } from \"./palette\"\n\n// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the same matrix\n// the charts dither with.\nexport const BAYER4 = [\n [0, 8, 2, 10],\n [12, 4, 14, 6],\n [3, 11, 1, 9],\n [15, 7, 13, 5],\n].map((row) => row.map((v) => (v + 0.5) / 16))\n\nexport const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t)\n\n/** 32-bit FNV-1a hash — turns any string seed into a stable uint32. */\nexport function fnv1a(str: string): number {\n let h = 0x811c9dc5\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i)\n h = Math.imul(h, 0x01000193)\n }\n return h >>> 0\n}\n\n/** Tiny deterministic PRNG (xorshift32) — returns floats in [0, 1). */\nexport function xorshift32(seed: number): () => number {\n let s = seed || 0x9e3779b9\n return () => {\n s ^= s << 13\n s >>>= 0\n s ^= s >>> 17\n s ^= s << 5\n s >>>= 0\n return s / 0x100000000\n }\n}\n\n/** A named palette colour or a raw hue (0–360). */\nexport type PixelColor = DitherColor | number\n\n/** Hue (0–360) → an rgb fill tuned to sit alongside the chart palette. */\nexport function hueFill(hue: number): Rgb {\n const h = ((hue % 360) + 360) % 360\n const s = 0.85\n const l = 0.58\n const c = (1 - Math.abs(2 * l - 1)) * s\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n const m = l - c / 2\n const [r, g, b] =\n h < 60\n ? [c, x, 0]\n : h < 120\n ? [x, c, 0]\n : h < 180\n ? [0, c, x]\n : h < 240\n ? [0, x, c]\n : h < 300\n ? [x, 0, c]\n : [c, 0, x]\n return [\n Math.round((r + m) * 255),\n Math.round((g + m) * 255),\n Math.round((b + m) * 255),\n ]\n}\n\n/** Resolve a {@link PixelColor} to its rgb fill. */\nexport function fillOf(color: PixelColor): Rgb {\n return typeof color === \"number\" ? hueFill(color) : PALETTE[color].fill\n}\n\n// Bloom — same recipe as the charts: a blurred copy of the crisp canvas,\n// composited additively so the glow stays in the dither's own colour.\nexport type PixelBloom = \"off\" | \"low\" | \"high\" | \"aura\"\n\nconst BLOOM_PRESET: Record<\n Exclude<PixelBloom, \"off\">,\n { blur: number; brightness: number; opacity: number; saturate: number }\n> = {\n low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 },\n high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 },\n aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 },\n}\n\nexport type PixelBloomStyle = {\n filter: string\n opacity: number\n mixBlendMode: \"plus-lighter\"\n imageRendering: \"auto\"\n}\n\n/** Style for the bloom layer canvas. null when off. */\nexport function pixelBloomStyle(bloom: PixelBloom): PixelBloomStyle | null {\n if (bloom === \"off\") return null\n const cfg = BLOOM_PRESET[bloom]\n return {\n filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate})`,\n opacity: cfg.opacity,\n mixBlendMode: \"plus-lighter\",\n imageRendering: \"auto\",\n }\n}\n\n/** Whether the OS asks for reduced motion (skip entrances). */\nexport function pixelPrefersReducedMotion(): boolean {\n return (\n window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches ?? false\n )\n}\n"
},
{
"path": "components/dither-kit/palette.ts",
"type": "registry:component",
"target": "components/dither-kit/palette.ts",
"content": "// Shared seed palette for the dither chart family. Mirrors the seeds in\n// `dither-chart.tsx` so a series rendered through the composable engine reads\n// with the exact same fill / line / star hues as the legacy sparkline.\n\nexport type Rgb = [number, number, number]\n\nexport type DitherColor =\n | \"green\"\n | \"blue\"\n | \"purple\"\n | \"pink\"\n | \"orange\"\n | \"red\"\n | \"grey\"\n\nexport type Seed = { fill: Rgb; line: Rgb; star: Rgb }\n\n// Each seed: the area-fill hue, the bright series line, and the star sparkle.\nexport const PALETTE: Record<DitherColor, Seed> = {\n green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] },\n blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] },\n purple: {\n fill: [150, 110, 255],\n line: [200, 175, 255],\n star: [225, 210, 255],\n },\n pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] },\n orange: {\n fill: [255, 150, 50],\n line: [255, 195, 130],\n star: [255, 220, 175],\n },\n red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] },\n // No-data: a muted grey so empty metrics read as \"nothing here\".\n grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] },\n}\n\nexport const rgb = ([r, g, b]: Rgb, k = 1, a = 1) =>\n `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})`\n\nexport const seedOfColor = (color: DitherColor): Seed => PALETTE[color]\n\nexport const isDitherColor = (value: unknown): value is DitherColor =>\n typeof value === \"string\" && value in PALETTE\n"
},
{
"path": "components/dither-kit/lib.ts",
"type": "registry:component",
"target": "components/dither-kit/lib.ts",
"content": "import { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\n/** Tailwind-aware className combiner — local copy so the chart pack is\n * self-contained and portable as a registry. */\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n"
}
]
}
Loading