git-hash-art 0.10.1 → 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
@@ -50,7 +50,7 @@ import {
50
50
  } from "./canvas/shapes/affinity";
51
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,15 +69,7 @@ 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
- | "golden-spiral";
79
-
80
- const COMPOSITION_MODES: CompositionMode[] = [
72
+ const ALL_COMPOSITION_MODES: CompositionMode[] = [
81
73
  "radial",
82
74
  "flow-field",
83
75
  "spiral",
@@ -193,7 +185,80 @@ function isInVoidZone(
193
185
  return false;
194
186
  }
195
187
 
196
- // ── 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) ──────────────────────────
197
262
 
198
263
  function localDensity(
199
264
  x: number,
@@ -498,44 +563,45 @@ export function renderHashArt(
498
563
  const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
499
564
 
500
565
  if (bgPatternRoll < 0.2) {
501
- // Dot grid
566
+ // Dot grid — batched into a single path
502
567
  const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
503
568
  const dotR = dotSpacing * 0.08;
504
569
  ctx.globalAlpha = patternOpacity;
505
570
  ctx.fillStyle = patternColor;
571
+ ctx.beginPath();
506
572
  for (let px = 0; px < width; px += dotSpacing) {
507
573
  for (let py = 0; py < height; py += dotSpacing) {
508
- ctx.beginPath();
574
+ ctx.moveTo(px + dotR, py);
509
575
  ctx.arc(px, py, dotR, 0, Math.PI * 2);
510
- ctx.fill();
511
576
  }
512
577
  }
578
+ ctx.fill();
513
579
  } else if (bgPatternRoll < 0.4) {
514
- // Diagonal lines
580
+ // Diagonal lines — batched into a single path
515
581
  const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
516
582
  ctx.globalAlpha = patternOpacity;
517
583
  ctx.strokeStyle = patternColor;
518
584
  ctx.lineWidth = 0.5 * scaleFactor;
519
585
  const diag = Math.hypot(width, height);
586
+ ctx.beginPath();
520
587
  for (let d = -diag; d < diag; d += lineSpacing) {
521
- ctx.beginPath();
522
588
  ctx.moveTo(d, 0);
523
589
  ctx.lineTo(d + height, height);
524
- ctx.stroke();
525
590
  }
591
+ ctx.stroke();
526
592
  } else {
527
- // Tessellation — hexagonal grid of tiny shapes
593
+ // Tessellation — hexagonal grid, batched into a single path
528
594
  const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
529
595
  const tessH = tessSize * Math.sqrt(3);
530
596
  ctx.globalAlpha = patternOpacity * 0.7;
531
597
  ctx.strokeStyle = patternColor;
532
598
  ctx.lineWidth = 0.4 * scaleFactor;
599
+ ctx.beginPath();
533
600
  for (let row = 0; row * tessH < height + tessH; row++) {
534
601
  const offsetX = (row % 2) * tessSize * 0.75;
535
602
  for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
536
603
  const hx = col * tessSize * 1.5 + offsetX;
537
604
  const hy = row * tessH;
538
- ctx.beginPath();
539
605
  for (let s = 0; s < 6; s++) {
540
606
  const angle = (Math.PI / 3) * s - Math.PI / 6;
541
607
  const vx = hx + Math.cos(angle) * tessSize * 0.5;
@@ -544,17 +610,18 @@ export function renderHashArt(
544
610
  else ctx.lineTo(vx, vy);
545
611
  }
546
612
  ctx.closePath();
547
- ctx.stroke();
548
613
  }
549
614
  }
615
+ ctx.stroke();
550
616
  }
551
617
  ctx.restore();
552
618
  }
553
619
  ctx.globalCompositeOperation = "source-over";
554
620
 
555
- // ── 2. Composition mode ────────────────────────────────────────
556
- const compositionMode =
557
- 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)];
558
625
 
559
626
  // ── 2b. Symmetry mode — ~25% of hashes trigger mirroring ──────
560
627
  type SymmetryMode = "none" | "bilateral-x" | "bilateral-y" | "quad";
@@ -564,7 +631,7 @@ export function renderHashArt(
564
631
  symRoll < 0.20 ? "bilateral-y" :
565
632
  symRoll < 0.25 ? "quad" : "none";
566
633
 
567
- // ── 3. Focal points + void zones ───────────────────────────────
634
+ // ── 3. Focal points + void zones (archetype-aware) ───────────────
568
635
  const THIRDS_POINTS = [
569
636
  { x: 1 / 3, y: 1 / 3 },
570
637
  { x: 2 / 3, y: 1 / 3 },
@@ -590,14 +657,30 @@ export function renderHashArt(
590
657
  }
591
658
  }
592
659
 
593
- 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);
594
666
  const voidZones: Array<{ x: number; y: number; radius: number }> = [];
595
667
  for (let v = 0; v < numVoids; v++) {
596
- voidZones.push({
597
- x: width * (0.15 + rng() * 0.7),
598
- y: height * (0.15 + rng() * 0.7),
599
- radius: Math.min(width, height) * (0.06 + rng() * 0.1),
600
- });
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
+ }
601
684
  }
