git-hash-art 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.kiro/steering/product.md +16 -0
- package/.kiro/steering/structure.md +40 -0
- package/.kiro/steering/tech.md +24 -0
- package/ALGORITHM.md +198 -0
- package/CHANGELOG.md +19 -0
- package/README.md +165 -57
- package/dist/browser.js +1430 -0
- package/dist/browser.js.map +1 -0
- package/dist/main.js +672 -333
- package/dist/main.js.map +1 -1
- package/dist/module.js +665 -329
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +2 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +47 -3
- package/src/__tests__/compatibility.test.ts +42 -0
- package/src/__tests__/generation.test.ts +58 -0
- package/src/__tests__/render.test.ts +81 -0
- package/src/__tests__/shapes.test.ts +71 -0
- package/src/browser.ts +100 -0
- package/src/index.ts +38 -199
- package/src/lib/canvas/colors.ts +90 -66
- package/src/lib/canvas/draw.ts +38 -7
- package/src/lib/canvas/shapes/basic.ts +1 -1
- package/src/lib/canvas/shapes/complex.ts +2 -1
- package/src/lib/render.ts +483 -0
- package/src/lib/utils.ts +33 -6
- package/src/types.ts +35 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rendering logic — environment-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Uses only the standard CanvasRenderingContext2D API so it works
|
|
5
|
+
* identically in Node (@napi-rs/canvas) and browsers.
|
|
6
|
+
*
|
|
7
|
+
* Generation pipeline:
|
|
8
|
+
* 1. Background — radial gradient from hash-derived dark palette
|
|
9
|
+
* 2. Composition mode — hash selects: radial, flow-field, spiral, grid-subdivision, or clustered
|
|
10
|
+
* 3. Color field — smooth positional color blending across the canvas
|
|
11
|
+
* 4. Shape layers — weighted selection, focal-point placement, transparency, glow, gradients, jitter
|
|
12
|
+
* 5. Recursive nesting — some shapes contain smaller shapes inside
|
|
13
|
+
* 6. Flow-line pass — bezier curves following a hash-derived vector field
|
|
14
|
+
* 7. Noise texture overlay — subtle grain for organic feel
|
|
15
|
+
* 8. Organic connecting curves — beziers between nearby shapes
|
|
16
|
+
*/
|
|
17
|
+
import { SacredColorScheme, hexWithAlpha, jitterColor } from "./canvas/colors";
|
|
18
|
+
import { enhanceShapeGeneration } from "./canvas/draw";
|
|
19
|
+
import { shapes } from "./canvas/shapes";
|
|
20
|
+
import { createRng, seedFromHash } from "./utils";
|
|
21
|
+
import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
|
|
22
|
+
|
|
23
|
+
// ── Shape categories for weighted selection ─────────────────────────
|
|
24
|
+
|
|
25
|
+
const BASIC_SHAPES = [
|
|
26
|
+
"circle",
|
|
27
|
+
"square",
|
|
28
|
+
"triangle",
|
|
29
|
+
"hexagon",
|
|
30
|
+
"diamond",
|
|
31
|
+
"cube",
|
|
32
|
+
];
|
|
33
|
+
const COMPLEX_SHAPES = [
|
|
34
|
+
"star",
|
|
35
|
+
"jacked-star",
|
|
36
|
+
"heart",
|
|
37
|
+
"platonicSolid",
|
|
38
|
+
"fibonacciSpiral",
|
|
39
|
+
"islamicPattern",
|
|
40
|
+
"celticKnot",
|
|
41
|
+
"merkaba",
|
|
42
|
+
"fractal",
|
|
43
|
+
];
|
|
44
|
+
const SACRED_SHAPES = [
|
|
45
|
+
"mandala",
|
|
46
|
+
"flowerOfLife",
|
|
47
|
+
"treeOfLife",
|
|
48
|
+
"metatronsCube",
|
|
49
|
+
"sriYantra",
|
|
50
|
+
"seedOfLife",
|
|
51
|
+
"vesicaPiscis",
|
|
52
|
+
"torus",
|
|
53
|
+
"eggOfLife",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// ── Composition modes ───────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
type CompositionMode =
|
|
59
|
+
| "radial"
|
|
60
|
+
| "flow-field"
|
|
61
|
+
| "spiral"
|
|
62
|
+
| "grid-subdivision"
|
|
63
|
+
| "clustered";
|
|
64
|
+
|
|
65
|
+
const COMPOSITION_MODES: CompositionMode[] = [
|
|
66
|
+
"radial",
|
|
67
|
+
"flow-field",
|
|
68
|
+
"spiral",
|
|
69
|
+
"grid-subdivision",
|
|
70
|
+
"clustered",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// ── Helper: pick shape with layer-aware weighting ───────────────────
|
|
74
|
+
|
|
75
|
+
function pickShape(
|
|
76
|
+
rng: () => number,
|
|
77
|
+
layerRatio: number,
|
|
78
|
+
shapeNames: string[],
|
|
79
|
+
): string {
|
|
80
|
+
const basicW = 1 - layerRatio * 0.6;
|
|
81
|
+
const complexW = 0.3 + layerRatio * 0.3;
|
|
82
|
+
const sacredW = 0.1 + layerRatio * 0.4;
|
|
83
|
+
const total = basicW + complexW + sacredW;
|
|
84
|
+
const roll = rng() * total;
|
|
85
|
+
|
|
86
|
+
let pool: string[];
|
|
87
|
+
if (roll < basicW) pool = BASIC_SHAPES;
|
|
88
|
+
else if (roll < basicW + complexW) pool = COMPLEX_SHAPES;
|
|
89
|
+
else pool = SACRED_SHAPES;
|
|
90
|
+
|
|
91
|
+
const available = pool.filter((s) => shapeNames.includes(s));
|
|
92
|
+
if (available.length === 0) {
|
|
93
|
+
return shapeNames[Math.floor(rng() * shapeNames.length)];
|
|
94
|
+
}
|
|
95
|
+
return available[Math.floor(rng() * available.length)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Helper: simple 2D value noise (hash-seeded) ─────────────────────
|
|
99
|
+
|
|
100
|
+
function valueNoise(
|
|
101
|
+
x: number,
|
|
102
|
+
y: number,
|
|
103
|
+
scale: number,
|
|
104
|
+
rng: () => number,
|
|
105
|
+
): number {
|
|
106
|
+
// Cheap pseudo-noise: combine sin waves at different frequencies
|
|
107
|
+
const nx = x / scale;
|
|
108
|
+
const ny = y / scale;
|
|
109
|
+
return (
|
|
110
|
+
(Math.sin(nx * 1.7 + ny * 2.3 + rng() * 0.001) * 0.5 +
|
|
111
|
+
Math.sin(nx * 3.1 - ny * 1.9 + rng() * 0.001) * 0.3 +
|
|
112
|
+
Math.sin(nx * 5.3 + ny * 4.7 + rng() * 0.001) * 0.2) *
|
|
113
|
+
0.5 +
|
|
114
|
+
0.5
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Helper: get position based on composition mode ──────────────────
|
|
119
|
+
|
|
120
|
+
function getCompositionPosition(
|
|
121
|
+
mode: CompositionMode,
|
|
122
|
+
rng: () => number,
|
|
123
|
+
width: number,
|
|
124
|
+
height: number,
|
|
125
|
+
shapeIndex: number,
|
|
126
|
+
totalShapes: number,
|
|
127
|
+
cx: number,
|
|
128
|
+
cy: number,
|
|
129
|
+
): { x: number; y: number } {
|
|
130
|
+
switch (mode) {
|
|
131
|
+
case "radial": {
|
|
132
|
+
const angle = rng() * Math.PI * 2;
|
|
133
|
+
const maxR = Math.min(width, height) * 0.45;
|
|
134
|
+
const r = Math.pow(rng(), 0.7) * maxR;
|
|
135
|
+
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
|
|
136
|
+
}
|
|
137
|
+
case "spiral": {
|
|
138
|
+
const t = shapeIndex / totalShapes;
|
|
139
|
+
const turns = 3 + rng() * 2;
|
|
140
|
+
const angle = t * Math.PI * 2 * turns;
|
|
141
|
+
const maxR = Math.min(width, height) * 0.42;
|
|
142
|
+
const r = t * maxR + (rng() - 0.5) * maxR * 0.15;
|
|
143
|
+
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
|
|
144
|
+
}
|
|
145
|
+
case "grid-subdivision": {
|
|
146
|
+
const cells = 3 + Math.floor(rng() * 3);
|
|
147
|
+
const cellW = width / cells;
|
|
148
|
+
const cellH = height / cells;
|
|
149
|
+
const gx = Math.floor(rng() * cells);
|
|
150
|
+
const gy = Math.floor(rng() * cells);
|
|
151
|
+
return {
|
|
152
|
+
x: gx * cellW + rng() * cellW,
|
|
153
|
+
y: gy * cellH + rng() * cellH,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
case "clustered": {
|
|
157
|
+
// Pick one of 3-5 cluster centers, then scatter around it
|
|
158
|
+
const numClusters = 3 + Math.floor(rng() * 3);
|
|
159
|
+
const ci = Math.floor(rng() * numClusters);
|
|
160
|
+
// Deterministic cluster center from index
|
|
161
|
+
const clusterRng = createRng(seedFromHash(String(ci), 999));
|
|
162
|
+
const clx = width * (0.15 + clusterRng() * 0.7);
|
|
163
|
+
const cly = height * (0.15 + clusterRng() * 0.7);
|
|
164
|
+
const spread = Math.min(width, height) * 0.18;
|
|
165
|
+
return {
|
|
166
|
+
x: clx + (rng() - 0.5) * spread * 2,
|
|
167
|
+
y: cly + (rng() - 0.5) * spread * 2,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case "flow-field":
|
|
171
|
+
default: {
|
|
172
|
+
// Random position, will be adjusted by flow field direction later
|
|
173
|
+
return { x: rng() * width, y: rng() * height };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Helper: positional color blending ───────────────────────────────
|
|
179
|
+
|
|
180
|
+
function getPositionalColor(
|
|
181
|
+
x: number,
|
|
182
|
+
y: number,
|
|
183
|
+
width: number,
|
|
184
|
+
height: number,
|
|
185
|
+
colors: string[],
|
|
186
|
+
rng: () => number,
|
|
187
|
+
): string {
|
|
188
|
+
// Blend between palette colors based on position
|
|
189
|
+
const nx = x / width;
|
|
190
|
+
const ny = y / height;
|
|
191
|
+
// Use position to bias which palette color is chosen
|
|
192
|
+
const posIndex = (nx * 0.6 + ny * 0.4) * (colors.length - 1);
|
|
193
|
+
const baseIdx = Math.floor(posIndex) % colors.length;
|
|
194
|
+
// Then jitter it slightly
|
|
195
|
+
return jitterColor(colors[baseIdx], rng, 0.08);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Main render function ────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export function renderHashArt(
|
|
201
|
+
ctx: CanvasRenderingContext2D,
|
|
202
|
+
gitHash: string,
|
|
203
|
+
config: Partial<GenerationConfig> = {},
|
|
204
|
+
): void {
|
|
205
|
+
const finalConfig: GenerationConfig = { ...DEFAULT_CONFIG, ...config };
|
|
206
|
+
const {
|
|
207
|
+
width,
|
|
208
|
+
height,
|
|
209
|
+
gridSize,
|
|
210
|
+
layers,
|
|
211
|
+
minShapeSize,
|
|
212
|
+
maxShapeSize,
|
|
213
|
+
baseOpacity,
|
|
214
|
+
opacityReduction,
|
|
215
|
+
} = finalConfig;
|
|
216
|
+
|
|
217
|
+
finalConfig.shapesPerLayer =
|
|
218
|
+
finalConfig.shapesPerLayer || Math.floor(gridSize * gridSize * 1.5);
|
|
219
|
+
|
|
220
|
+
const colorScheme = new SacredColorScheme(gitHash);
|
|
221
|
+
const colors = colorScheme.getColors();
|
|
222
|
+
const [bgStart, bgEnd] = colorScheme.getBackgroundColors();
|
|
223
|
+
|
|
224
|
+
const shapeNames = Object.keys(shapes);
|
|
225
|
+
const scaleFactor = Math.min(width, height) / 1024;
|
|
226
|
+
const adjustedMinSize = minShapeSize * scaleFactor;
|
|
227
|
+
const adjustedMaxSize = maxShapeSize * scaleFactor;
|
|
228
|
+
|
|
229
|
+
const rng = createRng(seedFromHash(gitHash));
|
|
230
|
+
const cx = width / 2;
|
|
231
|
+
const cy = height / 2;
|
|
232
|
+
|
|
233
|
+
// ── 1. Background ──────────────────────────────────────────────
|
|
234
|
+
const bgRadius = Math.hypot(cx, cy);
|
|
235
|
+
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, bgRadius);
|
|
236
|
+
bgGrad.addColorStop(0, bgStart);
|
|
237
|
+
bgGrad.addColorStop(1, bgEnd);
|
|
238
|
+
ctx.fillStyle = bgGrad;
|
|
239
|
+
ctx.fillRect(0, 0, width, height);
|
|
240
|
+
|
|
241
|
+
// ── 2. Composition mode ────────────────────────────────────────
|
|
242
|
+
const compositionMode =
|
|
243
|
+
COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
|
|
244
|
+
|
|
245
|
+
// ── 3. Focal points ────────────────────────────────────────────
|
|
246
|
+
const numFocal = 1 + Math.floor(rng() * 2);
|
|
247
|
+
const focalPoints: Array<{ x: number; y: number; strength: number }> = [];
|
|
248
|
+
for (let f = 0; f < numFocal; f++) {
|
|
249
|
+
focalPoints.push({
|
|
250
|
+
x: width * (0.2 + rng() * 0.6),
|
|
251
|
+
y: height * (0.2 + rng() * 0.6),
|
|
252
|
+
strength: 0.3 + rng() * 0.4,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function applyFocalBias(rx: number, ry: number): [number, number] {
|
|
257
|
+
let nearest = focalPoints[0];
|
|
258
|
+
let minDist = Infinity;
|
|
259
|
+
for (const fp of focalPoints) {
|
|
260
|
+
const d = Math.hypot(rx - fp.x, ry - fp.y);
|
|
261
|
+
if (d < minDist) {
|
|
262
|
+
minDist = d;
|
|
263
|
+
nearest = fp;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const pull = nearest.strength * rng() * 0.5;
|
|
267
|
+
return [rx + (nearest.x - rx) * pull, ry + (nearest.y - ry) * pull];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── 4. Flow field seed values (for flow-field mode & line pass) ─
|
|
271
|
+
const fieldAngleBase = rng() * Math.PI * 2;
|
|
272
|
+
const fieldFreq = 0.5 + rng() * 2;
|
|
273
|
+
|
|
274
|
+
function flowAngle(x: number, y: number): number {
|
|
275
|
+
return (
|
|
276
|
+
fieldAngleBase +
|
|
277
|
+
Math.sin((x / width) * fieldFreq * Math.PI * 2) * Math.PI * 0.5 +
|
|
278
|
+
Math.cos((y / height) * fieldFreq * Math.PI * 2) * Math.PI * 0.5
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── 5. Shape layers ────────────────────────────────────────────
|
|
283
|
+
const shapePositions: Array<{ x: number; y: number; size: number }> = [];
|
|
284
|
+
|
|
285
|
+
for (let layer = 0; layer < layers; layer++) {
|
|
286
|
+
const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
|
|
287
|
+
const numShapes =
|
|
288
|
+
finalConfig.shapesPerLayer +
|
|
289
|
+
Math.floor(rng() * finalConfig.shapesPerLayer * 0.3);
|
|
290
|
+
const layerOpacity = Math.max(0.15, baseOpacity - layer * opacityReduction);
|
|
291
|
+
const layerSizeScale = 1 - layer * 0.15;
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < numShapes; i++) {
|
|
294
|
+
// Position from composition mode, then focal bias
|
|
295
|
+
const rawPos = getCompositionPosition(
|
|
296
|
+
compositionMode,
|
|
297
|
+
rng,
|
|
298
|
+
width,
|
|
299
|
+
height,
|
|
300
|
+
i,
|
|
301
|
+
numShapes,
|
|
302
|
+
cx,
|
|
303
|
+
cy,
|
|
304
|
+
);
|
|
305
|
+
const [x, y] = applyFocalBias(rawPos.x, rawPos.y);
|
|
306
|
+
|
|
307
|
+
// Weighted shape selection
|
|
308
|
+
const shape = pickShape(rng, layerRatio, shapeNames);
|
|
309
|
+
|
|
310
|
+
// Power distribution for size
|
|
311
|
+
const sizeT = Math.pow(rng(), 1.8);
|
|
312
|
+
const size =
|
|
313
|
+
(adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
|
|
314
|
+
layerSizeScale;
|
|
315
|
+
|
|
316
|
+
// Flow-field rotation in flow-field mode, random otherwise
|
|
317
|
+
const rotation =
|
|
318
|
+
compositionMode === "flow-field"
|
|
319
|
+
? (flowAngle(x, y) * 180) / Math.PI + (rng() - 0.5) * 30
|
|
320
|
+
: rng() * 360;
|
|
321
|
+
|
|
322
|
+
// Positional color blending + jitter
|
|
323
|
+
const fillBase = getPositionalColor(x, y, width, height, colors, rng);
|
|
324
|
+
const strokeBase = colors[Math.floor(rng() * colors.length)];
|
|
325
|
+
const fillColor = jitterColor(fillBase, rng, 0.06);
|
|
326
|
+
const strokeColor = jitterColor(strokeBase, rng, 0.05);
|
|
327
|
+
|
|
328
|
+
// Semi-transparent fill
|
|
329
|
+
const fillAlpha = 0.2 + rng() * 0.5;
|
|
330
|
+
const transparentFill = hexWithAlpha(fillColor, fillAlpha);
|
|
331
|
+
|
|
332
|
+
const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor;
|
|
333
|
+
|
|
334
|
+
ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5);
|
|
335
|
+
|
|
336
|
+
// Glow on sacred shapes more often
|
|
337
|
+
const isSacred = SACRED_SHAPES.includes(shape);
|
|
338
|
+
const glowChance = isSacred ? 0.45 : 0.2;
|
|
339
|
+
const hasGlow = rng() < glowChance;
|
|
340
|
+
const glowRadius = hasGlow ? (8 + rng() * 20) * scaleFactor : 0;
|
|
341
|
+
|
|
342
|
+
// Gradient fill on ~30%
|
|
343
|
+
const hasGradient = rng() < 0.3;
|
|
344
|
+
const gradientEnd = hasGradient
|
|
345
|
+
? jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1)
|
|
346
|
+
: undefined;
|
|
347
|
+
|
|
348
|
+
enhanceShapeGeneration(ctx, shape, x, y, {
|
|
349
|
+
fillColor: transparentFill,
|
|
350
|
+
strokeColor,
|
|
351
|
+
strokeWidth,
|
|
352
|
+
size,
|
|
353
|
+
rotation,
|
|
354
|
+
proportionType: "GOLDEN_RATIO",
|
|
355
|
+
glowRadius,
|
|
356
|
+
glowColor: hasGlow ? hexWithAlpha(fillColor, 0.6) : undefined,
|
|
357
|
+
gradientFillEnd: gradientEnd,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
shapePositions.push({ x, y, size });
|
|
361
|
+
|
|
362
|
+
// ── 5b. Recursive nesting: ~15% of larger shapes get inner shapes ──
|
|
363
|
+
if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
|
|
364
|
+
const innerCount = 1 + Math.floor(rng() * 3);
|
|
365
|
+
for (let n = 0; n < innerCount; n++) {
|
|
366
|
+
const innerShape = pickShape(
|
|
367
|
+
rng,
|
|
368
|
+
Math.min(1, layerRatio + 0.3),
|
|
369
|
+
shapeNames,
|
|
370
|
+
);
|
|
371
|
+
const innerSize = size * (0.15 + rng() * 0.25);
|
|
372
|
+
const innerOffX = (rng() - 0.5) * size * 0.4;
|
|
373
|
+
const innerOffY = (rng() - 0.5) * size * 0.4;
|
|
374
|
+
const innerRot = rng() * 360;
|
|
375
|
+
const innerFill = hexWithAlpha(
|
|
376
|
+
jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1),
|
|
377
|
+
0.3 + rng() * 0.4,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
ctx.globalAlpha = layerOpacity * 0.7;
|
|
381
|
+
enhanceShapeGeneration(
|
|
382
|
+
ctx,
|
|
383
|
+
innerShape,
|
|
384
|
+
x + innerOffX,
|
|
385
|
+
y + innerOffY,
|
|
386
|
+
{
|
|
387
|
+
fillColor: innerFill,
|
|
388
|
+
strokeColor: hexWithAlpha(strokeColor, 0.5),
|
|
389
|
+
strokeWidth: strokeWidth * 0.6,
|
|
390
|
+
size: innerSize,
|
|
391
|
+
rotation: innerRot,
|
|
392
|
+
proportionType: "GOLDEN_RATIO",
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── 6. Flow-line pass ──────────────────────────────────────────
|
|
401
|
+
// Draw flowing curves that follow the hash-derived vector field
|
|
402
|
+
const numFlowLines = 6 + Math.floor(rng() * 10);
|
|
403
|
+
for (let i = 0; i < numFlowLines; i++) {
|
|
404
|
+
let fx = rng() * width;
|
|
405
|
+
let fy = rng() * height;
|
|
406
|
+
const steps = 30 + Math.floor(rng() * 40);
|
|
407
|
+
const stepLen = (3 + rng() * 5) * scaleFactor;
|
|
408
|
+
|
|
409
|
+
ctx.globalAlpha = 0.06 + rng() * 0.1;
|
|
410
|
+
ctx.strokeStyle = hexWithAlpha(
|
|
411
|
+
colors[Math.floor(rng() * colors.length)],
|
|
412
|
+
0.4,
|
|
413
|
+
);
|
|
414
|
+
ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
|
|
415
|
+
|
|
416
|
+
ctx.beginPath();
|
|
417
|
+
ctx.moveTo(fx, fy);
|
|
418
|
+
|
|
419
|
+
for (let s = 0; s < steps; s++) {
|
|
420
|
+
const angle = flowAngle(fx, fy) + (rng() - 0.5) * 0.3;
|
|
421
|
+
fx += Math.cos(angle) * stepLen;
|
|
422
|
+
fy += Math.sin(angle) * stepLen;
|
|
423
|
+
|
|
424
|
+
// Stay in bounds
|
|
425
|
+
if (fx < 0 || fx > width || fy < 0 || fy > height) break;
|
|
426
|
+
ctx.lineTo(fx, fy);
|
|
427
|
+
}
|
|
428
|
+
ctx.stroke();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── 7. Noise texture overlay ───────────────────────────────────
|
|
432
|
+
// Subtle grain rendered as tiny semi-transparent dots
|
|
433
|
+
const noiseRng = createRng(seedFromHash(gitHash, 777));
|
|
434
|
+
const noiseDensity = Math.floor((width * height) / 800);
|
|
435
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
436
|
+
const nx = noiseRng() * width;
|
|
437
|
+
const ny = noiseRng() * height;
|
|
438
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
439
|
+
const alpha = 0.01 + noiseRng() * 0.03;
|
|
440
|
+
ctx.globalAlpha = alpha;
|
|
441
|
+
ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
|
|
442
|
+
ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── 8. Organic connecting curves ───────────────────────────────
|
|
446
|
+
if (shapePositions.length > 1) {
|
|
447
|
+
const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
|
|
448
|
+
ctx.lineWidth = 0.8 * scaleFactor;
|
|
449
|
+
|
|
450
|
+
for (let i = 0; i < numCurves; i++) {
|
|
451
|
+
const idxA = Math.floor(rng() * shapePositions.length);
|
|
452
|
+
const offset =
|
|
453
|
+
1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
|
|
454
|
+
const idxB = (idxA + offset) % shapePositions.length;
|
|
455
|
+
|
|
456
|
+
const a = shapePositions[idxA];
|
|
457
|
+
const b = shapePositions[idxB];
|
|
458
|
+
|
|
459
|
+
const mx = (a.x + b.x) / 2;
|
|
460
|
+
const my = (a.y + b.y) / 2;
|
|
461
|
+
const dx = b.x - a.x;
|
|
462
|
+
const dy = b.y - a.y;
|
|
463
|
+
const dist = Math.hypot(dx, dy);
|
|
464
|
+
const bulge = (rng() - 0.5) * dist * 0.4;
|
|
465
|
+
|
|
466
|
+
const cpx = mx + (-dy / (dist || 1)) * bulge;
|
|
467
|
+
const cpy = my + (dx / (dist || 1)) * bulge;
|
|
468
|
+
|
|
469
|
+
ctx.globalAlpha = 0.06 + rng() * 0.1;
|
|
470
|
+
ctx.strokeStyle = hexWithAlpha(
|
|
471
|
+
colors[Math.floor(rng() * colors.length)],
|
|
472
|
+
0.3,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
ctx.beginPath();
|
|
476
|
+
ctx.moveTo(a.x, a.y);
|
|
477
|
+
ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
|
|
478
|
+
ctx.stroke();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
ctx.globalAlpha = 1;
|
|
483
|
+
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,18 +1,45 @@
|
|
|
1
|
-
import { shapes } from "./canvas/shapes";
|
|
2
|
-
|
|
3
1
|
export function gitHashToSeed(gitHash: string): number {
|
|
4
2
|
return parseInt(gitHash.slice(0, 8), 16);
|
|
5
3
|
}
|
|
6
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Mulberry32 — a fast, high-quality 32-bit seeded PRNG.
|
|
7
|
+
* Returns a function that produces deterministic floats in [0, 1).
|
|
8
|
+
*/
|
|
9
|
+
export function createRng(seed: number): () => number {
|
|
10
|
+
let s = seed | 0;
|
|
11
|
+
return () => {
|
|
12
|
+
s = (s + 0x6d2b79f5) | 0;
|
|
13
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
14
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
15
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Derive a deterministic seed from a hash string and an extra index
|
|
21
|
+
* so each call-site gets its own independent stream.
|
|
22
|
+
*/
|
|
23
|
+
export function seedFromHash(hash: string, offset = 0): number {
|
|
24
|
+
let h = 0;
|
|
25
|
+
for (let i = 0; i < hash.length; i++) {
|
|
26
|
+
h = (Math.imul(31, h) + hash.charCodeAt(i)) | 0;
|
|
27
|
+
}
|
|
28
|
+
return (h + offset) | 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Legacy helper kept for backward compat — now backed by mulberry32.
|
|
33
|
+
* Prefer createRng() + rng() for new code.
|
|
34
|
+
*/
|
|
7
35
|
export function getRandomFromHash(
|
|
8
36
|
hash: string,
|
|
9
37
|
index: number,
|
|
10
38
|
min: number,
|
|
11
39
|
max: number,
|
|
12
40
|
): number {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
return min + (decimal / 255) * (max - min);
|
|
41
|
+
const rng = createRng(seedFromHash(hash, index));
|
|
42
|
+
return min + rng() * (max - min);
|
|
16
43
|
}
|
|
17
44
|
|
|
18
45
|
// Golden ratio and other important proportions
|
|
@@ -29,7 +56,7 @@ export type ProportionType = keyof typeof Proportions;
|
|
|
29
56
|
|
|
30
57
|
interface Pattern {
|
|
31
58
|
type: string;
|
|
32
|
-
config: any;
|
|
59
|
+
config: any;
|
|
33
60
|
}
|
|
34
61
|
|
|
35
62
|
interface LayerConfig {
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for image generation.
|
|
3
|
+
*/
|
|
4
|
+
export interface GenerationConfig {
|
|
5
|
+
/** Canvas width in pixels (default: 2048) */
|
|
6
|
+
width: number;
|
|
7
|
+
/** Canvas height in pixels (default: 2048) */
|
|
8
|
+
height: number;
|
|
9
|
+
/** Controls base shape count per layer — gridSize² × 1.5 (default: 5) */
|
|
10
|
+
gridSize: number;
|
|
11
|
+
/** Number of layers to generate (default: 4) */
|
|
12
|
+
layers: number;
|
|
13
|
+
/** Minimum shape size in pixels, scaled to canvas (default: 30) */
|
|
14
|
+
minShapeSize: number;
|
|
15
|
+
/** Maximum shape size in pixels, scaled to canvas (default: 400) */
|
|
16
|
+
maxShapeSize: number;
|
|
17
|
+
/** Starting opacity for the first layer (default: 0.7) */
|
|
18
|
+
baseOpacity: number;
|
|
19
|
+
/** Opacity reduction per layer (default: 0.12) */
|
|
20
|
+
opacityReduction: number;
|
|
21
|
+
/** Base shapes per layer — defaults to gridSize² × 1.5 when 0 */
|
|
22
|
+
shapesPerLayer: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_CONFIG: GenerationConfig = {
|
|
26
|
+
width: 2048,
|
|
27
|
+
height: 2048,
|
|
28
|
+
gridSize: 5,
|
|
29
|
+
layers: 4,
|
|
30
|
+
minShapeSize: 30,
|
|
31
|
+
maxShapeSize: 400,
|
|
32
|
+
baseOpacity: 0.7,
|
|
33
|
+
opacityReduction: 0.12,
|
|
34
|
+
shapesPerLayer: 0,
|
|
35
|
+
};
|