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/.github/workflows/deploy-www.yml +13 -3
- package/CHANGELOG.md +10 -0
- package/dist/browser.js +360 -56
- package/dist/browser.js.map +1 -1
- package/dist/main.js +362 -56
- package/dist/main.js.map +1 -1
- package/dist/module.js +362 -56
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +29 -0
- package/src/lib/canvas/colors.ts +7 -5
- package/src/lib/canvas/draw.ts +14 -4
- package/src/lib/render.ts +249 -60
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
|
-
|
|
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
|
-
// ──
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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 (
|
|
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
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
1286
|
-
const
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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,
|
|
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 —
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
|