[READ-ONLY] Mirror of https://github.com/flo-bit/particle-surfer. flo-bit.dev/particle-surfer/
0

Configure Feed

Select the types of activity you want to include in your feed.

particle-surfer / src / colors.ts
6.1 kB 214 lines
1// colorGrid.ts 2 3export type RGB01 = { r: number; g: number; b: number }; // each in [0,1] 4type HexColor = string; 5 6export interface ColorGridResult { 7 grid: RGB01[][]; // [row][col], y then x 8 getColor: (xSpeed: number, ySpeed: number, out: RGB01) => void; 9} 10 11/** Parse hex like "#abc" or "#aabbcc" into RGB01 (0..1) */ 12function hexToRgb01(hex: string): RGB01 { 13 hex = hex.replace(/^#/, ""); 14 if (hex.length === 3) { 15 hex = hex 16 .split("") 17 .map((c) => c + c) 18 .join(""); 19 } 20 const int = parseInt(hex, 16); 21 const r = ((int >> 16) & 0xff) / 255; 22 const g = ((int >> 8) & 0xff) / 255; 23 const b = (int & 0xff) / 255; 24 return { r, g, b }; 25} 26 27/** Convert RGB01 to hex string "#rrggbb" */ 28export function rgb01ToHex({ r, g, b }: RGB01): HexColor { 29 const clamp8 = (v: number) => 30 Math.max(0, Math.min(255, Math.round(v * 255))) // convert to 0-255 31 .toString(16) 32 .padStart(2, "0"); 33 return `#${clamp8(r)}${clamp8(g)}${clamp8(b)}`; 34} 35 36/** Linear interpolation */ 37function lerp(a: number, b: number, t: number): number { 38 return a + (b - a) * t; 39} 40 41/** Lerp between two RGB01 colors */ 42function lerpColor(c1: RGB01, c2: RGB01, t: number): RGB01 { 43 return { 44 r: lerp(c1.r, c2.r, t), 45 g: lerp(c1.g, c2.g, t), 46 b: lerp(c1.b, c2.b, t), 47 }; 48} 49 50/** 51 * Sample a multi-stop gradient. stops are hex colors, t in [0,1]. 52 * Returns RGB01. 53 */ 54function sampleGradient(stops: HexColor[], t: number): RGB01 { 55 if (t <= 0) return hexToRgb01(stops[0]); 56 if (t >= 1) return hexToRgb01(stops[stops.length - 1]); 57 const scaled = t * (stops.length - 1); 58 const idx = Math.floor(scaled); 59 const frac = scaled - idx; 60 const c1 = hexToRgb01(stops[idx]); 61 const c2 = hexToRgb01(stops[idx + 1]); 62 return lerpColor(c1, c2, frac); 63} 64 65/** Simple average blend of two RGB01 colors */ 66function blendRGB(a: RGB01, b: RGB01): RGB01 { 67 return { 68 r: (a.r + b.r) * 0.5, 69 g: (a.g + b.g) * 0.5, 70 b: (a.b + b.b) * 0.5, 71 }; 72} 73 74/** 75 * Build the precomputed color grid and sampler. 76 * xSpeed and ySpeed are expected in [-1,1]. 77 */ 78export function createColorGrid(params: { 79 gradientXStops: HexColor[]; // for x speed (-1 → 1) 80 gradientYStops: HexColor[]; // for y speed (-1 → 1) 81 detailX?: number; // columns 82 detailY?: number; // rows 83}): ColorGridResult { 84 const detailX = params.detailX ?? 20; 85 const detailY = params.detailY ?? 20; 86 87 const gradX: RGB01[] = new Array(detailX); 88 const gradY: RGB01[] = new Array(detailY); 89 for (let i = 0; i < detailX; i++) { 90 const tx = detailX === 1 ? 0.5 : i / (detailX - 1); 91 gradX[i] = sampleGradient(params.gradientXStops, tx); 92 } 93 for (let j = 0; j < detailY; j++) { 94 const ty = detailY === 1 ? 0.5 : j / (detailY - 1); 95 gradY[j] = sampleGradient(params.gradientYStops, ty); 96 } 97 98 // build blended grid [row][col] (y then x) 99 const grid: RGB01[][] = new Array(detailY); 100 for (let row = 0; row < detailY; row++) { 101 grid[row] = new Array(detailX); 102 for (let col = 0; col < detailX; col++) { 103 grid[row][col] = blendRGB(gradX[col], gradY[row]); 104 } 105 } 106 107 const temp1 = { r: 0, g: 0, b: 0 }; 108 const temp2 = { r: 0, g: 0, b: 0 }; 109 110 /** 111 * Writes interpolated color into `out` (mutated), each channel in [0,1]. 112 */ 113 function getColor(xSpeed: number, ySpeed: number, out: RGB01): void { 114 const clamp = (v: number) => Math.max(-1, Math.min(1, v)); 115 xSpeed = clamp(xSpeed); 116 ySpeed = clamp(ySpeed); 117 118 const fx = ((xSpeed + 1) / 2) * (detailX - 1); 119 const fy = ((ySpeed + 1) / 2) * (detailY - 1); 120 121 const x0 = Math.floor(fx); 122 const x1 = Math.min(detailX - 1, x0 + 1); 123 const y0 = Math.floor(fy); 124 const y1 = Math.min(detailY - 1, y0 + 1); 125 const wx = fx - x0; 126 const wy = fy - y0; 127 128 const c00 = grid[y0][x0]; 129 const c10 = grid[y0][x1]; 130 const c01 = grid[y1][x0]; 131 const c11 = grid[y1][x1]; 132 133 // bilinear interpolation 134 const mix = (a: RGB01, b: RGB01, t: number, out?: RGB01): RGB01 => { 135 if (out) { 136 out.r = lerp(a.r, b.r, t); 137 out.g = lerp(a.g, b.g, t); 138 out.b = lerp(a.b, b.b, t); 139 return out; 140 } 141 return { 142 r: lerp(a.r, b.r, t), 143 g: lerp(a.g, b.g, t), 144 b: lerp(a.b, b.b, t), 145 }; 146 }; 147 148 mix(c00, c10, wx, temp1); 149 mix(c01, c11, wx, temp2); 150 mix(temp1, temp2, wy, out); 151 } 152 153 return { grid, getColor }; 154} 155 156/** 157 * Render a RGB01 grid to an offscreen canvas and trigger PNG download. 158 */ 159export function saveGridAsPNG( 160 grid: RGB01[][], 161 cellSize: number = 20, 162 filename: string = "color-grid.png", 163): void { 164 const rows = grid.length; 165 const cols = grid[0]?.length ?? 0; 166 const canvas = document.createElement("canvas"); 167 canvas.width = cols * cellSize; 168 canvas.height = rows * cellSize; 169 const ctx = canvas.getContext("2d"); 170 if (!ctx) { 171 console.error("2D context unavailable"); 172 return; 173 } 174 175 for (let y = 0; y < rows; y++) { 176 for (let x = 0; x < cols; x++) { 177 const { r, g, b } = grid[y][x]; 178 // convert to 0-255 179 const ir = Math.round(Math.max(0, Math.min(1, r)) * 255); 180 const ig = Math.round(Math.max(0, Math.min(1, g)) * 255); 181 const ib = Math.round(Math.max(0, Math.min(1, b)) * 255); 182 ctx.fillStyle = `rgb(${ir},${ig},${ib})`; 183 ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); 184 } 185 } 186 187 // optional subtle grid lines 188 ctx.strokeStyle = "rgba(0,0,0,0.1)"; 189 for (let x = 0; x <= cols; x++) { 190 ctx.beginPath(); 191 ctx.moveTo(x * cellSize + 0.5, 0); 192 ctx.lineTo(x * cellSize + 0.5, rows * cellSize); 193 ctx.stroke(); 194 } 195 for (let y = 0; y <= rows; y++) { 196 ctx.beginPath(); 197 ctx.moveTo(0, y * cellSize + 0.5); 198 ctx.lineTo(cols * cellSize, y * cellSize + 0.5); 199 ctx.stroke(); 200 } 201 202 canvas.toBlob((blob) => { 203 if (!blob) { 204 console.error("Failed to make PNG blob"); 205 return; 206 } 207 const url = URL.createObjectURL(blob); 208 const a = document.createElement("a"); 209 a.href = url; 210 a.download = filename; 211 a.click(); 212 setTimeout(() => URL.revokeObjectURL(url), 5000); 213 }, "image/png"); 214}