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/.github/workflows/deploy-www.yml +47 -0
- package/ALGORITHM.md +212 -14
- package/CHANGELOG.md +18 -0
- package/dist/browser.js +758 -23
- package/dist/browser.js.map +1 -1
- package/dist/main.js +758 -23
- package/dist/main.js.map +1 -1
- package/dist/module.js +758 -23
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +51 -4
- package/src/lib/canvas/colors.ts +70 -0
- package/src/lib/canvas/draw.ts +115 -6
- package/src/lib/canvas/shapes/affinity.ts +3 -3
- package/src/lib/render.ts +423 -17
- package/src/lib/utils.ts +109 -0
package/package.json
CHANGED
package/src/lib/archetypes.ts
CHANGED
|
@@ -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"
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -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
|
+
}
|
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 {
|
|
@@ -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
|
-
//
|
|
550
|
-
if (
|
|
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-
|
|
577
|
-
|
|
578
|
-
|
|
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,
|