| 1 | "use client" |
| 2 | |
| 3 | import { useEffect, useRef } from "react" |
| 4 | import { cn } |
| 5 | import { rgb } |
| 6 | import { |
| 7 | BAYER4, |
| 8 | clamp01, |
| 9 | fnv1a, |
| 10 | hueFill, |
| 11 | type PixelBloom, |
| 12 | pixelBloomStyle, |
| 13 | pixelPrefersReducedMotion, |
| 14 | xorshift32, |
| 15 | } from "./pixel" |
| 16 | |
| 17 | // 8×8 cells, mirrored across one axis → 32 free pattern bits. With the mirror |
| 18 | // axis bit and 180 hues that's 2^33 × 180 ≈ 1.5 trillion distinct avatars. |
| 19 | const GRID = 8 |
| 20 | const CELL_PX = 4 |
| 21 | |
| 22 | export type AvatarMirror |
| 23 | |
| 24 | export type DitherAvatarProps |
| 25 | /** The seed - same name, same avatar, every time. */ |
| 26 | name: string |
| 27 | /** Hue override (0–360). Derived from the name when omitted. */ |
| 28 | hue?: number |
| 29 | /** Mirror axis. "auto" picks one from the name - half the avatars fold |
| 30 | * left/right, half fold top/bottom. */ |
| 31 | mirror?: AvatarMirror |
| 32 | /** Square size in px. Omit to size via className (e.g. `size-12`). */ |
| 33 | size?: number |
| 34 | /** Glow on the dither fill. */ |
| 35 | bloom?: PixelBloom |
| 36 | /** Play the Bayer-ordered materialize entrance. */ |
| 37 | animate?: boolean |
| 38 | animationDuration?: number |
| 39 | /** Bump to replay the entrance. */ |
| 40 | replayToken?: number |
| 41 | className?: string |
| 42 | } |
| 43 | |
| 44 | type AvatarModel |
| 45 | on: boolean |
| 46 | density: number |
| 47 | fill: [ |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Derive the full 8×8 cell grid from the name: 32 pattern bits + the mirror |
| 52 | * axis + the hue + per-cell densities, all from one deterministic PRNG stream. |
| 53 | * Every draw happens unconditionally so overriding `hue` or `mirror` never |
| 54 | * shifts the pattern. |
| 55 | */ |
| 56 | function avatarModel( |
| 57 | name: string, |
| 58 | hueProp: number |
| 59 | mirrorProp: AvatarMirror |
| 60 | ): AvatarModel { |
| 61 | const rand = |
| 62 | const bits = |
| 63 | const drawnVertical = |
| 64 | const drawnHue = |
| 65 | const halfDensity = |
| 66 | |
| 67 | const vertical = |
| 68 | mirrorProp === "auto" |
| 69 | const hue = |
| 70 | |
| 71 | const on = |
| 72 | const density = |
| 73 | for (let r |
| 74 | for (let c |
| 75 | // Fold across the chosen axis: left/right symmetric ("horizontal" |
| 76 | // mirror) or top/bottom symmetric ("vertical"). |
| 77 | const i = |
| 78 | ? Math.min( |
| 79 | : r * ( |
| 80 | on[r * |
| 81 | density[r * |
| 82 | } |
| 83 | } |
| 84 | return { on, |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Paint the avatar, optionally sweeping cells in with the Bayer-ordered |
| 89 | * materialize entrance. Lives outside the component (same shape as the chart |
| 90 | * canvases). Returns a cleanup that cancels the entrance loop. |
| 91 | */ |
| 92 | function paintAvatar( |
| 93 | canvas: HTMLCanvasElement |
| 94 | bloomCanvas: HTMLCanvasElement |
| 95 | model: AvatarModel |
| 96 | animate: boolean, |
| 97 | duration: number |
| 98 | ): (() => |
| 99 | const ctx = |
| 100 | if (!ctx) |
| 101 | const px = |
| 102 | canvas.width = |
| 103 | canvas.height = |
| 104 | const bloomCtx = |
| 105 | if (bloomCanvas) { |
| 106 | bloomCanvas.width = |
| 107 | bloomCanvas.height = |
| 108 | } |
| 109 | |
| 110 | const draw = |
| 111 | ctx.clearRect(0, |
| 112 | for (let r |
| 113 | for (let c |
| 114 | if (!model.on |
| 115 | // Cells materialize in Bayer order - the entrance is made of the same |
| 116 | // matrix as the texture. |
| 117 | const start = |
| 118 | const cellAlpha = |
| 119 | if (cellAlpha <= |
| 120 | const density = |
| 121 | const base = |
| 122 | for (let py |
| 123 | for (let pxi |
| 124 | const gx = |
| 125 | const gy = |
| 126 | const lit = |
| 127 | // On/off cells modulate alpha tiers of the one fill colour, so the |
| 128 | // avatar holds up on light and dark backgrounds alike. |
| 129 | const alpha = |
| 130 | ctx.fillStyle = |
| 131 | ctx.fillRect(gx |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | if (bloomCtx) { |
| 137 | bloomCtx.clearRect(0, |
| 138 | bloomCtx.drawImage(canvas |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | if (!animate || |
| 143 | draw(1) |
| 144 | return undefined |
| 145 | } |
| 146 | |
| 147 | let raf = |
| 148 | const startTime = |
| 149 | const tick = |
| 150 | const t = |
| 151 | draw(1 - ( |
| 152 | if (t < |
| 153 | } |
| 154 | raf = requestAnimationFrame( |
| 155 | return () => |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Generative dithered avatar - a mirrored 8×8 pixel glyph derived from a name, |
| 160 | * rendered with the ordered-dither texture the charts are made of. Same name, |
| 161 | * same avatar; ~1.5 trillion combinations across pattern, mirror axis, and hue. |
| 162 | */ |
| 163 | export function DitherAvatar({ |
| 164 | name, |
| 165 | hue, |
| 166 | mirror = "auto" |
| 167 | size, |
| 168 | bloom = "off" |
| 169 | animate = true, |
| 170 | animationDuration = 600, |
| 171 | replayToken = 0, |
| 172 | className, |
| 173 | }: DitherAvatarProps) |
| 174 | const canvasRef = |
| 175 | const bloomRef = |
| 176 | |
| 177 | useEffect(() => |
| 178 | const canvas = |
| 179 | if (!canvas) |
| 180 | return paintAvatar( |
| 181 | canvas, |
| 182 | bloomRef.current, |
| 183 | avatarModel(name, hue |
| 184 | animate, |
| 185 | animationDuration |
| 186 | ) |
| 187 | }, [name, |
| 188 | |
| 189 | const bloomStyle = |
| 190 | |
| 191 | return ( |
| 192 | <div |
| 193 | role="img" |
| 194 | aria-label={`${name |
| 195 | className={cn(" |
| 196 | style={size != |
| 197 | > |
| 198 | <canvas |
| 199 | ref={canvasRef} |
| 200 | className="absolute inset-0 h-full w-full" |
| 201 | style={{ imageRendering: |
| 202 | /> |
| 203 | {bloomStyle && ( |
| 204 | <canvas |
| 205 | ref={bloomRef} |
| 206 | className="pointer-events-none absolute inset-0 h-full w-full" |
| 207 | style={bloomStyle} |
| 208 | /> |
| 209 | )} |
| 210 | </div> |
| 211 | ) |
| 212 | } |