git-hash-art 0.9.0 → 0.10.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 +105 -7
- package/CHANGELOG.md +8 -0
- package/dist/browser.js +241 -11
- package/dist/browser.js.map +1 -1
- package/dist/main.js +241 -11
- package/dist/main.js.map +1 -1
- package/dist/module.js +241 -11
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +47 -3
- package/src/lib/canvas/colors.ts +25 -0
- package/src/lib/canvas/draw.ts +31 -1
- package/src/lib/canvas/shapes/affinity.ts +3 -3
- package/src/lib/render.ts +168 -7
package/package.json
CHANGED
package/src/lib/archetypes.ts
CHANGED
|
@@ -357,11 +357,55 @@ const ARCHETYPES: Archetype[] = [
|
|
|
357
357
|
},
|
|
358
358
|
];
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Linearly interpolate between two archetype numeric parameters.
|
|
362
|
+
*/
|
|
363
|
+
function lerpNum(a: number, b: number, t: number): number {
|
|
364
|
+
return a + (b - a) * t;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Blend two archetypes by interpolating their numeric parameters
|
|
369
|
+
* and merging their style arrays.
|
|
370
|
+
*/
|
|
371
|
+
function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
|
|
372
|
+
// Merge preferred styles — unique union, primary archetype first
|
|
373
|
+
const mergedStyles = [...new Set([...a.preferredStyles, ...b.preferredStyles])] as RenderStyle[];
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
name: `${a.name}+${b.name}`,
|
|
377
|
+
gridSize: Math.round(lerpNum(a.gridSize, b.gridSize, t)),
|
|
378
|
+
layers: Math.round(lerpNum(a.layers, b.layers, t)),
|
|
379
|
+
baseOpacity: lerpNum(a.baseOpacity, b.baseOpacity, t),
|
|
380
|
+
opacityReduction: lerpNum(a.opacityReduction, b.opacityReduction, t),
|
|
381
|
+
minShapeSize: Math.round(lerpNum(a.minShapeSize, b.minShapeSize, t)),
|
|
382
|
+
maxShapeSize: Math.round(lerpNum(a.maxShapeSize, b.maxShapeSize, t)),
|
|
383
|
+
backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
|
|
384
|
+
paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
|
|
385
|
+
preferredStyles: mergedStyles,
|
|
386
|
+
flowLineMultiplier: lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
|
|
387
|
+
heroShape: t < 0.5 ? a.heroShape : b.heroShape,
|
|
388
|
+
glowMultiplier: lerpNum(a.glowMultiplier, b.glowMultiplier, t),
|
|
389
|
+
sizePower: lerpNum(a.sizePower, b.sizePower, t),
|
|
390
|
+
invertForeground: t < 0.5 ? a.invertForeground : b.invertForeground,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
360
394
|
/**
|
|
361
395
|
* Select an archetype deterministically from the hash.
|
|
362
|
-
*
|
|
363
|
-
* but only gets ~10% of hashes.
|
|
396
|
+
* ~15% of hashes produce a blended archetype (interpolation of two).
|
|
364
397
|
*/
|
|
365
398
|
export function selectArchetype(rng: () => number): Archetype {
|
|
366
|
-
|
|
399
|
+
const primary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
|
|
400
|
+
|
|
401
|
+
// ~15% chance of blending with a second archetype
|
|
402
|
+
if (rng() < 0.15) {
|
|
403
|
+
const secondary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
|
|
404
|
+
if (secondary.name !== primary.name) {
|
|
405
|
+
const blendT = 0.25 + rng() * 0.25; // 25-50% blend toward secondary
|
|
406
|
+
return blendArchetypes(primary, secondary, blendT);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return primary;
|
|
367
411
|
}
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -510,3 +510,28 @@ export function pickColorGrade(rng: () => number): { hue: number; intensity: num
|
|
|
510
510
|
const intensity = 0.15 + rng() * 0.25;
|
|
511
511
|
return { hue: (hue + 360) % 360, intensity };
|
|
512
512
|
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Rotate the hue of a hex color by a given number of degrees.
|
|
516
|
+
*/
|
|
517
|
+
export function hueRotate(hex: string, degrees: number): string {
|
|
518
|
+
const [h, s, l] = hexToHsl(hex);
|
|
519
|
+
return hslToHex((h + degrees + 360) % 360, s, l);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Evolve a color hierarchy for a given layer — shifts hue progressively.
|
|
524
|
+
* Creates atmospheric color perspective (like distant mountains shifting blue).
|
|
525
|
+
*/
|
|
526
|
+
export function evolveHierarchy(
|
|
527
|
+
base: ColorHierarchy,
|
|
528
|
+
layerRatio: number,
|
|
529
|
+
hueShiftPerLayer: number,
|
|
530
|
+
): ColorHierarchy {
|
|
531
|
+
const shift = layerRatio * hueShiftPerLayer;
|
|
532
|
+
return {
|
|
533
|
+
dominant: hueRotate(base.dominant, shift),
|
|
534
|
+
secondary: hueRotate(base.secondary, shift * 0.7),
|
|
535
|
+
accent: hueRotate(base.accent, shift * 0.5),
|
|
536
|
+
};
|
|
537
|
+
}
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -41,7 +41,8 @@ export type RenderStyle =
|
|
|
41
41
|
| "noise-grain" // procedural noise grain texture clipped to shape
|
|
42
42
|
| "wood-grain" // parallel wavy lines simulating wood
|
|
43
43
|
| "marble-vein" // branching vein lines on a soft fill
|
|
44
|
-
| "fabric-weave"
|
|
44
|
+
| "fabric-weave" // interlocking horizontal/vertical threads
|
|
45
|
+
| "hand-drawn"; // wobbly hand-drawn edge treatment
|
|
45
46
|
|
|
46
47
|
const RENDER_STYLES: RenderStyle[] = [
|
|
47
48
|
"fill-and-stroke",
|
|
@@ -59,6 +60,7 @@ const RENDER_STYLES: RenderStyle[] = [
|
|
|
59
60
|
"wood-grain",
|
|
60
61
|
"marble-vein",
|
|
61
62
|
"fabric-weave",
|
|
63
|
+
"hand-drawn",
|
|
62
64
|
];
|
|
63
65
|
|
|
64
66
|
export function pickRenderStyle(rng: () => number): RenderStyle {
|
|
@@ -506,6 +508,34 @@ function applyRenderStyle(
|
|
|
506
508
|
break;
|
|
507
509
|
}
|
|
508
510
|
|
|
511
|
+
case "hand-drawn": {
|
|
512
|
+
// Wobbly hand-drawn edge treatment — fill normally, then redraw
|
|
513
|
+
// the outline with perturbed control points for a sketchy feel
|
|
514
|
+
const savedAlphaHD = ctx.globalAlpha;
|
|
515
|
+
ctx.globalAlpha = savedAlphaHD * 0.85;
|
|
516
|
+
ctx.fill();
|
|
517
|
+
ctx.globalAlpha = savedAlphaHD;
|
|
518
|
+
|
|
519
|
+
// Draw 2-3 slightly offset wobbly strokes for a sketchy look
|
|
520
|
+
const wobblePasses = 2 + (rng ? Math.floor(rng() * 2) : 0);
|
|
521
|
+
ctx.lineWidth = strokeWidth * 0.8;
|
|
522
|
+
for (let wp = 0; wp < wobblePasses; wp++) {
|
|
523
|
+
ctx.globalAlpha = savedAlphaHD * (0.4 - wp * 0.1);
|
|
524
|
+
ctx.save();
|
|
525
|
+
// Slight random offset per pass
|
|
526
|
+
const wobbleX = rng ? (rng() - 0.5) * size * 0.02 : 0;
|
|
527
|
+
const wobbleY = rng ? (rng() - 0.5) * size * 0.02 : 0;
|
|
528
|
+
ctx.translate(wobbleX, wobbleY);
|
|
529
|
+
// Slightly different scale per pass for edge variation
|
|
530
|
+
const wobbleScale = 1 + (rng ? (rng() - 0.5) * 0.03 : 0);
|
|
531
|
+
ctx.scale(wobbleScale, wobbleScale);
|
|
532
|
+
ctx.stroke();
|
|
533
|
+
ctx.restore();
|
|
534
|
+
}
|
|
535
|
+
ctx.globalAlpha = savedAlphaHD;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
|
|
509
539
|
case "fill-and-stroke":
|
|
510
540
|
default:
|
|
511
541
|
ctx.fill();
|
|
@@ -39,7 +39,7 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
|
|
|
39
39
|
affinities: ["circle", "blob", "hexagon", "flowerOfLife", "seedOfLife"],
|
|
40
40
|
category: "basic",
|
|
41
41
|
heroCandidate: false,
|
|
42
|
-
bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
|
|
42
|
+
bestStyles: ["fill-only", "watercolor", "fill-and-stroke", "hand-drawn"],
|
|
43
43
|
},
|
|
44
44
|
square: {
|
|
45
45
|
tier: 2,
|
|
@@ -57,7 +57,7 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
|
|
|
57
57
|
affinities: ["triangle", "diamond", "hexagon", "merkaba", "sriYantra"],
|
|
58
58
|
category: "basic",
|
|
59
59
|
heroCandidate: false,
|
|
60
|
-
bestStyles: ["fill-and-stroke", "fill-only", "watercolor"],
|
|
60
|
+
bestStyles: ["fill-and-stroke", "fill-only", "watercolor", "hand-drawn"],
|
|
61
61
|
},
|
|
62
62
|
hexagon: {
|
|
63
63
|
tier: 1,
|
|
@@ -261,7 +261,7 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
|
|
|
261
261
|
affinities: ["blob", "circle", "superellipse", "waveRing"],
|
|
262
262
|
category: "procedural",
|
|
263
263
|
heroCandidate: false,
|
|
264
|
-
bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
|
|
264
|
+
bestStyles: ["fill-only", "watercolor", "fill-and-stroke", "hand-drawn"],
|
|
265
265
|
},
|
|
266
266
|
ngon: {
|
|
267
267
|
tier: 2,
|
package/src/lib/render.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
enforceContrast,
|
|
32
32
|
buildColorHierarchy,
|
|
33
33
|
pickHierarchyColor, pickColorGrade,
|
|
34
|
-
type ColorHierarchy
|
|
34
|
+
evolveHierarchy, type ColorHierarchy
|
|
35
35
|
} from "./canvas/colors";
|
|
36
36
|
import {
|
|
37
37
|
enhanceShapeGeneration,
|
|
@@ -409,6 +409,9 @@ export function renderHashArt(
|
|
|
409
409
|
// ── 0e. Light direction — consistent shadow angle ──────────────
|
|
410
410
|
const lightAngle = rng() * Math.PI * 2;
|
|
411
411
|
|
|
412
|
+
// ── 0f. Palette evolution — hue drift direction across layers ──
|
|
413
|
+
const paletteHueShift = (rng() - 0.5) * 40; // -20° to +20° total drift
|
|
414
|
+
|
|
412
415
|
const scaleFactor = Math.min(width, height) / 1024;
|
|
413
416
|
const adjustedMinSize = minShapeSize * scaleFactor;
|
|
414
417
|
const adjustedMaxSize = maxShapeSize * scaleFactor;
|
|
@@ -598,6 +601,48 @@ export function renderHashArt(
|
|
|
598
601
|
return [rx + (nearest.x - rx) * pull, ry + (nearest.y - ry) * pull];
|
|
599
602
|
}
|
|
600
603
|
|
|
604
|
+
// ── 3b. Void zone decoration — intentional negative space ────
|
|
605
|
+
for (const zone of voidZones) {
|
|
606
|
+
// Subtle halo ring around void zones
|
|
607
|
+
ctx.globalAlpha = 0.04 + rng() * 0.04;
|
|
608
|
+
ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.2);
|
|
609
|
+
ctx.lineWidth = 1.5 * scaleFactor;
|
|
610
|
+
ctx.beginPath();
|
|
611
|
+
ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
|
|
612
|
+
ctx.stroke();
|
|
613
|
+
|
|
614
|
+
// ~50% chance: scatter tiny dots inside the void
|
|
615
|
+
if (rng() < 0.5) {
|
|
616
|
+
const dotCount = 3 + Math.floor(rng() * 6);
|
|
617
|
+
ctx.globalAlpha = 0.06 + rng() * 0.04;
|
|
618
|
+
ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
|
|
619
|
+
for (let d = 0; d < dotCount; d++) {
|
|
620
|
+
const angle = rng() * Math.PI * 2;
|
|
621
|
+
const dist = rng() * zone.radius * 0.7;
|
|
622
|
+
const dotR = (1 + rng() * 3) * scaleFactor;
|
|
623
|
+
ctx.beginPath();
|
|
624
|
+
ctx.arc(
|
|
625
|
+
zone.x + Math.cos(angle) * dist,
|
|
626
|
+
zone.y + Math.sin(angle) * dist,
|
|
627
|
+
dotR, 0, Math.PI * 2,
|
|
628
|
+
);
|
|
629
|
+
ctx.fill();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ~30% chance: thin concentric ring inside
|
|
634
|
+
if (rng() < 0.3) {
|
|
635
|
+
ctx.globalAlpha = 0.03 + rng() * 0.03;
|
|
636
|
+
ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
|
|
637
|
+
ctx.lineWidth = 0.5 * scaleFactor;
|
|
638
|
+
const innerR = zone.radius * (0.4 + rng() * 0.3);
|
|
639
|
+
ctx.beginPath();
|
|
640
|
+
ctx.arc(zone.x, zone.y, innerR, 0, Math.PI * 2);
|
|
641
|
+
ctx.stroke();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
ctx.globalAlpha = 1;
|
|
645
|
+
|
|
601
646
|
// ── 4. Flow field seed values ──────────────────────────────────
|
|
602
647
|
const fieldAngleBase = rng() * Math.PI * 2;
|
|
603
648
|
const fieldFreq = 0.5 + rng() * 2;
|
|
@@ -690,6 +735,20 @@ export function renderHashArt(
|
|
|
690
735
|
const dofStrokeScale = 0.4 + dofFactor * 0.6; // strokes thin out with depth
|
|
691
736
|
const dofContrastReduction = layerRatio * 0.2; // colors fade toward bg
|
|
692
737
|
|
|
738
|
+
// Color palette evolution — hue-rotate the hierarchy per layer
|
|
739
|
+
const layerHierarchy = evolveHierarchy(colorHierarchy, layerRatio, paletteHueShift);
|
|
740
|
+
|
|
741
|
+
// Focal depth: shapes near focal points get more detail
|
|
742
|
+
const focalDetailBoost = (px: number, py: number): number => {
|
|
743
|
+
let minFocalDist = Infinity;
|
|
744
|
+
for (const fp of focalPoints) {
|
|
745
|
+
const d = Math.hypot(px - fp.x, py - fp.y);
|
|
746
|
+
if (d < minFocalDist) minFocalDist = d;
|
|
747
|
+
}
|
|
748
|
+
const maxDist = Math.hypot(width, height) * 0.5;
|
|
749
|
+
return Math.max(0, 1 - minFocalDist / maxDist); // 1.0 at focal, 0.0 at edges
|
|
750
|
+
};
|
|
751
|
+
|
|
693
752
|
for (let i = 0; i < numShapes; i++) {
|
|
694
753
|
// Position from composition mode, then focal bias
|
|
695
754
|
const rawPos = getCompositionPosition(
|
|
@@ -741,9 +800,9 @@ export function renderHashArt(
|
|
|
741
800
|
}
|
|
742
801
|
}
|
|
743
802
|
|
|
744
|
-
// Positional color from hierarchy + jitter
|
|
745
|
-
let fillBase = getPositionalColor(x, y, width, height,
|
|
746
|
-
const strokeBase = pickHierarchyColor(
|
|
803
|
+
// Positional color from hierarchy + jitter (using evolved layer palette)
|
|
804
|
+
let fillBase = getPositionalColor(x, y, width, height, layerHierarchy, rng);
|
|
805
|
+
const strokeBase = pickHierarchyColor(layerHierarchy, rng);
|
|
747
806
|
|
|
748
807
|
// Desaturate colors on later layers for depth
|
|
749
808
|
if (atmosphericDesat > 0) {
|
|
@@ -853,6 +912,26 @@ export function renderHashArt(
|
|
|
853
912
|
enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
|
|
854
913
|
}
|
|
855
914
|
|
|
915
|
+
// ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
|
|
916
|
+
if (rng() < 0.2 && size > adjustedMinSize * 2) {
|
|
917
|
+
const glazePasses = 2 + Math.floor(rng() * 2);
|
|
918
|
+
for (let g = 0; g < glazePasses; g++) {
|
|
919
|
+
const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
|
|
920
|
+
const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
|
|
921
|
+
ctx.globalAlpha = glazeAlpha;
|
|
922
|
+
enhanceShapeGeneration(ctx, shape, finalX, finalY, {
|
|
923
|
+
fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
|
|
924
|
+
strokeColor: "rgba(0,0,0,0)",
|
|
925
|
+
strokeWidth: 0,
|
|
926
|
+
size: size * glazeScale,
|
|
927
|
+
rotation,
|
|
928
|
+
proportionType: "GOLDEN_RATIO",
|
|
929
|
+
renderStyle: "fill-only",
|
|
930
|
+
rng,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
856
935
|
shapePositions.push({ x: finalX, y: finalY, size, shape });
|
|
857
936
|
|
|
858
937
|
// ── 5c. Size echo — large shapes spawn trailing smaller copies ──
|
|
@@ -884,7 +963,10 @@ export function renderHashArt(
|
|
|
884
963
|
}
|
|
885
964
|
|
|
886
965
|
// ── 5d. Recursive nesting ──────────────────────────────────
|
|
887
|
-
|
|
966
|
+
// Focal depth: shapes near focal points get more detail
|
|
967
|
+
const focalProximity = focalDetailBoost(finalX, finalY);
|
|
968
|
+
const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
|
|
969
|
+
if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
|
|
888
970
|
const innerCount = 1 + Math.floor(rng() * 3);
|
|
889
971
|
for (let n = 0; n < innerCount; n++) {
|
|
890
972
|
// Pick inner shape from palette affinities
|
|
@@ -920,7 +1002,8 @@ export function renderHashArt(
|
|
|
920
1002
|
}
|
|
921
1003
|
|
|
922
1004
|
// ── 5e. Shape constellations — pre-composed groups ─────────
|
|
923
|
-
|
|
1005
|
+
const constellationChance = 0.12 + focalProximity * 0.1; // 12-22% near focal
|
|
1006
|
+
if (size > adjustedMaxSize * 0.35 && rng() < constellationChance) {
|
|
924
1007
|
const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
|
|
925
1008
|
const members = constellation.build(rng, size);
|
|
926
1009
|
const groupRotation = rng() * Math.PI * 2;
|
|
@@ -1047,7 +1130,41 @@ export function renderHashArt(
|
|
|
1047
1130
|
}
|
|
1048
1131
|
}
|
|
1049
1132
|
|
|
1050
|
-
// ── 6b.
|
|
1133
|
+
// ── 6b. Motion/energy lines — short directional bursts ─────────
|
|
1134
|
+
const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
|
|
1135
|
+
const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
|
|
1136
|
+
if (hasEnergyLines && shapePositions.length > 0) {
|
|
1137
|
+
const energyCount = 5 + Math.floor(rng() * 10);
|
|
1138
|
+
ctx.lineCap = "round";
|
|
1139
|
+
for (let e = 0; e < energyCount; e++) {
|
|
1140
|
+
// Pick a random shape to radiate from
|
|
1141
|
+
const source = shapePositions[Math.floor(rng() * shapePositions.length)];
|
|
1142
|
+
const burstCount = 2 + Math.floor(rng() * 4);
|
|
1143
|
+
const baseAngle = flowAngle(source.x, source.y);
|
|
1144
|
+
|
|
1145
|
+
for (let b = 0; b < burstCount; b++) {
|
|
1146
|
+
const angle = baseAngle + (rng() - 0.5) * 1.2;
|
|
1147
|
+
const lineLen = (source.size * 0.3 + rng() * source.size * 0.5) * scaleFactor * 0.3;
|
|
1148
|
+
const startDist = source.size * 0.5;
|
|
1149
|
+
const sx = source.x + Math.cos(angle) * startDist;
|
|
1150
|
+
const sy = source.y + Math.sin(angle) * startDist;
|
|
1151
|
+
const ex = sx + Math.cos(angle) * lineLen;
|
|
1152
|
+
const ey = sy + Math.sin(angle) * lineLen;
|
|
1153
|
+
|
|
1154
|
+
ctx.globalAlpha = 0.04 + rng() * 0.06;
|
|
1155
|
+
ctx.strokeStyle = hexWithAlpha(
|
|
1156
|
+
enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
|
|
1157
|
+
);
|
|
1158
|
+
ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
|
|
1159
|
+
ctx.beginPath();
|
|
1160
|
+
ctx.moveTo(sx, sy);
|
|
1161
|
+
ctx.lineTo(ex, ey);
|
|
1162
|
+
ctx.stroke();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ── 6c. Apply symmetry mirroring ─────────────────────────────────
|
|
1051
1168
|
if (symmetryMode !== "none") {
|
|
1052
1169
|
const canvas = ctx.canvas;
|
|
1053
1170
|
ctx.save();
|
|
@@ -1170,6 +1287,50 @@ export function renderHashArt(
|
|
|
1170
1287
|
ctx.globalCompositeOperation = "source-over";
|
|
1171
1288
|
}
|
|
1172
1289
|
|
|
1290
|
+
// ── 11. Signature mark — unique geometric chop from hash prefix ──
|
|
1291
|
+
{
|
|
1292
|
+
const sigRng = createRng(seedFromHash(gitHash, 42));
|
|
1293
|
+
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;
|
|
1297
|
+
const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
|
|
1298
|
+
const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
|
|
1299
|
+
|
|
1300
|
+
ctx.save();
|
|
1301
|
+
ctx.globalAlpha = 0.12 + sigRng() * 0.08;
|
|
1302
|
+
ctx.translate(sigX, sigY);
|
|
1303
|
+
ctx.strokeStyle = sigColor;
|
|
1304
|
+
ctx.fillStyle = hexWithAlpha(colorHierarchy.dominant, 0.06);
|
|
1305
|
+
ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor);
|
|
1306
|
+
|
|
1307
|
+
// Outer ring
|
|
1308
|
+
ctx.beginPath();
|
|
1309
|
+
ctx.arc(0, 0, sigSize, 0, Math.PI * 2);
|
|
1310
|
+
ctx.stroke();
|
|
1311
|
+
ctx.fill();
|
|
1312
|
+
|
|
1313
|
+
// Inner geometric pattern — unique per hash
|
|
1314
|
+
ctx.beginPath();
|
|
1315
|
+
for (let s = 0; s < sigSegments; s++) {
|
|
1316
|
+
const angle1 = sigRng() * Math.PI * 2;
|
|
1317
|
+
const angle2 = sigRng() * Math.PI * 2;
|
|
1318
|
+
const r1 = sigSize * (0.2 + sigRng() * 0.6);
|
|
1319
|
+
const r2 = sigSize * (0.2 + sigRng() * 0.6);
|
|
1320
|
+
ctx.moveTo(Math.cos(angle1) * r1, Math.sin(angle1) * r1);
|
|
1321
|
+
ctx.lineTo(Math.cos(angle2) * r2, Math.sin(angle2) * r2);
|
|
1322
|
+
}
|
|
1323
|
+
ctx.stroke();
|
|
1324
|
+
|
|
1325
|
+
// Center dot
|
|
1326
|
+
ctx.beginPath();
|
|
1327
|
+
ctx.arc(0, 0, sigSize * 0.12, 0, Math.PI * 2);
|
|
1328
|
+
ctx.fillStyle = sigColor;
|
|
1329
|
+
ctx.fill();
|
|
1330
|
+
|
|
1331
|
+
ctx.restore();
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1173
1334
|
ctx.globalAlpha = 1;
|
|
1174
1335
|
|
|
1175
1336
|
}
|