602
685
 
603
686
  function applyFocalBias(rx: number, ry: number): [number, number] {
@@ -681,6 +764,10 @@ export function renderHashArt(
681
764
  // Track all placed shapes for density checks and connecting curves
682
765
  const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
683
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
+
684
771
  // Hero avoidance radius — shapes near the hero orient toward it
685
772
  let heroCenter: { x: number; y: number; size: number } | null = null;
686
773
 
@@ -727,11 +814,11 @@ export function renderHashArt(
727
814
 
728
815
  heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
729
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 });
730
818
  }
731
819
 
732
820
 
733
821
  // ── 5. Shape layers ────────────────────────────────────────────
734
- const densityCheckRadius = Math.min(width, height) * 0.08;
735
822
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
736
823
 
737
824
  for (let layer = 0; layer < layers; layer++) {
@@ -792,7 +879,7 @@ export function renderHashArt(
792
879
  if (isInVoidZone(x, y, voidZones)) {
793
880
  if (rng() < 0.85) continue;
794
881
  }
795
- if (localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
882
+ if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
796
883
  if (rng() < 0.6) continue;
797
884
  }
798
885
 
@@ -881,17 +968,11 @@ export function renderHashArt(
881
968
  let finalX = x;
882
969
  let finalY = y;
883
970
  if (shapePositions.length > 0 && rng() < 0.25) {
884
- // Find nearest placed shape
885
- let nearestDist = Infinity;
886
- let nearestPos: { x: number; y: number; size: number } | null = null;
887
- for (const sp of shapePositions) {
888
- const d = Math.hypot(x - sp.x, y - sp.y);
889
- if (d < nearestDist && d > 0) {
890
- nearestDist = d;
891
- nearestPos = sp;
892
- }
893
- }
971
+ // Use spatial grid for O(1) nearest-neighbor lookup
972
+ const searchRadius = adjustedMaxSize * 3;
973
+ const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
894
974
  if (nearestPos) {
975
+ const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
895
976
  // Target distance: edges kissing (sum of half-sizes)
896
977
  const targetDist = (size + nearestPos.size) * 0.5;
897
978
  if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
@@ -960,6 +1041,7 @@ export function renderHashArt(
960
1041
  }
961
1042
 
962
1043
  shapePositions.push({ x: finalX, y: finalY, size, shape });
1044
+ spatialGrid.insert({ x: finalX, y: finalY, size, shape });
963
1045
 
964
1046
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
965
1047
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
@@ -986,6 +1068,7 @@ export function renderHashArt(
986
1068
  rng,
987
1069
  });
988
1070
  shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1071
+ spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
989
1072
  }
990
1073
  }
991
1074
 
@@ -1069,6 +1152,50 @@ export function renderHashArt(
1069
1152
  rng,
1070
1153
  });
1071
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 });
1072
1199
  }
