git-hash-art 0.9.0 → 0.10.1

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.1",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -27,7 +27,10 @@ export type PaletteMode =
27
27
  | "neon" // high saturation on dark
28
28
  | "pastel-light" // soft pastels on light background
29
29
  | "earth" // muted warm naturals
30
- | "high-contrast"; // black + white + one accent
30
+ | "high-contrast" // black + white + one accent
31
+ | "split-complementary" // base hue + two flanking complements
32
+ | "analogous-accent" // tight analogous cluster + one distant accent
33
+ | "limited-palette"; // 3 colors only, risograph-print feel
31
34
 
32
35
  // ── Archetype definition ────────────────────────────────────────────
33
36
 
@@ -357,11 +360,55 @@ const ARCHETYPES: Archetype[] = [
357
360
  },
358
361
  ];
359
362
 
363
+ /**
364
+ * Linearly interpolate between two archetype numeric parameters.
365
+ */
366
+ function lerpNum(a: number, b: number, t: number): number {
367
+ return a + (b - a) * t;
368
+ }
369
+
370
+ /**
371
+ * Blend two archetypes by interpolating their numeric parameters
372
+ * and merging their style arrays.
373
+ */
374
+ function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
375
+ // Merge preferred styles — unique union, primary archetype first
376
+ const mergedStyles = [...new Set([...a.preferredStyles, ...b.preferredStyles])] as RenderStyle[];
377
+
378
+ return {
379
+ name: `${a.name}+${b.name}`,
380
+ gridSize: Math.round(lerpNum(a.gridSize, b.gridSize, t)),
381
+ layers: Math.round(lerpNum(a.layers, b.layers, t)),
382
+ baseOpacity: lerpNum(a.baseOpacity, b.baseOpacity, t),
383
+ opacityReduction: lerpNum(a.opacityReduction, b.opacityReduction, t),
384
+ minShapeSize: Math.round(lerpNum(a.minShapeSize, b.minShapeSize, t)),
385
+ maxShapeSize: Math.round(lerpNum(a.maxShapeSize, b.maxShapeSize, t)),
386
+ backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
387
+ paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
388
+ preferredStyles: mergedStyles,
389
+ flowLineMultiplier: lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
390
+ heroShape: t < 0.5 ? a.heroShape : b.heroShape,
391
+ glowMultiplier: lerpNum(a.glowMultiplier, b.glowMultiplier, t),
392
+ sizePower: lerpNum(a.sizePower, b.sizePower, t),
393
+ invertForeground: t < 0.5 ? a.invertForeground : b.invertForeground,
394
+ };
395
+ }
396
+
360
397
  /**
361
398
  * 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.
399
+ * ~15% of hashes produce a blended archetype (interpolation of two).
364
400
  */
365
401
  export function selectArchetype(rng: () => number): Archetype {
366
- return ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
402
+ const primary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
403
+
404
+ // ~15% chance of blending with a second archetype
405
+ if (rng() < 0.15) {
406
+ const secondary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
407
+ if (secondary.name !== primary.name) {
408
+ const blendT = 0.25 + rng() * 0.25; // 25-50% blend toward secondary
409
+ return blendArchetypes(primary, secondary, blendT);
410
+ }
411
+ }
412
+
413
+ return primary;
367
414
  }
@@ -194,6 +194,45 @@ export class SacredColorScheme {
194
194
  const accent = hslToHex(baseHue, 0.9, 0.5);
195
195
  return ["#111111", "#eeeeee", accent, hslToHex(baseHue, 0.7, 0.35)];
196
196
  }
197
+ case "split-complementary": {
198
+ // Base hue + two colors flanking the complement (±30°)
199
+ const comp = (baseHue + 180) % 360;
200
+ const split1 = (comp - 30 + 360) % 360;
201
+ const split2 = (comp + 30) % 360;
202
+ const sat = 0.55 + this.rng() * 0.25;
203
+ return [
204
+ hslToHex(baseHue, sat, 0.5),
205
+ hslToHex(baseHue, sat * 0.8, 0.65),
206
+ hslToHex(split1, sat, 0.5),
207
+ hslToHex(split2, sat, 0.5),
208
+ hslToHex(split1, sat * 0.7, 0.7),
209
+ ];
210
+ }
211
+ case "analogous-accent": {
212
+ // Tight cluster of 3 analogous hues + 1 distant accent
213
+ const step = 15 + this.rng() * 20; // 15-35° apart
214
+ const h1 = (baseHue - step + 360) % 360;
215
+ const h2 = (baseHue + step) % 360;
216
+ const accentHue = (baseHue + 150 + this.rng() * 60) % 360;
217
+ const sat = 0.5 + this.rng() * 0.3;
218
+ return [
219
+ hslToHex(baseHue, sat, 0.5),
220
+ hslToHex(h1, sat, 0.55),
221
+ hslToHex(h2, sat, 0.45),
222
+ hslToHex(accentHue, sat + 0.15, 0.5),
223
+ ];
224
+ }
225
+ case "limited-palette": {
226
+ // Only 3 colors — like a risograph print
227
+ const h2 = (baseHue + 120 + this.rng() * 40) % 360;
228
+ const h3 = (baseHue + 220 + this.rng() * 40) % 360;
229
+ const sat = 0.6 + this.rng() * 0.2;
230
+ return [
231
+ hslToHex(baseHue, sat, 0.5),
232
+ hslToHex(h2, sat, 0.5),
233
+ hslToHex(h3, sat * 0.9, 0.55),
234
+ ];
235
+ }
197
236
  case "harmonious":
