[READ-ONLY] Mirror of https://github.com/flo-bit/particle-surfer.
flo-bit.dev/particle-surfer/
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}