git-hash-art 0.10.0 → 0.11.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/src/lib/render.ts CHANGED
@@ -48,9 +48,9 @@ import {
48
48
  pickStyleForShape,
49
49
  SHAPE_PROFILES
50
50
  } from "./canvas/shapes/affinity";
51
- import { createRng, seedFromHash } from "./utils";
51
+ import { createRng, seedFromHash, createSimplexNoise, createFBM } from "./utils";
52
52
  import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
53
- import { selectArchetype, type BackgroundStyle } from "./archetypes";
53
+ import { selectArchetype, type BackgroundStyle, type CompositionMode } from "./archetypes";
54
54
 
55
55
 
56
56
  // ── Shape categories for weighted selection (legacy fallback) ───────
@@ -69,19 +69,13 @@ const SACRED_SHAPES = [
69
69
 
70
70
  // ── Composition modes ───────────────────────────────────────────────
71
71
 
72
- type CompositionMode =
73
- | "radial"
74
- | "flow-field"
75
- | "spiral"
76
- | "grid-subdivision"
77
- | "clustered";
78
-
79
- const COMPOSITION_MODES: CompositionMode[] = [
72
+ const ALL_COMPOSITION_MODES: CompositionMode[] = [
80
73
  "radial",
81
74
  "flow-field",
82
75
  "spiral",
83
76
  "grid-subdivision",
84
77
  "clustered",
78
+ "golden-spiral",
85
79
  ];
86
80
 
87
81
  // ── Helper: get position based on composition mode ──────────────────
@@ -138,6 +132,17 @@ function getCompositionPosition(
138
132
  default: {
139
133
  return { x: rng() * width, y: rng() * height };
140
134
  }
135
+ case "golden-spiral": {
136
+ // Logarithmic spiral: r = a * e^(b*theta), with golden angle spacing
137
+ const PHI = (1 + Math.sqrt(5)) / 2;
138
+ const goldenAngle = 2 * Math.PI / (PHI * PHI); // ~137.5° in radians
139
+ const t = shapeIndex / totalShapes;
140
+ const angle = shapeIndex * goldenAngle + rng() * 0.3;
141
+ const maxR = Math.min(width, height) * 0.44;
142
+ // Shapes spiral outward with sqrt distribution for even area coverage
143
+ const r = Math.sqrt(t) * maxR + (rng() - 0.5) * maxR * 0.08;
144
+ return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
145
+ }
141
146
  }
142
147
  }
143
148
 
@@ -180,7 +185,80 @@ function isInVoidZone(
180
185
  return false;
181
186
  }
182
187
 
183
- // ── Helper: density check ───────────────────────────────────────────
188
+ // ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
189
+
190
+ class SpatialGrid {
191
+ private cells: Map<string, Array<{ x: number; y: number; size: number; shape: string }>>;
192
+ private cellSize: number;
193
+
194
+ constructor(cellSize: number) {
195
+ this.cells = new Map();
196
+ this.cellSize = cellSize;
197
+ }
198
+
199
+ private key(cx: number, cy: number): string {
200
+ return `${cx},${cy}`;
201
+ }
202
+
203
+ insert(item: { x: number; y: number; size: number; shape: string }): void {
204
+ const cx = Math.floor(item.x / this.cellSize);
205
+ const cy = Math.floor(item.y / this.cellSize);
206
+ const k = this.key(cx, cy);
207
+ const cell = this.cells.get(k);
208
+ if (cell) cell.push(item);
209
+ else this.cells.set(k, [item]);
210
+ }
211
+
212
+ /** Count items within radius of (x, y) */
213
+ countNear(x: number, y: number, radius: number): number {
214
+ const r2 = radius * radius;
215
+ const minCx = Math.floor((x - radius) / this.cellSize);
216
+ const maxCx = Math.floor((x + radius) / this.cellSize);
217
+ const minCy = Math.floor((y - radius) / this.cellSize);
218
+ const maxCy = Math.floor((y + radius) / this.cellSize);
219
+ let count = 0;
220
+ for (let cx = minCx; cx <= maxCx; cx++) {
221
+ for (let cy = minCy; cy <= maxCy; cy++) {
222
+ const cell = this.cells.get(this.key(cx, cy));
223
+ if (!cell) continue;
224
+ for (const p of cell) {
225
+ const dx = x - p.x;
226
+ const dy = y - p.y;
227
+ if (dx * dx + dy * dy < r2) count++;
228
+ }
229
+ }
230
+ }
231
+ return count;
232
+ }
233
+
234
+ /** Find nearest item to (x, y) */
235
+ findNearest(x: number, y: number, searchRadius: number): { x: number; y: number; size: number } | null {
236
+ const minCx = Math.floor((x - searchRadius) / this.cellSize);
237
+ const maxCx = Math.floor((x + searchRadius) / this.cellSize);
238
+ const minCy = Math.floor((y - searchRadius) / this.cellSize);
239
+ const maxCy = Math.floor((y + searchRadius) / this.cellSize);
240
+ let nearest: { x: number; y: number; size: number } | null = null;
241
+ let bestDist2 = Infinity;
242
+ for (let cx = minCx; cx <= maxCx; cx++) {
243
+ for (let cy = minCy; cy <= maxCy; cy++) {
244
+ const cell = this.cells.get(this.key(cx, cy));
245
+ if (!cell) continue;
246
+ for (const p of cell) {
247
+ const dx = x - p.x;
248
+ const dy = y - p.y;
249
+ const d2 = dx * dx + dy * dy;
250
+ if (d2 > 0 && d2 < bestDist2) {
251
+ bestDist2 = d2;
252
+ nearest = p;
253
+ }
254
+ }
255
+ }
256
+ }
257
+ return nearest;
258
+ }
259
+ }
260
+
261
+ // ── Helper: density check (legacy wrapper) ──────────────────────────
184
262
 
185
263
  function localDensity(
186
264
  x: number,
@@ -485,44 +563,45 @@ export function renderHashArt(
485
563
  const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
486
564
 
487
565
  if (bgPatternRoll < 0.2) {
488
- // Dot grid
566
+ // Dot grid — batched into a single path
489
567
  const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
490
568
  const dotR = dotSpacing * 0.08;
491
569
  ctx.globalAlpha = patternOpacity;
492
570
  ctx.fillStyle = patternColor;
571
+ ctx.beginPath();
493
572
  for (let px = 0; px < width; px += dotSpacing) {
494
573
  for (let py = 0; py < height; py += dotSpacing) {
495
- ctx.beginPath();
574
+ ctx.moveTo(px + dotR, py);
496
575
  ctx.arc(px, py, dotR, 0, Math.PI * 2);
497
- ctx.fill();
498
576
  }
499
577
  }
578
+ ctx.fill();
500
579
  } else if (bgPatternRoll < 0.4) {
501
- // Diagonal lines
580
+ // Diagonal lines — batched into a single path
502
581
  const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
503
582
  ctx.globalAlpha = patternOpacity;
504
583
  ctx.strokeStyle = patternColor;
505
584
  ctx.lineWidth = 0.5 * scaleFactor;
506
585
  const diag = Math.hypot(width, height);
586
+ ctx.beginPath();
507
587
  for (let d = -diag; d < diag; d += lineSpacing) {
508
- ctx.beginPath();
509
588
  ctx.moveTo(d, 0);
510
589
  ctx.lineTo(d + height, height);
511
- ctx.stroke();
512
590
  }
591
+ ctx.stroke();
513
592
  } else {
514
- // Tessellation — hexagonal grid of tiny shapes
593
+ // Tessellation — hexagonal grid, batched into a single path
515
594
  const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
516
595
  const tessH = tessSize * Math.sqrt(3);
517
596
  ctx.globalAlpha = patternOpacity * 0.7;
518
597
  ctx.strokeStyle = patternColor;
519
598
  ctx.lineWidth = 0.4 * scaleFactor;
599
+ ctx.beginPath();
520
600
  for (let row = 0; row * tessH < height + tessH; row++) {
521
601
  const offsetX = (row % 2) * tessSize * 0.75;
522
602
  for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
523
603
  const hx = col * tessSize * 1.5 + offsetX;
524
604
  const hy = row * tessH;
525
- ctx.beginPath();
526
605
  for (let s = 0; s < 6; s++) {
527
606
  const angle = (Math.PI / 3) * s - Math.PI / 6;
528
607
  const vx = hx + Math.cos(angle) * tessSize * 0.5;
@@ -531,17 +610,18 @@ export function renderHashArt(
531
610
  else ctx.lineTo(vx, vy);
532
611
  }
533
612
  ctx.closePath();
534
- ctx.stroke();
535
613
  }
536
614
  }
615
+ ctx.stroke();
537
616
  }
538
617
  ctx.restore();
539
618
  }
540
619
  ctx.globalCompositeOperation = "source-over";
541
620
 
542
- // ── 2. Composition mode ────────────────────────────────────────
543
- const compositionMode =
544
- COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
621
+ // ── 2. Composition mode — archetype-aware selection ──────────────
622
+ const compositionMode: CompositionMode = rng() < 0.7
623
+ ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
624
+ : ALL_COMPOSITION_MODES[Math.floor(rng() * ALL_COMPOSITION_MODES.length)];
545
625
 
546
626
  // ── 2b. Symmetry mode — ~25% of hashes trigger mirroring ──────
547
627
  type SymmetryMode = "none" | "bilateral-x" | "bilateral-y" | "quad";
@@ -551,7 +631,7 @@ export function renderHashArt(
551
631
  symRoll < 0.20 ? "bilateral-y" :
552
632
  symRoll < 0.25 ? "quad" : "none";
553
633
 
554
- // ── 3. Focal points + void zones ───────────────────────────────
634
+ // ── 3. Focal points + void zones (archetype-aware) ───────────────
555
635
  const THIRDS_POINTS = [
556
636
  { x: 1 / 3, y: 1 / 3 },
557
637
  { x: 2 / 3, y: 1 / 3 },
@@ -577,14 +657,30 @@ export function renderHashArt(
577
657
  }
578
658
  }
579
659
 
580
- const numVoids = Math.floor(rng() * 2) + 1;
660
+ // Archetype-aware void zones: dense archetypes get fewer/no voids,
661
+ // minimal archetypes get golden-ratio positioned voids
662
+ const PHI = (1 + Math.sqrt(5)) / 2;
663
+ const isMinimalArchetype = archetype.gridSize <= 3;
664
+ const isDenseArchetype = archetype.gridSize >= 8;
665
+ const numVoids = isDenseArchetype ? 0 : (Math.floor(rng() * 2) + 1);
581
666
  const voidZones: Array<{ x: number; y: number; radius: number }> = [];
582
667
  for (let v = 0; v < numVoids; v++) {
583
- voidZones.push({
584
- x: width * (0.15 + rng() * 0.7),
585
- y: height * (0.15 + rng() * 0.7),
586
- radius: Math.min(width, height) * (0.06 + rng() * 0.1),
587
- });
668
+ if (isMinimalArchetype) {
669
+ // Place voids at golden-ratio positions for intentional negative space
670
+ const gx = (v === 0) ? 1 / PHI : 1 - 1 / PHI;
671
+ const gy = (v === 0) ? 1 - 1 / PHI : 1 / PHI;
672
+ voidZones.push({
673
+ x: width * (gx + (rng() - 0.5) * 0.05),
674
+ y: height * (gy + (rng() - 0.5) * 0.05),
675
+ radius: Math.min(width, height) * (0.08 + rng() * 0.08),
676
+ });
677
+ } else {
678
+ voidZones.push({
679
+ x: width * (0.15 + rng() * 0.7),
680
+ y: height * (0.15 + rng() * 0.7),
681
+ radius: Math.min(width, height) * (0.06 + rng() * 0.1),
682
+ });
683
+ }
588
684
  }
589
685
 
590
686
  function applyFocalBias(rx: number, ry: number): [number, number] {
@@ -643,21 +739,35 @@ export function renderHashArt(
643
739
  }
644
740
  ctx.globalAlpha = 1;
645
741
 
646
- // ── 4. Flow field seed values ──────────────────────────────────
742
+ // ── 4. Flow field simplex noise for organic variation ─────────
743
+ // Create a seeded simplex noise field (unique per hash)
744
+ const noiseFieldRng = createRng(seedFromHash(gitHash, 333));
745
+ const simplexNoise = createSimplexNoise(noiseFieldRng);
746
+ const fbmNoise = createFBM(simplexNoise, 3, 2.0, 0.5);
647
747
  const fieldAngleBase = rng() * Math.PI * 2;
648
- const fieldFreq = 0.5 + rng() * 2;
748
+ const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency
649
749
 
650
750
  function flowAngle(x: number, y: number): number {
651
- return (
652
- fieldAngleBase +
653
- Math.sin((x / width) * fieldFreq * Math.PI * 2) * Math.PI * 0.5 +
654
- Math.cos((y / height) * fieldFreq * Math.PI * 2) * Math.PI * 0.5
655
- );
751
+ // Sample FBM noise at the position, scaled by frequency
752
+ const nx = (x / width) * fieldFreq;
753
+ const ny = (y / height) * fieldFreq;
754
+ return fieldAngleBase + fbmNoise(nx, ny) * Math.PI;
755
+ }
756
+
757
+ // Noise-based size modulation — shapes in "high noise" areas get scaled
758
+ function noiseSizeModulation(x: number, y: number): number {
759
+ const n = simplexNoise((x / width) * 3, (y / height) * 3);
760
+ // Map [-1,1] to [0.7, 1.3] — subtle terrain-like size variation
761
+ return 0.7 + (n + 1) * 0.3;
656
762
  }
657
763
 
658
764
  // Track all placed shapes for density checks and connecting curves
659
765
  const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
660
766
 
767
+ // Spatial grid for O(1) density and nearest-neighbor lookups
768
+ const densityCheckRadius = Math.min(width, height) * 0.08;
769
+ const spatialGrid = new SpatialGrid(densityCheckRadius);
770
+
661
771
  // Hero avoidance radius — shapes near the hero orient toward it
662
772
  let heroCenter: { x: number; y: number; size: number } | null = null;
663
773
 
@@ -698,15 +808,17 @@ export function renderHashArt(
698
808
  gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
699
809
  renderStyle: heroStyle,
700
810
  rng,
811
+ lightAngle,
812
+ scaleFactor,
701
813
  });
702
814
 
703
815
  heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
704
816
  shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
817
+ spatialGrid.insert({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
705
818
  }
706
819
 
707
820
 
708
821
  // ── 5. Shape layers ────────────────────────────────────────────
709
- const densityCheckRadius = Math.min(width, height) * 0.08;
710
822
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
711
823
 
712
824
  for (let layer = 0; layer < layers; layer++) {
@@ -767,7 +879,7 @@ export function renderHashArt(
767
879
  if (isInVoidZone(x, y, voidZones)) {
768
880
  if (rng() < 0.85) continue;
769
881
  }
770
- if (localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
882
+ if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
771
883
  if (rng() < 0.6) continue;
772
884
  }
773
885
 
@@ -775,7 +887,7 @@ export function renderHashArt(
775
887
  const sizeT = Math.pow(rng(), archetype.sizePower);
776
888
  const size =
777
889
  (adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
778
- layerSizeScale;
890
+ layerSizeScale * noiseSizeModulation(x, y);
779
891
 
780
892
  // Size fraction for affinity-aware shape selection
781
893
  const sizeFraction = size / adjustedMaxSize;
@@ -856,17 +968,11 @@ export function renderHashArt(
856
968
  let finalX = x;
857
969
  let finalY = y;
858
970
  if (shapePositions.length > 0 && rng() < 0.25) {
859
- // Find nearest placed shape
860
- let nearestDist = Infinity;
861
- let nearestPos: { x: number; y: number; size: number } | null = null;
862
- for (const sp of shapePositions) {
863
- const d = Math.hypot(x - sp.x, y - sp.y);
864
- if (d < nearestDist && d > 0) {
865
- nearestDist = d;
866
- nearestPos = sp;
867
- }
868
- }
971
+ // Use spatial grid for O(1) nearest-neighbor lookup
972
+ const searchRadius = adjustedMaxSize * 3;
973
+ const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
869
974
  if (nearestPos) {
975
+ const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
870
976
  // Target distance: edges kissing (sum of half-sizes)
871
977
  const targetDist = (size + nearestPos.size) * 0.5;
872
978
  if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
@@ -900,6 +1006,8 @@ export function renderHashArt(
900
1006
  gradientFillEnd: gradientEnd,
901
1007
  renderStyle: finalRenderStyle,
902
1008
  rng,
1009
+ lightAngle,
1010
+ scaleFactor,
903
1011
  };
904
1012
 
905
1013
  if (shouldMirror) {
@@ -933,6 +1041,7 @@ export function renderHashArt(
933
1041
  }
934
1042
 
935
1043
  shapePositions.push({ x: finalX, y: finalY, size, shape });
1044
+ spatialGrid.insert({ x: finalX, y: finalY, size, shape });
936
1045
 
937
1046
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
938
1047
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
@@ -959,6 +1068,7 @@ export function renderHashArt(
959
1068
  rng,
960
1069
  });
961
1070
  shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1071
+ spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
962
1072
  }
963
1073
  }
964
1074
 
@@ -1042,6 +1152,50 @@ export function renderHashArt(
1042
1152
  rng,
1043
1153
  });
1044
1154
  shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1155
+ spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
1156
+ }
1157
+ }
1158
+
1159
+ // ── 5f. Rhythm placement — deliberate geometric progressions ──
1160
+ // ~12% of medium-large shapes spawn a rhythmic sequence
1161
+ if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
1162
+ const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
1163
+ const rhythmAngle = rng() * Math.PI * 2;
1164
+ const rhythmSpacing = size * (0.8 + rng() * 0.6);
1165
+ const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
1166
+ const rhythmShape = shape; // same shape for visual rhythm
1167
+
1168
+ let rhythmSize = size * 0.6;
1169
+ for (let r = 0; r < rhythmCount; r++) {
1170
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
1171
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
1172
+
1173
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
1174
+ if (isInVoidZone(rx, ry, voidZones)) break;
1175
+
1176
+ rhythmSize *= rhythmDecay;
1177
+ if (rhythmSize < adjustedMinSize) break;
1178
+
1179
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
1180
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
1181
+
1182
+ const rhythmFill = hexWithAlpha(
1183
+ jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
1184
+ fillAlpha * 0.7,
1185
+ );
1186
+
1187
+ enhanceShapeGeneration(ctx, rhythmShape, rx, ry, {
1188
+ fillColor: rhythmFill,
1189
+ strokeColor: hexWithAlpha(strokeColor, 0.5),
1190
+ strokeWidth: strokeWidth * 0.7,
1191
+ size: rhythmSize,
1192
+ rotation: rotation + (r + 1) * 12,
1193
+ proportionType: "GOLDEN_RATIO",
1194
+ renderStyle: finalRenderStyle,
1195
+ rng,
1196
+ });
1197
+ shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1198
+ spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1045
1199
  }
1046
1200
  }
1047
1201
  }
@@ -1050,6 +1204,72 @@ export function renderHashArt(
1050
1204
  // Reset blend mode for post-processing passes
1051
1205
  ctx.globalCompositeOperation = "source-over";
1052
1206
 
1207
+ // ── 5g. Layered masking / cutout portals ───────────────────────
1208
+ // ~18% of images get 1-3 portal windows that paint over foreground
1209
+ // with a tinted background wash, creating a "peek through" effect.
1210
+ if (rng() < 0.18 && shapePositions.length > 3) {
1211
+ const portalCount = 1 + Math.floor(rng() * 2);
1212
+ for (let p = 0; p < portalCount; p++) {
1213
+ // Pick a position biased toward placed shapes
1214
+ const sourceShape = shapePositions[Math.floor(rng() * shapePositions.length)];
1215
+ const portalX = sourceShape.x + (rng() - 0.5) * sourceShape.size * 0.5;
1216
+ const portalY = sourceShape.y + (rng() - 0.5) * sourceShape.size * 0.5;
1217
+ const portalSize = adjustedMaxSize * (0.15 + rng() * 0.25);
1218
+
1219
+ // Pick a portal shape from the palette
1220
+ const portalShape = pickShapeFromPalette(shapePalette, rng, portalSize / adjustedMaxSize);
1221
+ const portalRotation = rng() * 360;
1222
+ const portalAlpha = 0.6 + rng() * 0.35;
1223
+
1224
+ ctx.save();
1225
+ ctx.translate(portalX, portalY);
1226
+ ctx.rotate((portalRotation * Math.PI) / 180);
1227
+
1228
+ // Step 1: Clip to the portal shape and fill with background wash
1229
+ ctx.beginPath();
1230
+ shapes[portalShape]?.(ctx, portalSize);
1231
+ ctx.clip();
1232
+
1233
+ // Fill the clipped region with a radial gradient from background colors
1234
+ const portalColor = jitterColorHSL(bgStart, rng, 15, 0.1);
1235
+ const portalGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, portalSize);
1236
+ portalGrad.addColorStop(0, portalColor);
1237
+ portalGrad.addColorStop(1, bgEnd);
1238
+ ctx.globalAlpha = portalAlpha;
1239
+ ctx.fillStyle = portalGrad;
1240
+ ctx.fillRect(-portalSize, -portalSize, portalSize * 2, portalSize * 2);
1241
+
1242
+ // Optional: subtle inner texture — a few tiny dots inside the portal
1243
+ if (rng() < 0.5) {
1244
+ const dotCount = 3 + Math.floor(rng() * 5);
1245
+ ctx.globalAlpha = portalAlpha * 0.3;
1246
+ ctx.fillStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.2);
1247
+ for (let d = 0; d < dotCount; d++) {
1248
+ const dx = (rng() - 0.5) * portalSize * 1.4;
1249
+ const dy = (rng() - 0.5) * portalSize * 1.4;
1250
+ const dr = (1 + rng() * 3) * scaleFactor;
1251
+ ctx.beginPath();
1252
+ ctx.arc(dx, dy, dr, 0, Math.PI * 2);
1253
+ ctx.fill();
1254
+ }
1255
+ }
1256
+
1257
+ ctx.restore();
1258
+
1259
+ // Step 2: Draw a border ring around the portal (outside the clip)
1260
+ ctx.save();
1261
+ ctx.translate(portalX, portalY);
1262
+ ctx.rotate((portalRotation * Math.PI) / 180);
1263
+ ctx.globalAlpha = 0.15 + rng() * 0.2;
1264
+ ctx.strokeStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.5);
1265
+ ctx.lineWidth = (1.5 + rng() * 2.5) * scaleFactor;
1266
+ ctx.beginPath();
1267
+ shapes[portalShape]?.(ctx, portalSize * 1.06);
1268
+ ctx.stroke();
1269
+ ctx.restore();
1270
+ }
1271
+ }
1272
+
1053
1273
 
1054
1274
  // ── 6. Flow-line pass — variable color, branching, pressure ────
1055
1275
  const baseFlowLines = 6 + Math.floor(rng() * 10);
@@ -1080,6 +1300,13 @@ export function renderHashArt(
1080
1300
 
1081
1301
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
1082
1302
 
1303
+ // Skip segments that pass through void zones
1304
+ if (isInVoidZone(fx, fy, voidZones)) {
1305
+ prevX = fx;
1306
+ prevY = fy;
1307
+ continue;
1308
+ }
1309
+
1083
1310
  const t = s / steps;
1084
1311
  // Taper + pressure
1085
1312
  const taper = 1 - t * 0.8;
@@ -1186,32 +1413,65 @@ export function renderHashArt(
1186
1413
  }
1187
1414
 
1188
1415
 
1189
- // ── 7. Noise texture overlay ───────────────────────────────────
1416
+ // ── 7. Noise texture overlay — batched via ImageData ─────────────
1190
1417
  const noiseRng = createRng(seedFromHash(gitHash, 777));
1191
1418
  const noiseDensity = Math.floor((width * height) / 800);
1192
- for (let i = 0; i < noiseDensity; i++) {
1193
- const nx = noiseRng() * width;
1194
- const ny = noiseRng() * height;
1195
- const brightness = noiseRng() > 0.5 ? 255 : 0;
1196
- const alpha = 0.01 + noiseRng() * 0.03;
1197
- ctx.globalAlpha = alpha;
1198
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1199
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
1419
+ try {
1420
+ const imageData = ctx.getImageData(0, 0, width, height);
1421
+ const data = imageData.data;
1422
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
1423
+ for (let i = 0; i < noiseDensity; i++) {
1424
+ const nx = Math.floor(noiseRng() * width);
1425
+ const ny = Math.floor(noiseRng() * height);
1426
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1427
+ const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
1428
+ // Write a small block of pixels for scale
1429
+ for (let dy = 0; dy < pixelScale && ny + dy < height; dy++) {
1430
+ for (let dx = 0; dx < pixelScale && nx + dx < width; dx++) {
1431
+ const idx = ((ny + dy) * width + (nx + dx)) * 4;
1432
+ // Alpha-blend the noise dot onto existing pixel data
1433
+ const srcA = alpha / 255;
1434
+ const invA = 1 - srcA;
1435
+ data[idx] = Math.round(data[idx] * invA + brightness * srcA);
1436
+ data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
1437
+ data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
1438
+ // Keep existing alpha
1439
+ }
1440
+ }
1441
+ }
1442
+ ctx.putImageData(imageData, 0, 0);
1443
+ } catch {
1444
+ // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
1445
+ for (let i = 0; i < noiseDensity; i++) {
1446
+ const nx = noiseRng() * width;
1447
+ const ny = noiseRng() * height;
1448
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1449
+ const alpha = 0.01 + noiseRng() * 0.03;
1450
+ ctx.globalAlpha = alpha;
1451
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1452
+ ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
1453
+ }
1200
1454
  }
1201
1455
 
1202
1456
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
1203
1457
  ctx.globalAlpha = 1;
1204
1458
  const vignetteStrength = 0.25 + rng() * 0.2;
1205
1459
  const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
1460
+ // Tint vignette based on background: warm sepia for light, cool blue for dark
1461
+ const isLightBg = bgLum > 0.5;
1462
+ const vignetteColor = isLightBg
1463
+ ? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
1464
+ : `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
1206
1465
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
1207
1466
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
1208
- vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
1467
+ vigGrad.addColorStop(1, vignetteColor);
1209
1468
  ctx.fillStyle = vigGrad;
1210
1469
  ctx.fillRect(0, 0, width, height);
1211
1470
 
1212
- // ── 9. Organic connecting curves ───────────────────────────────
1471
+ // ── 9. Organic connecting curves — proximity-aware ───────────────
1213
1472
  if (shapePositions.length > 1) {
1214
1473
  const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
1474
+ const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
1215
1475
  ctx.lineWidth = 0.8 * scaleFactor;
1216
1476
 
1217
1477
  for (let i = 0; i < numCurves; i++) {
@@ -1223,11 +1483,15 @@ export function renderHashArt(
1223
1483
  const a = shapePositions[idxA];
1224
1484
  const b = shapePositions[idxB];
1225
1485
 
1226
- const mx = (a.x + b.x) / 2;
1227
- const my = (a.y + b.y) / 2;
1228
1486
  const dx = b.x - a.x;
1229
1487
  const dy = b.y - a.y;
1230
1488
  const dist = Math.hypot(dx, dy);
1489
+
1490
+ // Skip connections between distant shapes
1491
+ if (dist > maxCurveDist) continue;
1492
+
1493
+ const mx = (a.x + b.x) / 2;
1494
+ const my = (a.y + b.y) / 2;
1231
1495
  const bulge = (rng() - 0.5) * dist * 0.4;
1232
1496
 
1233
1497
  const cpx = mx + (-dy / (dist || 1)) * bulge;
@@ -1287,13 +1551,183 @@ export function renderHashArt(
1287
1551
  ctx.globalCompositeOperation = "source-over";
1288
1552
  }
1289
1553
 
1290
- // ── 11. Signature markunique geometric chop from hash prefix ──
1554
+ // 10d. Gradient mapmap luminance through a two-color gradient
1555
+ // Uses dominant→accent as the dark→light ramp for a cohesive tonal look
1556
+ if (rng() < 0.35) {
1557
+ const gmDark = colorHierarchy.dominant;
1558
+ const gmLight = colorHierarchy.accent;
1559
+ ctx.globalAlpha = 0.06 + rng() * 0.06; // very subtle: 6-12%
1560
+ ctx.globalCompositeOperation = "color";
1561
+ // Paint a linear gradient from dark color (top) to light color (bottom)
1562
+ const gmGrad = ctx.createLinearGradient(0, 0, 0, height);
1563
+ gmGrad.addColorStop(0, gmDark);
1564
+ gmGrad.addColorStop(1, gmLight);
1565
+ ctx.fillStyle = gmGrad;
1566
+ ctx.fillRect(0, 0, width, height);
1567
+ ctx.globalCompositeOperation = "source-over";
1568
+ }
1569
+
1570
+ // ── 10e. Generative borders — archetype-driven decorative frames ──
1571
+ {
1572
+ ctx.save();
1573
+ ctx.globalAlpha = 1;
1574
+ ctx.globalCompositeOperation = "source-over";
1575
+ const borderRng = createRng(seedFromHash(gitHash, 314));
1576
+ const borderPad = Math.min(width, height) * 0.025;
1577
+ const borderColor = hexWithAlpha(colorHierarchy.accent, 0.2);
1578
+ const borderColorSolid = colorHierarchy.accent;
1579
+ const archName = archetype.name;
1580
+
1581
+ if (archName.includes("geometric") || archName.includes("op-art") || archName.includes("shattered")) {
1582
+ // Clean ruled lines with corner ornaments
1583
+ ctx.strokeStyle = borderColor;
1584
+ ctx.lineWidth = Math.max(1, 1.5 * scaleFactor);
1585
+ ctx.globalAlpha = 0.18 + borderRng() * 0.1;
1586
+
1587
+ // Outer rule
1588
+ ctx.strokeRect(borderPad, borderPad, width - borderPad * 2, height - borderPad * 2);
1589
+ // Inner rule (thinner, offset)
1590
+ const innerPad = borderPad * 1.8;
1591
+ ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor);
1592
+ ctx.globalAlpha *= 0.7;
1593
+ ctx.strokeRect(innerPad, innerPad, width - innerPad * 2, height - innerPad * 2);
1594
+
1595
+ // Corner ornaments — small squares at each corner
1596
+ const ornSize = borderPad * 0.6;
1597
+ ctx.fillStyle = hexWithAlpha(borderColorSolid, 0.12);
1598
+ const corners = [
1599
+ [borderPad, borderPad],
1600
+ [width - borderPad - ornSize, borderPad],
1601
+ [borderPad, height - borderPad - ornSize],
1602
+ [width - borderPad - ornSize, height - borderPad - ornSize],
1603
+ ];
1604
+ for (const [cx2, cy2] of corners) {
1605
+ ctx.fillRect(cx2, cy2, ornSize, ornSize);
1606
+ // Diagonal cross inside ornament
1607
+ ctx.beginPath();
1608
+ ctx.moveTo(cx2, cy2);
1609
+ ctx.lineTo(cx2 + ornSize, cy2 + ornSize);
1610
+ ctx.moveTo(cx2 + ornSize, cy2);
1611
+ ctx.lineTo(cx2, cy2 + ornSize);
1612
+ ctx.stroke();
1613
+ }
1614
+ } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
1615
+ // Vine tendrils — organic curving lines along edges
1616
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
1617
+ ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
1618
+ ctx.globalAlpha = 0.12 + borderRng() * 0.08;
1619
+ ctx.lineCap = "round";
1620
+
1621
+ const tendrilCount = 8 + Math.floor(borderRng() * 8);
1622
+ for (let t = 0; t < tendrilCount; t++) {
1623
+ // Start from a random edge point
1624
+ const edge = Math.floor(borderRng() * 4);
1625
+ let tx: number, ty: number;
1626
+ if (edge === 0) { tx = borderRng() * width; ty = borderPad; }
1627
+ else if (edge === 1) { tx = borderRng() * width; ty = height - borderPad; }
1628
+ else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
1629
+ else { tx = width - borderPad; ty = borderRng() * height; }
1630
+
1631
+ ctx.beginPath();
1632
+ ctx.moveTo(tx, ty);
1633
+ const segs = 3 + Math.floor(borderRng() * 4);
1634
+ for (let s = 0; s < segs; s++) {
1635
+ const inward = borderPad * (1 + borderRng() * 2);
1636
+ // Curl inward from edge
1637
+ const cpx2 = tx + (borderRng() - 0.5) * borderPad * 4;
1638
+ const cpy2 = ty + (edge < 2 ? (edge === 0 ? inward : -inward) : 0);
1639
+ const cpx3 = tx + (edge >= 2 ? (edge === 2 ? inward : -inward) : (borderRng() - 0.5) * borderPad * 3);
1640
+ const cpy3 = ty + (borderRng() - 0.5) * borderPad * 3;
1641
+ tx = cpx3;
1642
+ ty = cpy3;
1643
+ ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
1644
+ }
1645
+ ctx.stroke();
1646
+
1647
+ // Small leaf/dot at tendril end
1648
+ if (borderRng() < 0.6) {
1649
+ ctx.beginPath();
1650
+ ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
1651
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
1652
+ ctx.fill();
1653
+ }
1654
+ }
1655
+ } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
1656
+ // Star-studded arcs along edges
1657
+ ctx.globalAlpha = 0.1 + borderRng() * 0.08;
1658
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.accent, 0.2);
1659
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.12);
1660
+ ctx.lineWidth = Math.max(0.5, 0.7 * scaleFactor);
1661
+
1662
+ // Subtle arc along top and bottom
1663
+ ctx.beginPath();
1664
+ ctx.arc(cx, -height * 0.3, height * 0.6, 0.3, Math.PI - 0.3);
1665
+ ctx.stroke();
1666
+ ctx.beginPath();
1667
+ ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
1668
+ ctx.stroke();
1669
+
1670
+ // Scatter small stars along the border region
1671
+ const starCount = 15 + Math.floor(borderRng() * 15);
1672
+ for (let s = 0; s < starCount; s++) {
1673
+ const edge = Math.floor(borderRng() * 4);
1674
+ let sx: number, sy: number;
1675
+ if (edge === 0) { sx = borderRng() * width; sy = borderPad * (0.5 + borderRng()); }
1676
+ else if (edge === 1) { sx = borderRng() * width; sy = height - borderPad * (0.5 + borderRng()); }
1677
+ else if (edge === 2) { sx = borderPad * (0.5 + borderRng()); sy = borderRng() * height; }
1678
+ else { sx = width - borderPad * (0.5 + borderRng()); sy = borderRng() * height; }
1679
+
1680
+ const starR = (1 + borderRng() * 2.5) * scaleFactor;
1681
+ // 4-point star
1682
+ ctx.beginPath();
1683
+ for (let p = 0; p < 8; p++) {
1684
+ const a = (p / 8) * Math.PI * 2;
1685
+ const r = p % 2 === 0 ? starR : starR * 0.4;
1686
+ const px2 = sx + Math.cos(a) * r;
1687
+ const py2 = sy + Math.sin(a) * r;
1688
+ if (p === 0) ctx.moveTo(px2, py2);
1689
+ else ctx.lineTo(px2, py2);
1690
+ }
1691
+ ctx.closePath();
1692
+ ctx.fill();
1693
+ }
1694
+ } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
1695
+ // Thin single rule — understated elegance
1696
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
1697
+ ctx.lineWidth = Math.max(0.5, 0.6 * scaleFactor);
1698
+ ctx.globalAlpha = 0.1 + borderRng() * 0.06;
1699
+ ctx.strokeRect(borderPad * 1.5, borderPad * 1.5, width - borderPad * 3, height - borderPad * 3);
1700
+ }
1701
+ // Other archetypes: no border (intentional — not every image needs one)
1702
+
1703
+ ctx.restore();
1704
+ }
1705
+
1706
+ // ── 11. Signature mark — placed in the least-dense corner ──────
1291
1707
  {
1292
1708
  const sigRng = createRng(seedFromHash(gitHash, 42));
1293
1709
  const sigSize = Math.min(width, height) * 0.025;
1294
- // Bottom-right corner with padding
1295
- const sigX = width - sigSize * 2.5;
1296
- const sigY = height - sigSize * 2.5;
1710
+ const sigMargin = sigSize * 2.5;
1711
+
1712
+ // Find the corner with the lowest local density
1713
+ const cornerCandidates = [
1714
+ { x: sigMargin, y: sigMargin }, // top-left
1715
+ { x: width - sigMargin, y: sigMargin }, // top-right
1716
+ { x: sigMargin, y: height - sigMargin }, // bottom-left
1717
+ { x: width - sigMargin, y: height - sigMargin }, // bottom-right
1718
+ ];
1719
+ let bestCorner = cornerCandidates[3]; // default: bottom-right
1720
+ let minDensity = Infinity;
1721
+ for (const corner of cornerCandidates) {
1722
+ const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
1723
+ if (density < minDensity) {
1724
+ minDensity = density;
1725
+ bestCorner = corner;
1726
+ }
1727
+ }
1728
+
1729
+ const sigX = bestCorner.x;
1730
+ const sigY = bestCorner.y;
1297
1731
  const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
1298
1732
  const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
1299
1733