198
237
  default:
199
238
  return this.getColors();
@@ -210,6 +249,11 @@ export class SacredColorScheme {
210
249
  case "high-contrast":
211
250
  case "monochrome-ink":
212
251
  return ["#f5f5f0", "#e8e8e0"];
252
+ case "split-complementary":
253
+ case "analogous-accent":
254
+ return this.getBackgroundColors();
255
+ case "limited-palette":
256
+ return [hslToHex(this.seed % 360, 0.08, 0.94), hslToHex((this.seed + 20) % 360, 0.06, 0.90)];
213
257
  case "neon":
214
258
  return ["#0a0a12", "#050510"];
215
259
  case "earth":
@@ -510,3 +554,29 @@ export function pickColorGrade(rng: () => number): { hue: number; intensity: num
510
554
  const intensity = 0.15 + rng() * 0.25;
511
555
  return { hue: (hue + 360) % 360, intensity };
512
556
  }
557
+
558
+ /**
559
+ * Rotate the hue of a hex color by a given number of degrees.
560
+ */
561
+ export function hueRotate(hex: string, degrees: number): string {
562
+ const [h, s, l] = hexToHsl(hex);
563
+ return hslToHex((h + degrees + 360) % 360, s, l);
564
+ }
565
+
566
+ /**
567
+ * Evolve a color hierarchy for a given layer — shifts hue progressively.
568
+ * Creates atmospheric color perspective (like distant mountains shifting blue).
569
+ */
570
+ export function evolveHierarchy(
571
+ base: ColorHierarchy,
572
+ layerRatio: number,
573
+ hueShiftPerLayer: number,
574
+ ): ColorHierarchy {
575
+ const shift = layerRatio * hueShiftPerLayer;
576
+ return {
577
+ dominant: hueRotate(base.dominant, shift),
578
+ secondary: hueRotate(base.secondary, shift * 0.7),
579
+ accent: hueRotate(base.accent, shift * 0.5),
580
+ all: base.all.map(c => hueRotate(c, shift * 0.6)),
581
+ };
582
+ }
@@ -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 {
@@ -89,6 +91,10 @@ interface EnhanceShapeConfig extends DrawShapeConfig {
89
91
  renderStyle?: RenderStyle;
90
92
  /** RNG for watercolor jitter (required for "watercolor" style). */
91
93
  rng?: () => number;
94
+ /** Light direction angle in radians — used for shadow & highlight. */
95
+ lightAngle?: number;
96
+ /** Scale factor for resolution-independent sizing. */
97
+ scaleFactor?: number;
92
98
  }
93
99
 
94
100
  export function drawShape(
@@ -207,6 +213,28 @@ function applyRenderStyle(
207
213
  ctx.fillStyle = origFill;
208
214
  ctx.restore();
209
215
 
216
+ // Pass 4: Organic edge erosion — irregular bites along the boundary
217
+ if (rng && size > 20) {
218
+ const erosionBites = 6 + Math.floor(rng() * 8);
219
+ const edgeRadius = size * 0.45;
220
+ ctx.save();
221
+ ctx.globalCompositeOperation = "destination-out";
222
+ ctx.globalAlpha = 0.6 + rng() * 0.3;
223
+ for (let eb = 0; eb < erosionBites; eb++) {
224
+ const biteAngle = rng() * Math.PI * 2;
225
+ const biteDist = edgeRadius * (0.85 + rng() * 0.25);
226
+ const biteR = size * (0.02 + rng() * 0.04);
227
+ ctx.beginPath();
228
+ ctx.arc(
229
+ Math.cos(biteAngle) * biteDist,
230
+ Math.sin(biteAngle) * biteDist,
231
+ biteR, 0, Math.PI * 2,
232
+ );
233
+ ctx.fill();
234
+ }
235
+ ctx.restore();
236
+ }
237
+
210
238
  ctx.globalAlpha = savedAlpha;
211
239
  // Soft stroke on top — thinner than normal for delicacy
212
240
  ctx.globalAlpha *= 0.25;
@@ -506,6 +534,57 @@ function applyRenderStyle(
506
534
  break;
507
535
  }
508
536
 
537
+ case "hand-drawn": {
538
+ // Wobbly hand-drawn edge treatment — fill normally, then redraw
539
+ // the outline with perturbed control points for a sketchy feel
540
+ const savedAlphaHD = ctx.globalAlpha;
541
+ ctx.globalAlpha = savedAlphaHD * 0.85;
542
+ ctx.fill();
543
+ ctx.globalAlpha = savedAlphaHD;
544
+
545
+ // Draw 2-3 slightly offset wobbly strokes for a sketchy look
546
+ const wobblePasses = 2 + (rng ? Math.floor(rng() * 2) : 0);
547
+ ctx.lineWidth = strokeWidth * 0.8;
548
+ for (let wp = 0; wp < wobblePasses; wp++) {
549
+ ctx.globalAlpha = savedAlphaHD * (0.4 - wp * 0.1);
550
+ ctx.save();
551
+ // Slight random offset per pass
552
+ const wobbleX = rng ? (rng() - 0.5) * size * 0.02 : 0;
553
+ const wobbleY = rng ? (rng() - 0.5) * size * 0.02 : 0;
554
+ ctx.translate(wobbleX, wobbleY);
555
+ // Slightly different scale per pass for edge variation
556
+ const wobbleScale = 1 + (rng ? (rng() - 0.5) * 0.03 : 0);
557
+ ctx.scale(wobbleScale, wobbleScale);
558
+ ctx.stroke();
559
+ ctx.restore();
560
+ }
561
+
562
+ // Organic edge erosion — small irregular bites for rough paper feel
563
+ if (rng && size > 20) {
564
+ const erosionBites = 4 + Math.floor(rng() * 6);
565
+ const edgeRadius = size * 0.42;
566
+ ctx.save();
567
+ ctx.globalCompositeOperation = "destination-out";
568
+ ctx.globalAlpha = 0.5 + rng() * 0.3;
569
+ for (let eb = 0; eb < erosionBites; eb++) {
570
+ const biteAngle = rng() * Math.PI * 2;
571
+ const biteDist = edgeRadius * (0.9 + rng() * 0.2);
572
+ const biteR = size * (0.015 + rng() * 0.03);
573
+ ctx.beginPath();
574
+ ctx.arc(
575
+ Math.cos(biteAngle) * biteDist,
576
+ Math.sin(biteAngle) * biteDist,
577
+ biteR, 0, Math.PI * 2,
578
+ );
579
+ ctx.fill();
580
+ }
581
+ ctx.restore();
582
+ }
583
+
584
+ ctx.globalAlpha = savedAlphaHD;
585
+ break;
586
+ }
587
+
509
588
  case "fill-and-stroke":
510
589
  default:
511
590
  ctx.fill();
@@ -540,14 +619,24 @@ export function enhanceShapeGeneration(
540
619
  gradientFillEnd,
541
620
  renderStyle = "fill-and-stroke",
542
621
  rng,
622
+ lightAngle,
623
+ scaleFactor = 1,
543
624
  } = config;
544
625
 
545
626
  ctx.save();
546
627
  ctx.translate(x, y);
547
628
  ctx.rotate((rotation * Math.PI) / 180);
548
629
 
549
- // Glow / shadow effect
550
- if (glowRadius > 0) {
630
+ // ── Drop shadow — soft colored shadow offset along light direction ──
631
+ if (lightAngle !== undefined && size > 10) {
632
+ const shadowDist = size * 0.035;
633
+ const shadowBlurR = size * 0.06;
634
+ ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
635
+ ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
636
+ ctx.shadowBlur = shadowBlurR;
637
+ ctx.shadowColor = "rgba(0,0,0,0.12)";
638
+ } else if (glowRadius > 0) {
639
+ // Glow / shadow effect (legacy path)
551
640
  ctx.shadowBlur = glowRadius;
552
641
  ctx.shadowColor = glowColor || fillColor;
553
642
  ctx.shadowOffsetX = 0;
@@ -573,9 +662,29 @@ export function enhanceShapeGeneration(
573
662
  applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
574
663
  }
575
664
 
576
- // Reset shadow so patterns aren't double-glowed
577
- if (glowRadius > 0) {
578
- ctx.shadowBlur = 0;
665
+ // Reset shadow so patterns and highlight aren't double-shadowed
666
+ ctx.shadowBlur = 0;
667
+ ctx.shadowOffsetX = 0;
668
+ ctx.shadowOffsetY = 0;
669
+ ctx.shadowColor = "transparent";
670
+
671
+ // ── Specular highlight — bright arc on the light-facing side ──
672
+ if (lightAngle !== undefined && size > 15 && rng) {
673
+ const hlRadius = size * 0.35;
674
+ const hlDist = size * 0.15;
675
+ const hlX = Math.cos(lightAngle) * hlDist;
676
+ const hlY = Math.sin(lightAngle) * hlDist;
677
+ const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
678
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
679
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
680
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
681
+ const savedOp = ctx.globalCompositeOperation;
682
+ ctx.globalCompositeOperation = "soft-light";
683
+ ctx.fillStyle = hlGrad;
684
+ ctx.beginPath();
685
+ ctx.arc(hlX, hlY, hlRadius, 0, Math.PI * 2);
686
+ ctx.fill();
687
+ ctx.globalCompositeOperation = savedOp;
579
688
  }
580
689
 
581
690
  // Layer additional patterns if specified
@@ -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,