git-hash-art 0.6.0 → 0.8.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.6.0",
3
+ "version": "0.8.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -219,6 +219,57 @@ const ARCHETYPES: Archetype[] = [
219
219
  sizePower: 2.5,
220
220
  invertForeground: false,
221
221
  },
222
+ {
223
+ name: "watercolor-wash",
224
+ gridSize: 3,
225
+ layers: 3,
226
+ baseOpacity: 0.25,
227
+ opacityReduction: 0.03,
228
+ minShapeSize: 200,
229
+ maxShapeSize: 700,
230
+ backgroundStyle: "radial-light",
231
+ paletteMode: "harmonious",
232
+ preferredStyles: ["watercolor", "fill-only", "incomplete"],
233
+ flowLineMultiplier: 0.5,
234
+ heroShape: false,
235
+ glowMultiplier: 0.3,
236
+ sizePower: 0.6,
237
+ invertForeground: false,
238
+ },
239
+ {
240
+ name: "op-art",
241
+ gridSize: 8,
242
+ layers: 2,
243
+ baseOpacity: 0.95,
244
+ opacityReduction: 0.05,
245
+ minShapeSize: 20,
246
+ maxShapeSize: 200,
247
+ backgroundStyle: "solid-light",
248
+ paletteMode: "high-contrast",
249
+ preferredStyles: ["fill-and-stroke", "stroke-only", "dashed"],
250
+ flowLineMultiplier: 0,
251
+ heroShape: false,
252
+ glowMultiplier: 0,
253
+ sizePower: 0.4,
254
+ invertForeground: false,
255
+ },
256
+ {
257
+ name: "collage",
258
+ gridSize: 4,
259
+ layers: 3,
260
+ baseOpacity: 0.9,
261
+ opacityReduction: 0.08,
262
+ minShapeSize: 80,
263
+ maxShapeSize: 500,
264
+ backgroundStyle: "solid-light",
265
+ paletteMode: "duotone",
266
+ preferredStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
267
+ flowLineMultiplier: 0,
268
+ heroShape: true,
269
+ glowMultiplier: 0,
270
+ sizePower: 0.7,
271
+ invertForeground: false,
272
+ },
222
273
  {
223
274
  name: "classic",
224
275
  gridSize: 5,
@@ -325,9 +325,87 @@ export function hexWithAlpha(hex: string, alpha: number): string {
325
325
  }
326
326
 
327
327
  /**
328
- * Apply slight hue/saturation/lightness jitter to a hex color.
329
- * `rng` should return a float in [0,1). `amount` controls intensity (0-1, default 0.1).
328
+ * Color hierarchy — assigns dominant/secondary/accent roles to a palette.
329
+ * Dominant gets ~60% of usage, secondary ~25%, accent ~15%.
330
330
  */
331
+ export interface ColorHierarchy {
332
+ dominant: string;
333
+ secondary: string;
334
+ accent: string;
335
+ all: string[];
336
+ }
337
+
338
+ export function buildColorHierarchy(colors: string[], rng: () => number): ColorHierarchy {
339
+ if (colors.length < 3) {
340
+ return {
341
+ dominant: colors[0] || "#888888",
342
+ secondary: colors[1] || colors[0] || "#888888",
343
+ accent: colors[colors.length - 1] || "#888888",
344
+ all: colors,
345
+ };
346
+ }
347
+ // Pick dominant as the color closest to the palette's average hue
348
+ const hsls = colors.map((c) => hexToHsl(c));
349
+ const avgHue = hsls.reduce((s, h) => s + h[0], 0) / hsls.length;
350
+ let dominantIdx = 0;
351
+ let minDist = 360;
352
+ 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; }
355
+ }
356
+ // Accent is the color most distant from dominant in hue
357
+ let accentIdx = 0;
358
+ let maxDist = 0;
359
+ for (let i = 0; i < hsls.length; i++) {
360
+ if (i === dominantIdx) continue;
361
+ const d = Math.min(Math.abs(hsls[i][0] - hsls[dominantIdx][0]), 360 - Math.abs(hsls[i][0] - hsls[dominantIdx][0]));
362
+ if (d > maxDist) { maxDist = d; accentIdx = i; }
363
+ }
364
+ // Secondary is the remaining color with highest saturation
365
+ let secondaryIdx = 0;
366
+ let maxSat = -1;
367
+ for (let i = 0; i < hsls.length; i++) {
368
+ if (i === dominantIdx || i === accentIdx) continue;
369
+ if (hsls[i][1] > maxSat) { maxSat = hsls[i][1]; secondaryIdx = i; }
370
+ }
371
+ if (secondaryIdx === dominantIdx) secondaryIdx = accentIdx === 0 ? 1 : 0;
372
+
373
+ return {
374
+ dominant: colors[dominantIdx],
375
+ secondary: colors[secondaryIdx],
376
+ accent: colors[accentIdx],
377
+ all: colors,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Pick a color from the hierarchy with weighted probability.
383
+ * ~60% dominant, ~25% secondary, ~15% accent.
384
+ */
385
+ export function pickHierarchyColor(hierarchy: ColorHierarchy, rng: () => number): string {
386
+ const roll = rng();
387
+ if (roll < 0.60) return hierarchy.dominant;
388
+ if (roll < 0.85) return hierarchy.secondary;
389
+ return hierarchy.accent;
390
+ }
391
+
392
+ /**
393
+ * HSL-space color jitter — preserves vibrancy better than RGB jitter.
394
+ * Applies small hue wobble + saturation/lightness variation.
395
+ */
396
+ export function jitterColorHSL(
397
+ hex: string,
398
+ rng: () => number,
399
+ hueAmount = 8,
400
+ slAmount = 0.06,
401
+ ): string {
402
+ const [h, s, l] = hexToHsl(hex);
403
+ const newH = (h + (rng() - 0.5) * hueAmount * 2 + 360) % 360;
404
+ const newS = Math.max(0, Math.min(1, s + (rng() - 0.5) * slAmount * 2));
405
+ const newL = Math.max(0, Math.min(1, l + (rng() - 0.5) * slAmount * 2));
406
+ return hslToHex(newH, newS, newL);
407
+ }
408
+
331
409
  export function jitterColor(
332
410
  hex: string,
333
411
  rng: () => number,
@@ -357,3 +435,78 @@ export function shiftTemperature(hex: string, target: "warm" | "cool", amount: n
357
435
  const [h, s, l] = hexToHsl(hex);
358
436
  return hslToHex(shiftHueToward(h, target, amount), s, l);
359
437
  }
438
+
439
+ /**
440
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
441
+ * Uses the sRGB luminance formula from WCAG.
442
+ */
443
+ export function luminance(hex: string): number {
444
+ const [r, g, b] = hexToRgb(hex).map((c) => {
445
+ const s = c / 255;
446
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
447
+ });
448
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
449
+ }
450
+
451
+ /**
452
+ * Enforce minimum contrast between a foreground color and a background
453
+ * luminance. On light backgrounds, darkens/saturates the foreground.
454
+ * On dark backgrounds, lightens/saturates the foreground.
455
+ *
456
+ * `bgLuminance` is 0-1 (pre-computed from the background color).
457
+ * `minContrast` is the minimum luminance difference to enforce (default 0.15).
458
+ */
459
+ export function enforceContrast(
460
+ fgHex: string,
461
+ bgLuminance: number,
462
+ minContrast = 0.15,
463
+ ): string {
464
+ const fgLum = luminance(fgHex);
465
+ const diff = Math.abs(fgLum - bgLuminance);
466
+
467
+ if (diff >= minContrast) return fgHex;
468
+
469
+ const [h, s, l] = hexToHsl(fgHex);
470
+
471
+ if (bgLuminance > 0.5) {
472
+ // Light background — darken and boost saturation
473
+ const targetL = Math.max(0.08, l - (minContrast - diff) * 1.5);
474
+ const targetS = Math.min(1, s + 0.2);
475
+ return hslToHex(h, targetS, targetL);
476
+ } else {
477
+ // Dark background — lighten and boost saturation
478
+ const targetL = Math.min(0.92, l + (minContrast - diff) * 1.5);
479
+ const targetS = Math.min(1, s + 0.15);
480
+ return hslToHex(h, targetS, targetL);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Apply a unified color grade to a hex color — shifts the entire image
486
+ * toward a cohesive tone. This is the "Instagram filter" effect.
487
+ */
488
+ export function applyColorGrade(
489
+ hex: string,
490
+ gradeHue: number,
491
+ intensity: number,
492
+ ): string {
493
+ const [h, s, l] = hexToHsl(hex);
494
+ // Blend hue toward the grade hue
495
+ const hueDiff = ((gradeHue - h + 540) % 360) - 180;
496
+ const newH = (h + hueDiff * intensity * 0.3 + 360) % 360;
497
+ // Slightly unify saturation
498
+ const newS = Math.max(0, Math.min(1, s + (0.5 - s) * intensity * 0.15));
499
+ return hslToHex(newH, newS, l);
500
+ }
501
+
502
+ /**
503
+ * Compute a deterministic color grade from the hash.
504
+ * Returns a hue (0-360) and intensity (0.15-0.4).
505
+ */
506
+ export function pickColorGrade(rng: () => number): { hue: number; intensity: number } {
507
+ // Warm golden, cool blue, rosy, teal, amber
508
+ const GRADE_HUES = [40, 220, 340, 175, 30];
509
+ const hue = GRADE_HUES[Math.floor(rng() * GRADE_HUES.length)] + (rng() - 0.5) * 20;
510
+ const intensity = 0.15 + rng() * 0.25;
511
+ return { hue: (hue + 360) % 360, intensity };
512
+ }
@@ -151,23 +151,56 @@ function applyRenderStyle(
151
151
  break;
152
152
 
153
153
  case "watercolor": {
154
- // Draw 3-4 slightly offset passes at low opacity for a bleed effect
155
- const passes = 3 + (rng ? Math.floor(rng() * 2) : 0);
154
+ // Improved watercolor: edge darkening + radial bleed + layered washes
155
+ const passes = 4 + (rng ? Math.floor(rng() * 2) : 0);
156
156
  const savedAlpha = ctx.globalAlpha;
157
- ctx.globalAlpha = savedAlpha * (0.3 / passes * 2);
157
+
158
+ // Pass 1: Base wash — large, soft fill at low opacity
159
+ ctx.globalAlpha = savedAlpha * 0.15;
160
+ ctx.save();
161
+ const baseScale = 1.08 + (rng ? rng() * 0.04 : 0);
162
+ ctx.scale(baseScale, baseScale);
163
+ ctx.fill();
164
+ ctx.restore();
165
+
166
+ // Pass 2: Multiple offset washes with radial displacement
167
+ ctx.globalAlpha = savedAlpha * (0.25 / passes * 2);
158
168
  for (let p = 0; p < passes; p++) {
159
- const jx = rng ? (rng() - 0.5) * size * 0.06 : 0;
160
- const jy = rng ? (rng() - 0.5) * size * 0.06 : 0;
169
+ // Radial outward displacement (not uniform) for organic bleed
170
+ const angle = rng ? rng() * Math.PI * 2 : p * Math.PI / 2;
171
+ const dist = rng ? rng() * size * 0.05 : size * 0.02;
172
+ const jx = Math.cos(angle) * dist;
173
+ const jy = Math.sin(angle) * dist;
161
174
  ctx.save();
162
175
  ctx.translate(jx, jy);
163
176
  ctx.fill();
164
177
  ctx.restore();
165
178
  }
179
+
180
+ // Pass 3: Edge darkening — draw a slightly smaller shape with lighter fill
181
+ // to simulate pigment pooling at boundaries
182
+ ctx.globalAlpha = savedAlpha * 0.35;
183
+ ctx.save();
184
+ const innerScale = 0.85 + (rng ? rng() * 0.08 : 0);
185
+ ctx.scale(innerScale, innerScale);
186
+ // Lighten the fill for the inner area
187
+ const origFill = ctx.fillStyle;
188
+ if (typeof fillColor === "string") {
189
+ ctx.fillStyle = fillColor.replace(/[\d.]+\)$/, (m) => {
190
+ const v = parseFloat(m);
191
+ return Math.min(1, v * 1.4).toFixed(2) + ")";
192
+ });
193
+ }
194
+ ctx.fill();
195
+ ctx.fillStyle = origFill;
196
+ ctx.restore();
197
+
166
198
  ctx.globalAlpha = savedAlpha;
167
- // Light stroke on top
168
- ctx.globalAlpha *= 0.4;
199
+ // Soft stroke on top — thinner than normal for delicacy
200
+ ctx.globalAlpha *= 0.25;
201
+ ctx.lineWidth = strokeWidth * 0.6;
169
202
  ctx.stroke();
170
- ctx.globalAlpha /= 0.4;
203
+ ctx.globalAlpha /= 0.25;
171
204
  break;
172
205
  }
173
206
 
@@ -304,7 +337,7 @@ export function enhanceShapeGeneration(
304
337
 
305
338
  const drawFunction = shapes[shape];
306
339
  if (drawFunction) {
307
- drawFunction(ctx, size);
340
+ drawFunction(ctx, size, { rng });
308
341
  applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
309
342
  }
310
343