git-hash-art 0.8.0 → 0.9.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/ALGORITHM.md +323 -270
- package/CHANGELOG.md +10 -0
- package/bin/cli.js +17 -14
- package/bin/generateVersionComparison.js +353 -0
- package/dist/browser.js +1246 -36
- package/dist/browser.js.map +1 -1
- package/dist/main.js +1246 -36
- package/dist/main.js.map +1 -1
- package/dist/module.js +1246 -36
- package/dist/module.js.map +1 -1
- package/package.json +2 -1
- package/src/lib/archetypes.ts +68 -0
- package/src/lib/canvas/draw.ts +318 -1
- package/src/lib/canvas/shapes/affinity.ts +146 -1
- package/src/lib/canvas/shapes/procedural.ts +395 -32
- package/src/lib/render.ts +259 -13
package/src/lib/render.ts
CHANGED
|
@@ -35,9 +35,11 @@ import {
|
|
|
35
35
|
} from "./canvas/colors";
|
|
36
36
|
import {
|
|
37
37
|
enhanceShapeGeneration,
|
|
38
|
+
drawMirroredShape,
|
|
39
|
+
pickMirrorAxis,
|
|
38
40
|
pickBlendMode,
|
|
39
41
|
pickRenderStyle,
|
|
40
|
-
type RenderStyle
|
|
42
|
+
type RenderStyle
|
|
41
43
|
} from "./canvas/draw";
|
|
42
44
|
import { shapes } from "./canvas/shapes";
|
|
43
45
|
import {
|
|
@@ -272,6 +274,92 @@ function drawBackground(
|
|
|
272
274
|
}
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
// ── Shape constellations — pre-composed groups of shapes ────────
|
|
278
|
+
|
|
279
|
+
interface ConstellationDef {
|
|
280
|
+
name: string;
|
|
281
|
+
/** Generate member positions/shapes relative to center */
|
|
282
|
+
build: (rng: () => number, baseSize: number) => Array<{
|
|
283
|
+
dx: number; dy: number; shape: string; size: number; rotation: number;
|
|
284
|
+
}>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const CONSTELLATIONS: ConstellationDef[] = [
|
|
288
|
+
{
|
|
289
|
+
name: "flanked-triangle",
|
|
290
|
+
build: (rng, baseSize) => {
|
|
291
|
+
const gap = baseSize * (0.6 + rng() * 0.3);
|
|
292
|
+
return [
|
|
293
|
+
{ dx: 0, dy: 0, shape: "triangle", size: baseSize, rotation: rng() * 360 },
|
|
294
|
+
{ dx: -gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 },
|
|
295
|
+
{ dx: gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 },
|
|
296
|
+
];
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "hexagon-ring",
|
|
301
|
+
build: (rng, baseSize) => {
|
|
302
|
+
const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = [];
|
|
303
|
+
const count = 5 + Math.floor(rng() * 2);
|
|
304
|
+
const ringR = baseSize * 0.6;
|
|
305
|
+
for (let i = 0; i < count; i++) {
|
|
306
|
+
const angle = (i / count) * Math.PI * 2;
|
|
307
|
+
members.push({
|
|
308
|
+
dx: Math.cos(angle) * ringR,
|
|
309
|
+
dy: Math.sin(angle) * ringR,
|
|
310
|
+
shape: "hexagon",
|
|
311
|
+
size: baseSize * (0.25 + rng() * 0.1),
|
|
312
|
+
rotation: (angle * 180) / Math.PI,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return members;
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "spiral-dots",
|
|
320
|
+
build: (rng, baseSize) => {
|
|
321
|
+
const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = [];
|
|
322
|
+
const count = 7 + Math.floor(rng() * 5);
|
|
323
|
+
const turns = 1.5 + rng();
|
|
324
|
+
for (let i = 0; i < count; i++) {
|
|
325
|
+
const t = i / count;
|
|
326
|
+
const angle = t * Math.PI * 2 * turns;
|
|
327
|
+
const r = t * baseSize * 0.7;
|
|
328
|
+
members.push({
|
|
329
|
+
dx: Math.cos(angle) * r,
|
|
330
|
+
dy: Math.sin(angle) * r,
|
|
331
|
+
shape: "circle",
|
|
332
|
+
size: baseSize * (0.08 + (1 - t) * 0.12),
|
|
333
|
+
rotation: 0,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return members;
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: "diamond-cluster",
|
|
341
|
+
build: (rng, baseSize) => {
|
|
342
|
+
const gap = baseSize * 0.45;
|
|
343
|
+
return [
|
|
344
|
+
{ dx: 0, dy: -gap, shape: "diamond", size: baseSize * 0.4, rotation: 0 },
|
|
345
|
+
{ dx: gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: 15 },
|
|
346
|
+
{ dx: 0, dy: gap, shape: "diamond", size: baseSize * 0.3, rotation: 30 },
|
|
347
|
+
{ dx: -gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: -15 },
|
|
348
|
+
];
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "crescent-pair",
|
|
353
|
+
build: (rng, baseSize) => {
|
|
354
|
+
const gap = baseSize * 0.5;
|
|
355
|
+
return [
|
|
356
|
+
{ dx: -gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.5, rotation: rng() * 30 },
|
|
357
|
+
{ dx: gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.45, rotation: 180 + rng() * 30 },
|
|
358
|
+
];
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
|
|
275
363
|
// ── Main render function ────────────────────────────────────────────
|
|
276
364
|
|
|
277
365
|
export function renderHashArt(
|
|
@@ -385,6 +473,69 @@ export function renderHashArt(
|
|
|
385
473
|
}
|
|
386
474
|
ctx.globalCompositeOperation = "source-over";
|
|
387
475
|
|
|
476
|
+
// ── 1c. Background pattern layer — subtle textured paper ───────
|
|
477
|
+
const bgPatternRoll = rng();
|
|
478
|
+
if (bgPatternRoll < 0.6) {
|
|
479
|
+
ctx.save();
|
|
480
|
+
ctx.globalCompositeOperation = "soft-light";
|
|
481
|
+
const patternOpacity = 0.02 + rng() * 0.04;
|
|
482
|
+
const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
|
|
483
|
+
|
|
484
|
+
if (bgPatternRoll < 0.2) {
|
|
485
|
+
// Dot grid
|
|
486
|
+
const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
|
|
487
|
+
const dotR = dotSpacing * 0.08;
|
|
488
|
+
ctx.globalAlpha = patternOpacity;
|
|
489
|
+
ctx.fillStyle = patternColor;
|
|
490
|
+
for (let px = 0; px < width; px += dotSpacing) {
|
|
491
|
+
for (let py = 0; py < height; py += dotSpacing) {
|
|
492
|
+
ctx.beginPath();
|
|
493
|
+
ctx.arc(px, py, dotR, 0, Math.PI * 2);
|
|
494
|
+
ctx.fill();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} else if (bgPatternRoll < 0.4) {
|
|
498
|
+
// Diagonal lines
|
|
499
|
+
const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
|
|
500
|
+
ctx.globalAlpha = patternOpacity;
|
|
501
|
+
ctx.strokeStyle = patternColor;
|
|
502
|
+
ctx.lineWidth = 0.5 * scaleFactor;
|
|
503
|
+
const diag = Math.hypot(width, height);
|
|
504
|
+
for (let d = -diag; d < diag; d += lineSpacing) {
|
|
505
|
+
ctx.beginPath();
|
|
506
|
+
ctx.moveTo(d, 0);
|
|
507
|
+
ctx.lineTo(d + height, height);
|
|
508
|
+
ctx.stroke();
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
// Tessellation — hexagonal grid of tiny shapes
|
|
512
|
+
const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
|
|
513
|
+
const tessH = tessSize * Math.sqrt(3);
|
|
514
|
+
ctx.globalAlpha = patternOpacity * 0.7;
|
|
515
|
+
ctx.strokeStyle = patternColor;
|
|
516
|
+
ctx.lineWidth = 0.4 * scaleFactor;
|
|
517
|
+
for (let row = 0; row * tessH < height + tessH; row++) {
|
|
518
|
+
const offsetX = (row % 2) * tessSize * 0.75;
|
|
519
|
+
for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
|
|
520
|
+
const hx = col * tessSize * 1.5 + offsetX;
|
|
521
|
+
const hy = row * tessH;
|
|
522
|
+
ctx.beginPath();
|
|
523
|
+
for (let s = 0; s < 6; s++) {
|
|
524
|
+
const angle = (Math.PI / 3) * s - Math.PI / 6;
|
|
525
|
+
const vx = hx + Math.cos(angle) * tessSize * 0.5;
|
|
526
|
+
const vy = hy + Math.sin(angle) * tessSize * 0.5;
|
|
527
|
+
if (s === 0) ctx.moveTo(vx, vy);
|
|
528
|
+
else ctx.lineTo(vx, vy);
|
|
529
|
+
}
|
|
530
|
+
ctx.closePath();
|
|
531
|
+
ctx.stroke();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
ctx.restore();
|
|
536
|
+
}
|
|
537
|
+
ctx.globalCompositeOperation = "source-over";
|
|
538
|
+
|
|
388
539
|
// ── 2. Composition mode ────────────────────────────────────────
|
|
389
540
|
const compositionMode =
|
|
390
541
|
COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
|
|
@@ -533,6 +684,12 @@ export function renderHashArt(
|
|
|
533
684
|
// Atmospheric desaturation for later layers
|
|
534
685
|
const atmosphericDesat = layerRatio * 0.3;
|
|
535
686
|
|
|
687
|
+
// Depth-of-field simulation — later layers are "further away"
|
|
688
|
+
// Reduce stroke widths and shift colors toward the background
|
|
689
|
+
const dofFactor = 1 - layerRatio * 0.5; // 1.0 for front layer, 0.5 for back
|
|
690
|
+
const dofStrokeScale = 0.4 + dofFactor * 0.6; // strokes thin out with depth
|
|
691
|
+
const dofContrastReduction = layerRatio * 0.2; // colors fade toward bg
|
|
692
|
+
|
|
536
693
|
for (let i = 0; i < numShapes; i++) {
|
|
537
694
|
// Position from composition mode, then focal bias
|
|
538
695
|
const rawPos = getCompositionPosition(
|
|
@@ -605,9 +762,11 @@ export function renderHashArt(
|
|
|
605
762
|
const fillAlpha = 0.2 + rng() * 0.5;
|
|
606
763
|
const transparentFill = hexWithAlpha(fillColor, fillAlpha);
|
|
607
764
|
|
|
608
|
-
const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor;
|
|
765
|
+
const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor * dofStrokeScale;
|
|
609
766
|
|
|
610
|
-
|
|
767
|
+
// Depth-of-field: reduce opacity slightly for distant layers
|
|
768
|
+
const dofOpacityScale = 1 - dofContrastReduction;
|
|
769
|
+
ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5) * dofOpacityScale;
|
|
611
770
|
|
|
612
771
|
// Glow on sacred shapes more often — scaled by archetype
|
|
613
772
|
const isSacred = SACRED_SHAPES.includes(shape);
|
|
@@ -634,13 +793,47 @@ export function renderHashArt(
|
|
|
634
793
|
const shadowOffX = shadowDist * Math.cos(lightAngle);
|
|
635
794
|
const shadowOffY = shadowDist * Math.sin(lightAngle);
|
|
636
795
|
|
|
637
|
-
|
|
796
|
+
// ── 5a. Tangent placement — nudge toward nearest shape edge ──
|
|
797
|
+
let finalX = x;
|
|
798
|
+
let finalY = y;
|
|
799
|
+
if (shapePositions.length > 0 && rng() < 0.25) {
|
|
800
|
+
// Find nearest placed shape
|
|
801
|
+
let nearestDist = Infinity;
|
|
802
|
+
let nearestPos: { x: number; y: number; size: number } | null = null;
|
|
803
|
+
for (const sp of shapePositions) {
|
|
804
|
+
const d = Math.hypot(x - sp.x, y - sp.y);
|
|
805
|
+
if (d < nearestDist && d > 0) {
|
|
806
|
+
nearestDist = d;
|
|
807
|
+
nearestPos = sp;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (nearestPos) {
|
|
811
|
+
// Target distance: edges kissing (sum of half-sizes)
|
|
812
|
+
const targetDist = (size + nearestPos.size) * 0.5;
|
|
813
|
+
if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
|
|
814
|
+
const angle = Math.atan2(y - nearestPos.y, x - nearestPos.x);
|
|
815
|
+
finalX = nearestPos.x + Math.cos(angle) * targetDist;
|
|
816
|
+
finalY = nearestPos.y + Math.sin(angle) * targetDist;
|
|
817
|
+
// Keep in bounds
|
|
818
|
+
finalX = Math.max(0, Math.min(width, finalX));
|
|
819
|
+
finalY = Math.max(0, Math.min(height, finalY));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── 5b. Shape mirroring — basic shapes get reflected copies ──
|
|
825
|
+
const mirrorAxis = pickMirrorAxis(rng);
|
|
826
|
+
const isBasicShape = ["circle", "triangle", "square", "hexagon", "star",
|
|
827
|
+
"diamond", "crescent", "penroseTile", "reuleauxTriangle"].includes(shape);
|
|
828
|
+
const shouldMirror = mirrorAxis !== null && isBasicShape && size > adjustedMaxSize * 0.2;
|
|
829
|
+
|
|
830
|
+
const shapeConfig = {
|
|
638
831
|
fillColor: transparentFill,
|
|
639
832
|
strokeColor,
|
|
640
833
|
strokeWidth,
|
|
641
834
|
size,
|
|
642
835
|
rotation,
|
|
643
|
-
proportionType: "GOLDEN_RATIO",
|
|
836
|
+
proportionType: "GOLDEN_RATIO" as const,
|
|
644
837
|
glowRadius: glowRadius || (shadowDist > 0 ? shadowDist * 2 : 0),
|
|
645
838
|
glowColor: hasGlow
|
|
646
839
|
? hexWithAlpha(fillColor, 0.6)
|
|
@@ -648,19 +841,29 @@ export function renderHashArt(
|
|
|
648
841
|
gradientFillEnd: gradientEnd,
|
|
649
842
|
renderStyle: finalRenderStyle,
|
|
650
843
|
rng,
|
|
651
|
-
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
if (shouldMirror) {
|
|
847
|
+
drawMirroredShape(ctx, shape, finalX, finalY, {
|
|
848
|
+
...shapeConfig,
|
|
849
|
+
mirrorAxis: mirrorAxis!,
|
|
850
|
+
mirrorGap: size * (0.1 + rng() * 0.3),
|
|
851
|
+
});
|
|
852
|
+
} else {
|
|
853
|
+
enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
|
|
854
|
+
}
|
|
652
855
|
|
|
653
|
-
shapePositions.push({ x, y, size, shape });
|
|
856
|
+
shapePositions.push({ x: finalX, y: finalY, size, shape });
|
|
654
857
|
|
|
655
|
-
// ──
|
|
858
|
+
// ── 5c. Size echo — large shapes spawn trailing smaller copies ──
|
|
656
859
|
if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
|
|
657
860
|
const echoCount = 2 + Math.floor(rng() * 2);
|
|
658
861
|
const echoAngle = rng() * Math.PI * 2;
|
|
659
862
|
for (let e = 0; e < echoCount; e++) {
|
|
660
863
|
const echoScale = 0.3 - e * 0.08;
|
|
661
864
|
const echoDist = size * (0.6 + e * 0.4);
|
|
662
|
-
const echoX =
|
|
663
|
-
const echoY =
|
|
865
|
+
const echoX = finalX + Math.cos(echoAngle) * echoDist;
|
|
866
|
+
const echoY = finalY + Math.sin(echoAngle) * echoDist;
|
|
664
867
|
const echoSize = size * Math.max(0.1, echoScale);
|
|
665
868
|
|
|
666
869
|
if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
|
|
@@ -680,7 +883,7 @@ export function renderHashArt(
|
|
|
680
883
|
}
|
|
681
884
|
}
|
|
682
885
|
|
|
683
|
-
// ──
|
|
886
|
+
// ── 5d. Recursive nesting ──────────────────────────────────
|
|
684
887
|
if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
|
|
685
888
|
const innerCount = 1 + Math.floor(rng() * 3);
|
|
686
889
|
for (let n = 0; n < innerCount; n++) {
|
|
@@ -700,8 +903,8 @@ export function renderHashArt(
|
|
|
700
903
|
enhanceShapeGeneration(
|
|
701
904
|
ctx,
|
|
702
905
|
innerShape,
|
|
703
|
-
|
|
704
|
-
|
|
906
|
+
finalX + innerOffX,
|
|
907
|
+
finalY + innerOffY,
|
|
705
908
|
{
|
|
706
909
|
fillColor: innerFill,
|
|
707
910
|
strokeColor: hexWithAlpha(strokeColor, 0.5),
|
|
@@ -715,6 +918,49 @@ export function renderHashArt(
|
|
|
715
918
|
);
|
|
716
919
|
}
|
|
717
920
|
}
|
|
921
|
+
|
|
922
|
+
// ── 5e. Shape constellations — pre-composed groups ─────────
|
|
923
|
+
if (size > adjustedMaxSize * 0.35 && rng() < 0.12) {
|
|
924
|
+
const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
|
|
925
|
+
const members = constellation.build(rng, size);
|
|
926
|
+
const groupRotation = rng() * Math.PI * 2;
|
|
927
|
+
const cosR = Math.cos(groupRotation);
|
|
928
|
+
const sinR = Math.sin(groupRotation);
|
|
929
|
+
|
|
930
|
+
for (const member of members) {
|
|
931
|
+
// Rotate the group offset by the group rotation
|
|
932
|
+
const mx = finalX + member.dx * cosR - member.dy * sinR;
|
|
933
|
+
const my = finalY + member.dx * sinR + member.dy * cosR;
|
|
934
|
+
|
|
935
|
+
if (mx < 0 || mx > width || my < 0 || my > height) continue;
|
|
936
|
+
|
|
937
|
+
const memberFill = hexWithAlpha(
|
|
938
|
+
jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
|
|
939
|
+
fillAlpha * 0.8,
|
|
940
|
+
);
|
|
941
|
+
const memberStroke = enforceContrast(
|
|
942
|
+
jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
ctx.globalAlpha = layerOpacity * 0.6;
|
|
946
|
+
// Use the member's shape if available, otherwise fall back to palette
|
|
947
|
+
const memberShape = shapeNames.includes(member.shape)
|
|
948
|
+
? member.shape
|
|
949
|
+
: pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
|
|
950
|
+
|
|
951
|
+
enhanceShapeGeneration(ctx, memberShape, mx, my, {
|
|
952
|
+
fillColor: memberFill,
|
|
953
|
+
strokeColor: memberStroke,
|
|
954
|
+
strokeWidth: strokeWidth * 0.7,
|
|
955
|
+
size: member.size,
|
|
956
|
+
rotation: member.rotation + (groupRotation * 180) / Math.PI,
|
|
957
|
+
proportionType: "GOLDEN_RATIO",
|
|
958
|
+
renderStyle: pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle,
|
|
959
|
+
rng,
|
|
960
|
+
});
|
|
961
|
+
shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
718
964
|
}
|
|
719
965
|
}
|
|
720
966
|
|