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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-hash-art",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -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
- * The "classic" archetype preserves the original look for backward compat
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
- return ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
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
  }
@@ -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
+ }
@@ -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"; // interlocking horizontal/vertical threads
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, colorHierarchy, rng);
746
- const strokeBase = pickHierarchyColor(colorHierarchy, rng);
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
- if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
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
- if (size > adjustedMaxSize * 0.35 && rng() < 0.12) {
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. Apply symmetry mirroring ─────────────────────────────────
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
  }