1073
1200
  }
1074
1201
  }
@@ -1077,7 +1204,7 @@ export function renderHashArt(
1077
1204
  // Reset blend mode for post-processing passes
1078
1205
  ctx.globalCompositeOperation = "source-over";
1079
1206
 
1080
- // ── 5f. Layered masking / cutout portals ───────────────────────
1207
+ // ── 5g. Layered masking / cutout portals ───────────────────────
1081
1208
  // ~18% of images get 1-3 portal windows that paint over foreground
1082
1209
  // with a tinted background wash, creating a "peek through" effect.
1083
1210
  if (rng() < 0.18 && shapePositions.length > 3) {
@@ -1173,6 +1300,13 @@ export function renderHashArt(
1173
1300
 
1174
1301
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
1175
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
+
1176
1310
  const t = s / steps;
1177
1311
  // Taper + pressure
1178
1312
  const taper = 1 - t * 0.8;
@@ -1279,32 +1413,65 @@ export function renderHashArt(
1279
1413
  }
1280
1414
 
1281
1415
 
1282
- // ── 7. Noise texture overlay ───────────────────────────────────
1416
+ // ── 7. Noise texture overlay — batched via ImageData ─────────────
1283
1417
  const noiseRng = createRng(seedFromHash(gitHash, 777));
1284
1418
  const noiseDensity = Math.floor((width * height) / 800);
1285
- for (let i = 0; i < noiseDensity; i++) {
1286
- const nx = noiseRng() * width;
1287
- const ny = noiseRng() * height;
1288
- const brightness = noiseRng() > 0.5 ? 255 : 0;
1289
- const alpha = 0.01 + noiseRng() * 0.03;
1290
- ctx.globalAlpha = alpha;
1291
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1292
- 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
+ }
1293
1454
  }
1294
1455
 
1295
1456
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
1296
1457
  ctx.globalAlpha = 1;
1297
1458
  const vignetteStrength = 0.25 + rng() * 0.2;
1298
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
1299
1465
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
1300
1466
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
1301
- vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
1467
+ vigGrad.addColorStop(1, vignetteColor);
1302
1468
  ctx.fillStyle = vigGrad;
1303
1469
  ctx.fillRect(0, 0, width, height);
1304
1470
 
1305
- // ── 9. Organic connecting curves ───────────────────────────────
1471
+ // ── 9. Organic connecting curves — proximity-aware ───────────────
1306
1472
  if (shapePositions.length > 1) {
1307
1473
  const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
1474
+ const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
1308
1475
  ctx.lineWidth = 0.8 * scaleFactor;
1309
1476
 
1310
1477
  for (let i = 0; i < numCurves; i++) {
@@ -1316,11 +1483,15 @@ export function renderHashArt(
1316
1483
  const a = shapePositions[idxA];
1317
1484
  const b = shapePositions[idxB];
1318
1485
 
1319
- const mx = (a.x + b.x) / 2;
1320
- const my = (a.y + b.y) / 2;
1321
1486
  const dx = b.x - a.x;
1322
1487
  const dy = b.y - a.y;
1323
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;
1324
1495
  const bulge = (rng() - 0.5) * dist * 0.4;
1325
1496
 
1326
1497
  const cpx = mx + (-dy / (dist || 1)) * bulge;
@@ -1532,13 +1703,31 @@ export function renderHashArt(
1532
1703
  ctx.restore();
1533
1704
  }
1534
1705
 
1535
- // ── 11. Signature mark — unique geometric chop from hash prefix ──
1706
+ // ── 11. Signature mark — placed in the least-dense corner ──────
1536
1707
  {
1537
1708
  const sigRng = createRng(seedFromHash(gitHash, 42));
1538
1709
  const sigSize = Math.min(width, height) * 0.025;
1539
- // Bottom-right corner with padding
1540
- const sigX = width - sigSize * 2.5;
1541
- 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;
1542
1731
  const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
1543
1732
  const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
1544
1733