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.
@@ -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 hexPair = hash.substr((index * 2) % hash.length, 2);
14
- const decimal = parseInt(hexPair, 16);
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; // You might want to define a more specific type for config
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
+ };