git-hash-art 0.10.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-hash-art",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -27,10 +27,21 @@ 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
 
37
+ export type CompositionMode =
38
+ | "radial"
39
+ | "flow-field"
40
+ | "spiral"
41
+ | "grid-subdivision"
42
+ | "clustered"
43
+ | "golden-spiral";
44
+
34
45
  export interface Archetype {
35
46
  name: string;
36
47
  /** Override gridSize (controls shape count) */
@@ -51,6 +62,8 @@ export interface Archetype {
51
62
  paletteMode: PaletteMode;
52
63
  /** Preferred render styles (weighted toward these) */
53
64
  preferredStyles: RenderStyle[];
65
+ /** Preferred composition modes (70% chance of using one of these) */
66
+ preferredCompositions: CompositionMode[];
54
67
  /** Flow line count multiplier (1 = default) */
55
68
  flowLineMultiplier: number;
56
69
  /** Whether to draw the hero shape */
@@ -77,6 +90,7 @@ const ARCHETYPES: Archetype[] = [
77
90
  backgroundStyle: "radial-dark",
78
91
  paletteMode: "harmonious",
79
92
  preferredStyles: ["fill-and-stroke", "watercolor", "fill-only"],
93
+ preferredCompositions: ["clustered", "flow-field", "radial"],
80
94
  flowLineMultiplier: 2.5,
81
95
  heroShape: false,
82
96
  glowMultiplier: 0.5,
@@ -94,6 +108,7 @@ const ARCHETYPES: Archetype[] = [
94
108
  backgroundStyle: "solid-light",
95
109
  paletteMode: "duotone",
96
110
  preferredStyles: ["fill-and-stroke", "stroke-only", "incomplete"],
111
+ preferredCompositions: ["golden-spiral", "grid-subdivision"],
97
112
  flowLineMultiplier: 0.3,
98
113
  heroShape: true,
99
114
  glowMultiplier: 0,
@@ -111,6 +126,7 @@ const ARCHETYPES: Archetype[] = [
111
126
  backgroundStyle: "radial-dark",
112
127
  paletteMode: "earth",
113
128
  preferredStyles: ["watercolor", "fill-only", "incomplete"],
129
+ preferredCompositions: ["flow-field", "golden-spiral", "spiral"],
114
130
  flowLineMultiplier: 4,
115
131
  heroShape: false,
116
132
  glowMultiplier: 0.3,
@@ -128,6 +144,7 @@ const ARCHETYPES: Archetype[] = [
128
144
  backgroundStyle: "solid-dark",
129
145
  paletteMode: "high-contrast",
130
146
  preferredStyles: ["stroke-only", "dashed", "double-stroke", "hatched"],
147
+ preferredCompositions: ["grid-subdivision", "radial"],
131
148
  flowLineMultiplier: 0,
132
149
  heroShape: false,
133
150
  glowMultiplier: 0,
@@ -145,6 +162,7 @@ const ARCHETYPES: Archetype[] = [
145
162
  backgroundStyle: "radial-light",
146
163
  paletteMode: "pastel-light",
147
164
  preferredStyles: ["watercolor", "incomplete", "fill-only"],
165
+ preferredCompositions: ["golden-spiral", "radial", "spiral"],
148
166
  flowLineMultiplier: 1.5,
149
167
  heroShape: true,
150
168
  glowMultiplier: 2,
@@ -162,6 +180,7 @@ const ARCHETYPES: Archetype[] = [
162
180
  backgroundStyle: "linear-diagonal",
163
181
  paletteMode: "duotone",
164
182
  preferredStyles: ["fill-and-stroke", "double-stroke"],
183
+ preferredCompositions: ["grid-subdivision", "golden-spiral"],
165
184
  flowLineMultiplier: 0,
166
185
  heroShape: true,
167
186
  glowMultiplier: 0,
@@ -179,6 +198,7 @@ const ARCHETYPES: Archetype[] = [
179
198
  backgroundStyle: "solid-dark",
180
199
  paletteMode: "neon",
181
200
  preferredStyles: ["stroke-only", "double-stroke", "dashed"],
201
+ preferredCompositions: ["radial", "spiral", "clustered"],
182
202
  flowLineMultiplier: 2,
183
203
  heroShape: true,
184
204
  glowMultiplier: 3,
@@ -196,6 +216,7 @@ const ARCHETYPES: Archetype[] = [
196
216
  backgroundStyle: "solid-light",
197
217
  paletteMode: "monochrome",
198
218
  preferredStyles: ["hatched", "incomplete", "stroke-only", "dashed"],
219
+ preferredCompositions: ["flow-field", "grid-subdivision", "clustered"],
199
220
  flowLineMultiplier: 1.5,
200
221
  heroShape: false,
201
222
  glowMultiplier: 0,
@@ -213,6 +234,7 @@ const ARCHETYPES: Archetype[] = [
213
234
  backgroundStyle: "radial-dark",
214
235
  paletteMode: "neon",
215
236
  preferredStyles: ["fill-only", "watercolor", "fill-and-stroke"],
237
+ preferredCompositions: ["radial", "spiral", "golden-spiral"],
216
238
  flowLineMultiplier: 3,
217
239
  heroShape: true,
218
240
  glowMultiplier: 2.5,
@@ -230,6 +252,7 @@ const ARCHETYPES: Archetype[] = [
230
252
  backgroundStyle: "radial-light",
231
253
  paletteMode: "harmonious",
232
254
  preferredStyles: ["watercolor", "fill-only", "incomplete"],
255
+ preferredCompositions: ["golden-spiral", "flow-field", "radial"],
233
256
  flowLineMultiplier: 0.5,
234
257
  heroShape: false,
235
258
  glowMultiplier: 0.3,
@@ -247,6 +270,7 @@ const ARCHETYPES: Archetype[] = [
247
270
  backgroundStyle: "solid-light",
248
271
  paletteMode: "high-contrast",
249
272
  preferredStyles: ["fill-and-stroke", "stroke-only", "dashed"],
273
+ preferredCompositions: ["grid-subdivision", "radial"],
250
274
  flowLineMultiplier: 0,
251
275
  heroShape: false,
252
276
  glowMultiplier: 0,
@@ -264,6 +288,7 @@ const ARCHETYPES: Archetype[] = [
264
288
  backgroundStyle: "solid-light",
265
289
  paletteMode: "duotone",
266
290
  preferredStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
291
+ preferredCompositions: ["grid-subdivision", "clustered"],
267
292
  flowLineMultiplier: 0,
268
293
  heroShape: true,
269
294
  glowMultiplier: 0,
@@ -281,6 +306,7 @@ const ARCHETYPES: Archetype[] = [
281
306
  backgroundStyle: "radial-dark",
282
307
  paletteMode: "harmonious",
283
308
  preferredStyles: ["fill-and-stroke", "watercolor", "fill-only"],
309
+ preferredCompositions: ["radial", "golden-spiral", "flow-field"],
284
310
  flowLineMultiplier: 1,
285
311
  heroShape: true,
286
312
  glowMultiplier: 1,
@@ -298,6 +324,7 @@ const ARCHETYPES: Archetype[] = [
298
324
  backgroundStyle: "solid-dark",
299
325
  paletteMode: "high-contrast",
300
326
  preferredStyles: ["fill-and-stroke", "stroke-only", "fill-only"],
327
+ preferredCompositions: ["clustered", "grid-subdivision", "radial"],
301
328
  flowLineMultiplier: 0,
302
329
  heroShape: false,
303
330
  glowMultiplier: 0.3,
@@ -315,6 +342,7 @@ const ARCHETYPES: Archetype[] = [
315
342
  backgroundStyle: "radial-light",
316
343
  paletteMode: "earth",
317
344
  preferredStyles: ["watercolor", "fill-only", "incomplete"],
345
+ preferredCompositions: ["flow-field", "golden-spiral", "spiral"],
318
346
  flowLineMultiplier: 3,
319
347
  heroShape: true,
320
348
  glowMultiplier: 0.2,
@@ -332,6 +360,7 @@ const ARCHETYPES: Archetype[] = [
332
360
  backgroundStyle: "solid-light",
333
361
  paletteMode: "monochrome",
334
362
  preferredStyles: ["stipple", "fill-only", "hatched"],
363
+ preferredCompositions: ["radial", "clustered", "flow-field"],
335
364
  flowLineMultiplier: 0,
336
365
  heroShape: false,
337
366
  glowMultiplier: 0,
@@ -349,6 +378,7 @@ const ARCHETYPES: Archetype[] = [
349
378
  backgroundStyle: "radial-dark",
350
379
  paletteMode: "neon",
351
380
  preferredStyles: ["fill-only", "watercolor", "stroke-only", "incomplete"],
381
+ preferredCompositions: ["spiral", "radial", "golden-spiral"],
352
382
  flowLineMultiplier: 2,
353
383
  heroShape: true,
354
384
  glowMultiplier: 2.5,
@@ -371,6 +401,7 @@ function lerpNum(a: number, b: number, t: number): number {
371
401
  function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
372
402
  // Merge preferred styles — unique union, primary archetype first
373
403
  const mergedStyles = [...new Set([...a.preferredStyles, ...b.preferredStyles])] as RenderStyle[];
404
+ const mergedCompositions = [...new Set([...a.preferredCompositions, ...b.preferredCompositions])] as CompositionMode[];
374
405
 
375
406
  return {
376
407
  name: `${a.name}+${b.name}`,
@@ -383,6 +414,7 @@ function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
383
414
  backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
384
415
  paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
385
416
  preferredStyles: mergedStyles,
417
+ preferredCompositions: mergedCompositions,
386
418
  flowLineMultiplier: lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
387
419
  heroShape: t < 0.5 ? a.heroShape : b.heroShape,
388
420
  glowMultiplier: lerpNum(a.glowMultiplier, b.glowMultiplier, t),
@@ -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":
@@ -344,14 +388,16 @@ export function buildColorHierarchy(colors: string[], rng: () => number): ColorH
344
388
  all: colors,
345
389
  };
346
390
  }
347
- // Pick dominant as the color closest to the palette's average hue
391
+ // Pick dominant as the color with the highest chroma (saturation × distance from gray)
392
+ // This selects the most visually prominent color rather than the average
348
393
  const hsls = colors.map((c) => hexToHsl(c));
349
- const avgHue = hsls.reduce((s, h) => s + h[0], 0) / hsls.length;
350
394
  let dominantIdx = 0;
351
- let minDist = 360;
395
+ let maxChroma = -1;
352
396
  for (let i = 0; i < hsls.length; i++) {
353
- const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
354
- if (d < minDist) { minDist = d; dominantIdx = i; }
397
+ // Chroma approximation: saturation × how far lightness is from 50% (gray)
398
+ const lightnessVibrancy = 1 - Math.abs(hsls[i][2] - 0.5) * 2; // peaks at L=0.5
399
+ const chroma = hsls[i][1] * lightnessVibrancy;
400
+ if (chroma > maxChroma) { maxChroma = chroma; dominantIdx = i; }
355
401
  }
356
402
  // Accent is the color most distant from dominant in hue
357
403
  let accentIdx = 0;
@@ -533,5 +579,6 @@ export function evolveHierarchy(
533
579
  dominant: hueRotate(base.dominant, shift),
534
580
  secondary: hueRotate(base.secondary, shift * 0.7),
535
581
  accent: hueRotate(base.accent, shift * 0.5),
582
+ all: base.all.map(c => hueRotate(c, shift * 0.6)),
536
583
  };
537
584
  }
@@ -91,6 +91,10 @@ interface EnhanceShapeConfig extends DrawShapeConfig {
91
91
  renderStyle?: RenderStyle;
92
92
  /** RNG for watercolor jitter (required for "watercolor" style). */
93
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;
94
98
  }
95
99
 
96
100
  export function drawShape(
@@ -209,6 +213,28 @@ function applyRenderStyle(
209
213
  ctx.fillStyle = origFill;
210
214
  ctx.restore();
211
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
+
212
238
  ctx.globalAlpha = savedAlpha;
213
239
  // Soft stroke on top — thinner than normal for delicacy
214
240
  ctx.globalAlpha *= 0.25;
@@ -532,6 +558,29 @@ function applyRenderStyle(
532
558
  ctx.stroke();
533
559
  ctx.restore();
534
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
+
535
584
  ctx.globalAlpha = savedAlphaHD;
536
585
  break;
537
586
  }
@@ -570,14 +619,24 @@ export function enhanceShapeGeneration(
570
619
  gradientFillEnd,
571
620
  renderStyle = "fill-and-stroke",
572
621
  rng,
622
+ lightAngle,
623
+ scaleFactor = 1,
573
624
  } = config;
574
625
 
575
626
  ctx.save();
576
627
  ctx.translate(x, y);
577
628
  ctx.rotate((rotation * Math.PI) / 180);
578
629
 
579
- // Glow / shadow effect
580
- 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)
581
640
  ctx.shadowBlur = glowRadius;
582
641
  ctx.shadowColor = glowColor || fillColor;
583
642
  ctx.shadowOffsetX = 0;
@@ -603,9 +662,39 @@ export function enhanceShapeGeneration(
603
662
  applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
604
663
  }
605
664
 
606
- // Reset shadow so patterns aren't double-glowed
607
- if (glowRadius > 0) {
608
- 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 — tinted 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
+ // Tint highlight warm/cool based on fill color for cohesion
679
+ // Parse fill to detect warmth — fallback to white for non-parseable
680
+ let hlBase = "255,255,255";
681
+ if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
682
+ const r = parseInt(fillColor.slice(1, 3), 16);
683
+ const g = parseInt(fillColor.slice(3, 5), 16);
684
+ const b = parseInt(fillColor.slice(5, 7), 16);
685
+ // Blend toward white but keep a hint of the fill's warmth
686
+ hlBase = `${Math.round(r * 0.15 + 255 * 0.85)},${Math.round(g * 0.15 + 255 * 0.85)},${Math.round(b * 0.15 + 255 * 0.85)}`;
687
+ }
688
+ hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
689
+ hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
690
+ hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
691
+ const savedOp = ctx.globalCompositeOperation;
692
+ ctx.globalCompositeOperation = "soft-light";
693
+ ctx.fillStyle = hlGrad;
694
+ ctx.beginPath();
695
+ ctx.arc(hlX, hlY, hlRadius, 0, Math.PI * 2);
696
+ ctx.fill();
697
+ ctx.globalCompositeOperation = savedOp;
609
698
  }
610
699
 
611
700
  // Layer additional patterns if specified