git-hash-art 0.11.0 → 0.12.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/dist/browser.js CHANGED
@@ -508,13 +508,21 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
508
508
  }
509
509
  }
510
510
  // ── Standalone color utilities ──────────────────────────────────────
511
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $b5a262d09b87e373$var$hexToRgb(hex) {
512
- const c = hex.replace("#", "");
513
- return [
511
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
512
+ const $b5a262d09b87e373$var$_rgbCache = new Map();
513
+ const $b5a262d09b87e373$var$_RGB_CACHE_MAX = 512;
514
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $b5a262d09b87e373$var$hexToRgb(hex) {
515
+ let cached = $b5a262d09b87e373$var$_rgbCache.get(hex);
516
+ if (cached) return cached;
517
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
518
+ cached = [
514
519
  parseInt(c.substring(0, 2), 16),
515
520
  parseInt(c.substring(2, 4), 16),
516
521
  parseInt(c.substring(4, 6), 16)
517
522
  ];
523
+ if ($b5a262d09b87e373$var$_rgbCache.size >= $b5a262d09b87e373$var$_RGB_CACHE_MAX) $b5a262d09b87e373$var$_rgbCache.clear();
524
+ $b5a262d09b87e373$var$_rgbCache.set(hex, cached);
525
+ return cached;
518
526
  }
519
527
  /** Format [r, g, b] back to #RRGGBB. */ function $b5a262d09b87e373$var$rgbToHex(r, g, b) {
520
528
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -571,7 +579,9 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
571
579
  }
572
580
  function $b5a262d09b87e373$export$f2121afcad3d553f(hex, alpha) {
573
581
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex);
574
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
582
+ // Quantize alpha to 3 decimal places without toFixed overhead
583
+ const a = Math.round(alpha * 1000) / 1000;
584
+ return `rgba(${r},${g},${b},${a})`;
575
585
  }
576
586
  function $b5a262d09b87e373$export$fabac4600b87056(colors, rng) {
577
587
  if (colors.length < 3) return {
@@ -651,12 +661,21 @@ function $b5a262d09b87e373$export$51ea55f869b7e0d3(hex, target, amount) {
651
661
  const [h, s, l] = $b5a262d09b87e373$var$hexToHsl(hex);
652
662
  return $b5a262d09b87e373$var$hslToHex($b5a262d09b87e373$var$shiftHueToward(h, target, amount), s, l);
653
663
  }
664
+ /**
665
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
666
+ * Uses the sRGB luminance formula from WCAG. Cached.
667
+ */ const $b5a262d09b87e373$var$_lumCache = new Map();
654
668
  function $b5a262d09b87e373$export$5c6e3c2b59b7fbbe(hex) {
669
+ let cached = $b5a262d09b87e373$var$_lumCache.get(hex);
670
+ if (cached !== undefined) return cached;
655
671
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex).map((c)=>{
656
672
  const s = c / 255;
657
673
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
658
674
  });
659
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
675
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
676
+ if ($b5a262d09b87e373$var$_lumCache.size >= 512) $b5a262d09b87e373$var$_lumCache.clear();
677
+ $b5a262d09b87e373$var$_lumCache.set(hex, cached);
678
+ return cached;
660
679
  }
661
680
  function $b5a262d09b87e373$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
662
681
  const fgLum = $b5a262d09b87e373$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1092,21 +1111,31 @@ const $f0f1a7293548e501$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1092
1111
  (0, $ce2c52df8af02e62$export$e46c5570db033611)(ctx, size, finalConfig);
1093
1112
  const gridSize = 8;
1094
1113
  const unit = size / gridSize;
1114
+ const radius = unit / 2;
1115
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1116
+ const starPoints = [];
1117
+ for(let k = 0; k < 8; k++){
1118
+ const angle = Math.PI / 4 * k;
1119
+ const angle2 = angle + Math.PI / 4;
1120
+ starPoints.push({
1121
+ c1: Math.cos(angle) * radius,
1122
+ s1: Math.sin(angle) * radius,
1123
+ c2: Math.cos(angle2) * radius,
1124
+ s2: Math.sin(angle2) * radius
1125
+ });
1126
+ }
1095
1127
  ctx.beginPath();
1096
1128
  // Create base grid
1097
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1129
+ for(let i = 0; i <= gridSize; i++){
1098
1130
  const x = (i - gridSize / 2) * unit;
1099
- const y = (j - gridSize / 2) * unit;
1100
- // Draw star pattern at each intersection
1101
- const radius = unit / 2;
1102
- for(let k = 0; k < 8; k++){
1103
- const angle = Math.PI / 4 * k;
1104
- const x1 = x + radius * Math.cos(angle);
1105
- const y1 = y + radius * Math.sin(angle);
1106
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1107
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1108
- ctx.moveTo(x1, y1);
1109
- ctx.lineTo(x2, y2);
1131
+ for(let j = 0; j <= gridSize; j++){
1132
+ const y = (j - gridSize / 2) * unit;
1133
+ // Draw star pattern at each intersection using pre-computed offsets
1134
+ for(let k = 0; k < 8; k++){
1135
+ const sp = starPoints[k];
1136
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1137
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1138
+ }
1110
1139
  }
1111
1140
  }
1112
1141
  ctx.stroke();
@@ -1426,20 +1455,23 @@ const $77711f013715e6da$export$eeae7765f05012e2 = (ctx, size)=>{
1426
1455
  const $77711f013715e6da$export$3355220a8108efc3 = (ctx, size)=>{
1427
1456
  const outerRadius = size / 2;
1428
1457
  const innerRadius = size / 4;
1429
- const steps = 36;
1458
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1459
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1460
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1461
+ const TWO_PI = Math.PI * 2;
1462
+ const angleStep = TWO_PI / steps;
1430
1463
  ctx.beginPath();
1431
1464
  for(let i = 0; i < steps; i++){
1432
- const angle1 = i / steps * Math.PI * 2;
1433
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1465
+ const angle1 = i * angleStep;
1466
+ const cosA = Math.cos(angle1);
1467
+ const sinA = Math.sin(angle1);
1434
1468
  for(let j = 0; j < steps; j++){
1435
- const phi1 = j / steps * Math.PI * 2;
1436
- const phi2 = (j + 1) / steps * Math.PI * 2;
1437
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1438
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1439
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1440
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1441
- ctx.moveTo(x1, y1);
1442
- ctx.lineTo(x2, y2);
1469
+ const phi1 = j * angleStep;
1470
+ const phi2 = phi1 + angleStep;
1471
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1472
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1473
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1474
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1443
1475
  }
1444
1476
  }
1445
1477
  };
@@ -2002,6 +2034,43 @@ const $e0f99502ff383dd8$var$RENDER_STYLES = [
2002
2034
  function $e0f99502ff383dd8$export$9fd4e64b2acd410e(rng) {
2003
2035
  return $e0f99502ff383dd8$var$RENDER_STYLES[Math.floor(rng() * $e0f99502ff383dd8$var$RENDER_STYLES.length)];
2004
2036
  }
2037
+ const $e0f99502ff383dd8$export$2f738f61a8c15e07 = {
2038
+ "fill-and-stroke": 1,
2039
+ "fill-only": 0.5,
2040
+ "stroke-only": 1,
2041
+ "double-stroke": 1.5,
2042
+ "dashed": 1,
2043
+ "watercolor": 7,
2044
+ "hatched": 3,
2045
+ "incomplete": 1,
2046
+ "stipple": 90,
2047
+ "stencil": 2,
2048
+ "noise-grain": 400,
2049
+ "wood-grain": 10,
2050
+ "marble-vein": 4,
2051
+ "fabric-weave": 6,
2052
+ "hand-drawn": 5
2053
+ };
2054
+ function $e0f99502ff383dd8$export$909ab0580e273f19(style) {
2055
+ switch(style){
2056
+ case "noise-grain":
2057
+ return "hatched";
2058
+ case "stipple":
2059
+ return "dashed";
2060
+ case "wood-grain":
2061
+ return "hatched";
2062
+ case "watercolor":
2063
+ return "fill-and-stroke";
2064
+ case "fabric-weave":
2065
+ return "hatched";
2066
+ case "hand-drawn":
2067
+ return "fill-and-stroke";
2068
+ case "marble-vein":
2069
+ return "stroke-only";
2070
+ default:
2071
+ return style;
2072
+ }
2073
+ }
2005
2074
  function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2006
2075
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2007
2076
  ctx.save();
@@ -2121,6 +2190,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2121
2190
  case "hatched":
2122
2191
  {
2123
2192
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2193
+ // Optimized: batch all parallel lines into a single path per pass
2124
2194
  const savedAlphaH = ctx.globalAlpha;
2125
2195
  ctx.globalAlpha = savedAlphaH * 0.3;
2126
2196
  ctx.fill();
@@ -2132,28 +2202,28 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2132
2202
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2133
2203
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2134
2204
  ctx.globalAlpha = savedAlphaH * 0.6;
2135
- // Draw parallel lines across the bounding box
2205
+ // Draw parallel lines across the bounding box — batched into single path
2136
2206
  const extent = size * 0.8;
2137
2207
  const cos = Math.cos(hatchAngle);
2138
2208
  const sin = Math.sin(hatchAngle);
2209
+ ctx.beginPath();
2139
2210
  for(let d = -extent; d <= extent; d += hatchSpacing){
2140
- ctx.beginPath();
2141
2211
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2142
2212
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2143
- ctx.stroke();
2144
2213
  }
2214
+ ctx.stroke();
2145
2215
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2146
2216
  if (!rng || rng() < 0.5) {
2147
2217
  const crossAngle = hatchAngle + Math.PI / 2;
2148
2218
  const cos2 = Math.cos(crossAngle);
2149
2219
  const sin2 = Math.sin(crossAngle);
2150
2220
  ctx.globalAlpha = savedAlphaH * 0.35;
2221
+ ctx.beginPath();
2151
2222
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2152
- ctx.beginPath();
2153
2223
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2154
2224
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2155
- ctx.stroke();
2156
2225
  }
2226
+ ctx.stroke();
2157
2227
  }
2158
2228
  ctx.restore();
2159
2229
  ctx.globalAlpha = savedAlphaH;
@@ -2191,6 +2261,8 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2191
2261
  case "stipple":
2192
2262
  {
2193
2263
  // Dot-fill texture — clip to shape, then scatter dots
2264
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2265
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2194
2266
  const savedAlphaS = ctx.globalAlpha;
2195
2267
  ctx.globalAlpha = savedAlphaS * 0.15;
2196
2268
  ctx.fill(); // ghost fill
@@ -2198,16 +2270,20 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2198
2270
  ctx.save();
2199
2271
  ctx.clip();
2200
2272
  const dotSpacing = Math.max(2, size * 0.03);
2201
- const extent = size * 0.55;
2273
+ const extentS = size * 0.55;
2274
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2275
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2276
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2202
2277
  ctx.globalAlpha = savedAlphaS * 0.7;
2203
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2204
- // Jitter each dot position for organic feel
2205
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2206
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2207
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2208
- ctx.beginPath();
2209
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2210
- ctx.fill();
2278
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2279
+ const dx = -extentS + xi * actualSpacing;
2280
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2281
+ const dy = -extentS + yi * actualSpacing;
2282
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2283
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2284
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2285
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2286
+ }
2211
2287
  }
2212
2288
  ctx.restore();
2213
2289
  ctx.globalAlpha = savedAlphaS;
@@ -2240,6 +2316,9 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2240
2316
  case "noise-grain":
2241
2317
  {
2242
2318
  // Procedural noise grain texture clipped to shape boundary
2319
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2320
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2321
+ // and batch dots by brightness (black/white) × alpha bucket
2243
2322
  const savedAlphaN = ctx.globalAlpha;
2244
2323
  ctx.globalAlpha = savedAlphaN * 0.25;
2245
2324
  ctx.fill(); // base tint
@@ -2248,17 +2327,47 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2248
2327
  ctx.clip();
2249
2328
  const grainSpacing = Math.max(1.5, size * 0.015);
2250
2329
  const extentN = size * 0.55;
2251
- ctx.globalAlpha = savedAlphaN * 0.6;
2252
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2253
- if (!rng) break;
2254
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2255
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2256
- const brightness = rng() > 0.5 ? 255 : 0;
2257
- const dotAlpha = 0.15 + rng() * 0.35;
2258
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2259
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2260
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2261
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2330
+ if (rng) {
2331
+ // Cap grid to max 40 dots per axis beyond this the grain is
2332
+ // visually indistinguishable but cost scales quadratically.
2333
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2334
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2335
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2336
+ const BUCKETS = 4;
2337
+ const bucketMin = 0.15;
2338
+ const bucketRange = 0.35;
2339
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2340
+ const buckets = [];
2341
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2342
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2343
+ const gx = -extentN + xi * actualGrainSpacing;
2344
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2345
+ const gy = -extentN + yi * actualGrainSpacing;
2346
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2347
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2348
+ const isWhite = rng() > 0.5;
2349
+ const dotAlpha = bucketMin + rng() * bucketRange;
2350
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2351
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2352
+ const offset = isWhite ? BUCKETS : 0;
2353
+ buckets[offset + bucketIdx].push({
2354
+ x: gx + jx,
2355
+ y: gy + jy,
2356
+ s: dotSize
2357
+ });
2358
+ }
2359
+ }
2360
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2361
+ for(let color = 0; color < 2; color++){
2362
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2363
+ for(let b = 0; b < BUCKETS; b++){
2364
+ const dots = buckets[color * BUCKETS + b];
2365
+ if (dots.length === 0) continue;
2366
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2367
+ ctx.globalAlpha = savedAlphaN * alpha;
2368
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2369
+ }
2370
+ }
2262
2371
  }
2263
2372
  ctx.restore();
2264
2373
  ctx.fillStyle = fillColor;
@@ -2271,6 +2380,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2271
2380
  case "wood-grain":
2272
2381
  {
2273
2382
  // Parallel wavy lines simulating wood grain, clipped to shape
2383
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2274
2384
  const savedAlphaW = ctx.globalAlpha;
2275
2385
  ctx.globalAlpha = savedAlphaW * 0.2;
2276
2386
  ctx.fill(); // base tint
@@ -2286,17 +2396,19 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2286
2396
  ctx.globalAlpha = savedAlphaW * 0.5;
2287
2397
  const cosG = Math.cos(grainAngle);
2288
2398
  const sinG = Math.sin(grainAngle);
2399
+ const waveCoeff = waveFreq * Math.PI;
2400
+ const invExtentW = 1 / extentW;
2401
+ // Batch all grain lines into a single path
2402
+ ctx.beginPath();
2289
2403
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2290
- ctx.beginPath();
2291
- for(let t = -extentW; t <= extentW; t += 2){
2292
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2293
- const px = t * cosG - (d + wave) * sinG;
2294
- const py = t * sinG + (d + wave) * cosG;
2295
- if (t === -extentW) ctx.moveTo(px, py);
2296
- else ctx.lineTo(px, py);
2404
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2405
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2406
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2407
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2408
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2297
2409
  }
2298
- ctx.stroke();
2299
2410
  }
2411
+ ctx.stroke();
2300
2412
  ctx.restore();
2301
2413
  ctx.globalAlpha = savedAlphaW;
2302
2414
  ctx.globalAlpha *= 0.35;
@@ -2358,6 +2470,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2358
2470
  case "fabric-weave":
2359
2471
  {
2360
2472
  // Interlocking horizontal/vertical threads clipped to shape
2473
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2361
2474
  const savedAlphaF = ctx.globalAlpha;
2362
2475
  ctx.globalAlpha = savedAlphaF * 0.15;
2363
2476
  ctx.fill(); // ghost base
@@ -2367,26 +2480,24 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2367
2480
  const threadSpacing = Math.max(2, size * 0.04);
2368
2481
  const extentF = size * 0.55;
2369
2482
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2483
+ // Horizontal threads — batched
2370
2484
  ctx.globalAlpha = savedAlphaF * 0.55;
2371
- // Horizontal threads
2485
+ ctx.beginPath();
2372
2486
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2373
- ctx.beginPath();
2374
2487
  ctx.moveTo(-extentF, y);
2375
2488
  ctx.lineTo(extentF, y);
2376
- ctx.stroke();
2377
2489
  }
2378
- // Vertical threads (offset by half spacing for weave effect)
2490
+ ctx.stroke();
2491
+ // Vertical threads (offset by half spacing for weave effect) — batched
2379
2492
  ctx.globalAlpha = savedAlphaF * 0.45;
2380
2493
  ctx.strokeStyle = fillColor;
2381
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2382
- ctx.beginPath();
2383
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2384
- // Over-under: draw segment, skip segment
2385
- ctx.moveTo(x, y);
2386
- ctx.lineTo(x, y + threadSpacing);
2387
- }
2388
- ctx.stroke();
2494
+ ctx.beginPath();
2495
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2496
+ // Over-under: draw segment, skip segment
2497
+ ctx.moveTo(x, y);
2498
+ ctx.lineTo(x, y + threadSpacing);
2389
2499
  }
2500
+ ctx.stroke();
2390
2501
  ctx.strokeStyle = strokeColor;
2391
2502
  ctx.restore();
2392
2503
  ctx.globalAlpha = savedAlphaF;
@@ -2452,14 +2563,17 @@ function $e0f99502ff383dd8$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2452
2563
  ctx.translate(x, y);
2453
2564
  ctx.rotate(rotation * Math.PI / 180);
2454
2565
  // ── Drop shadow — soft colored shadow offset along light direction ──
2455
- if (lightAngle !== undefined && size > 10) {
2566
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2567
+ // and visually imperceptible at that scale.
2568
+ const useShadow = size >= 20;
2569
+ if (useShadow && lightAngle !== undefined) {
2456
2570
  const shadowDist = size * 0.035;
2457
2571
  const shadowBlurR = size * 0.06;
2458
2572
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2459
2573
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2460
2574
  ctx.shadowBlur = shadowBlurR;
2461
2575
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2462
- } else if (glowRadius > 0) {
2576
+ } else if (useShadow && glowRadius > 0) {
2463
2577
  // Glow / shadow effect (legacy path)
2464
2578
  ctx.shadowBlur = glowRadius;
2465
2579
  ctx.shadowColor = glowColor || fillColor;
@@ -2483,30 +2597,27 @@ function $e0f99502ff383dd8$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2483
2597
  $e0f99502ff383dd8$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2484
2598
  }
2485
2599
  // Reset shadow so patterns and highlight aren't double-shadowed
2486
- ctx.shadowBlur = 0;
2487
- ctx.shadowOffsetX = 0;
2488
- ctx.shadowOffsetY = 0;
2489
- ctx.shadowColor = "transparent";
2600
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2601
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2602
+ ctx.shadowBlur = 0;
2603
+ ctx.shadowOffsetX = 0;
2604
+ ctx.shadowOffsetY = 0;
2605
+ ctx.shadowColor = "transparent";
2606
+ }
2490
2607
  // ── Specular highlight — tinted arc on the light-facing side ──
2491
- if (lightAngle !== undefined && size > 15 && rng) {
2608
+ // Skip for small shapes (< 30px) gradient creation + composite op
2609
+ // switch is expensive and the highlight is invisible at small sizes.
2610
+ if (lightAngle !== undefined && size > 30 && rng) {
2492
2611
  const hlRadius = size * 0.35;
2493
2612
  const hlDist = size * 0.15;
2494
2613
  const hlX = Math.cos(lightAngle) * hlDist;
2495
2614
  const hlY = Math.sin(lightAngle) * hlDist;
2496
2615
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2497
- // Tint highlight warm/cool based on fill color for cohesion
2498
- // Parse fill to detect warmth fallback to white for non-parseable
2499
- let hlBase = "255,255,255";
2500
- if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
2501
- const r = parseInt(fillColor.slice(1, 3), 16);
2502
- const g = parseInt(fillColor.slice(3, 5), 16);
2503
- const b = parseInt(fillColor.slice(5, 7), 16);
2504
- // Blend toward white but keep a hint of the fill's warmth
2505
- hlBase = `${Math.round(r * 0.15 + 216.75)},${Math.round(g * 0.15 + 216.75)},${Math.round(b * 0.15 + 216.75)}`;
2506
- }
2507
- hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
2508
- hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
2509
- hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
2616
+ // Use a simple white highlight the per-shape hex parse was expensive
2617
+ // and the visual difference from tinted highlights is negligible.
2618
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2619
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2620
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
2510
2621
  const savedOp = ctx.globalCompositeOperation;
2511
2622
  ctx.globalCompositeOperation = "soft-light";
2512
2623
  ctx.fillStyle = hlGrad;
@@ -4048,6 +4159,46 @@ function $68a238ccd77f2bcd$export$f1142fd7da4d6590(rng) {
4048
4159
  }
4049
4160
 
4050
4161
 
4162
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4163
+ // Based on benchmark measurements. Used by the complexity budget to
4164
+ // cap total rendering work and downgrade expensive styles when needed.
4165
+ const $1f63dc64b5593c73$var$RENDER_STYLE_COST = {
4166
+ "fill-and-stroke": 1,
4167
+ "fill-only": 0.5,
4168
+ "stroke-only": 1,
4169
+ "double-stroke": 1.5,
4170
+ "dashed": 1,
4171
+ "watercolor": 7,
4172
+ "hatched": 3,
4173
+ "incomplete": 1,
4174
+ "stipple": 90,
4175
+ "stencil": 2,
4176
+ "noise-grain": 400,
4177
+ "wood-grain": 10,
4178
+ "marble-vein": 4,
4179
+ "fabric-weave": 6,
4180
+ "hand-drawn": 5
4181
+ };
4182
+ function $1f63dc64b5593c73$var$downgradeRenderStyle(style) {
4183
+ switch(style){
4184
+ case "noise-grain":
4185
+ return "hatched";
4186
+ case "stipple":
4187
+ return "dashed";
4188
+ case "wood-grain":
4189
+ return "hatched";
4190
+ case "watercolor":
4191
+ return "fill-and-stroke";
4192
+ case "fabric-weave":
4193
+ return "hatched";
4194
+ case "hand-drawn":
4195
+ return "fill-and-stroke";
4196
+ case "marble-vein":
4197
+ return "stroke-only";
4198
+ default:
4199
+ return style;
4200
+ }
4201
+ }
4051
4202
  // ── Shape categories for weighted selection (legacy fallback) ───────
4052
4203
  const $1f63dc64b5593c73$var$SACRED_SHAPES = [
4053
4204
  "mandala",
@@ -4657,19 +4808,20 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4657
4808
  ctx.beginPath();
4658
4809
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4659
4810
  ctx.stroke();
4660
- // ~50% chance: scatter tiny dots inside the void
4811
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4661
4812
  if (rng() < 0.5) {
4662
4813
  const dotCount = 3 + Math.floor(rng() * 6);
4663
4814
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4664
4815
  ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4816
+ ctx.beginPath();
4665
4817
  for(let d = 0; d < dotCount; d++){
4666
4818
  const angle = rng() * Math.PI * 2;
4667
4819
  const dist = rng() * zone.radius * 0.7;
4668
4820
  const dotR = (1 + rng() * 3) * scaleFactor;
4669
- ctx.beginPath();
4821
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4670
4822
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4671
- ctx.fill();
4672
4823
  }
4824
+ ctx.fill();
4673
4825
  }
4674
4826
  // ~30% chance: thin concentric ring inside
4675
4827
  if (rng() < 0.3) {
@@ -4761,6 +4913,26 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4761
4913
  }
4762
4914
  // ── 5. Shape layers ────────────────────────────────────────────
4763
4915
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4916
+ // ── Complexity budget — caps total rendering work ──────────────
4917
+ // Budget scales with pixel area so larger canvases get proportionally
4918
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4919
+ // constellations, rhythm) are gated behind the budget; when it runs
4920
+ // low they are skipped. When it's exhausted, expensive render styles
4921
+ // are downgraded to cheaper alternatives.
4922
+ //
4923
+ // RNG values are always consumed even when skipping, so the
4924
+ // deterministic sequence for shapes that *do* render is preserved.
4925
+ const pixelArea = width * height;
4926
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4927
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4928
+ const totalBudget = complexityBudget;
4929
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4930
+ let extrasSpent = 0;
4931
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4932
+ // These generate O(size²) fillRect calls per shape and dominate
4933
+ // worst-case render time. Cap scales with pixel area.
4934
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4935
+ let clipHeavyCount = 0;
4764
4936
  for(let layer = 0; layer < layers; layer++){
4765
4937
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4766
4938
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4849,7 +5021,26 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4849
5021
  const shapeRenderStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4850
5022
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4851
5023
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4852
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5024
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5025
+ // Budget check: downgrade expensive styles proportionally —
5026
+ // the more expensive the style, the earlier it gets downgraded.
5027
+ // noise-grain (400) downgrades when budget < 20% remaining,
5028
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5029
+ let styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5030
+ if (styleCost > 3) {
5031
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5032
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5033
+ finalRenderStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(finalRenderStyle);
5034
+ styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5035
+ }
5036
+ }
5037
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5038
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5039
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5040
+ finalRenderStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(finalRenderStyle);
5041
+ styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5042
+ }
5043
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4853
5044
  // Consistent light direction — subtle shadow offset
4854
5045
  const shadowDist = hasGlow ? 0 : size * 0.02;
4855
5046
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4904,30 +5095,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4904
5095
  lightAngle: lightAngle,
4905
5096
  scaleFactor: scaleFactor
4906
5097
  };
4907
- if (shouldMirror) (0, $e0f99502ff383dd8$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4908
- ...shapeConfig,
4909
- mirrorAxis: mirrorAxis,
4910
- mirrorGap: size * (0.1 + rng() * 0.3)
4911
- });
4912
- else (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5098
+ if (shouldMirror) {
5099
+ (0, $e0f99502ff383dd8$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5100
+ ...shapeConfig,
5101
+ mirrorAxis: mirrorAxis,
5102
+ mirrorGap: size * (0.1 + rng() * 0.3)
5103
+ });
5104
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5105
+ } else {
5106
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5107
+ complexityBudget -= styleCost;
5108
+ }
5109
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5110
+ const extrasAllowed = extrasSpent < budgetForExtras;
4913
5111
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4914
5112
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4915
5113
  const glazePasses = 2 + Math.floor(rng() * 2);
4916
- for(let g = 0; g < glazePasses; g++){
4917
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4918
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4919
- ctx.globalAlpha = glazeAlpha;
4920
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4921
- fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4922
- strokeColor: "rgba(0,0,0,0)",
4923
- strokeWidth: 0,
4924
- size: size * glazeScale,
4925
- rotation: rotation,
4926
- proportionType: "GOLDEN_RATIO",
4927
- renderStyle: "fill-only",
4928
- rng: rng
4929
- });
5114
+ if (extrasAllowed) {
5115
+ for(let g = 0; g < glazePasses; g++){
5116
+ const glazeScale = 1 - (g + 1) * 0.12;
5117
+ const glazeAlpha = 0.08 + g * 0.04;
5118
+ ctx.globalAlpha = glazeAlpha;
5119
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5120
+ fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5121
+ strokeColor: "rgba(0,0,0,0)",
5122
+ strokeWidth: 0,
5123
+ size: size * glazeScale,
5124
+ rotation: rotation,
5125
+ proportionType: "GOLDEN_RATIO",
5126
+ renderStyle: "fill-only",
5127
+ rng: rng
5128
+ });
5129
+ }
5130
+ extrasSpent += glazePasses;
4930
5131
  }
5132
+ // RNG consumed by glazePasses calculation above regardless
4931
5133
  }
4932
5134
  shapePositions.push({
4933
5135
  x: finalX,
@@ -4945,37 +5147,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4945
5147
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4946
5148
  const echoCount = 2 + Math.floor(rng() * 2);
4947
5149
  const echoAngle = rng() * Math.PI * 2;
4948
- for(let e = 0; e < echoCount; e++){
4949
- const echoScale = 0.3 - e * 0.08;
4950
- const echoDist = size * (0.6 + e * 0.4);
4951
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4952
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4953
- const echoSize = size * Math.max(0.1, echoScale);
4954
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4955
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4956
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4957
- fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4958
- strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.4),
4959
- strokeWidth: strokeWidth * 0.6,
4960
- size: echoSize,
4961
- rotation: rotation + (e + 1) * 15,
4962
- proportionType: "GOLDEN_RATIO",
4963
- renderStyle: finalRenderStyle,
4964
- rng: rng
4965
- });
4966
- shapePositions.push({
4967
- x: echoX,
4968
- y: echoY,
4969
- size: echoSize,
4970
- shape: shape
4971
- });
4972
- spatialGrid.insert({
4973
- x: echoX,
4974
- y: echoY,
4975
- size: echoSize,
4976
- shape: shape
4977
- });
5150
+ if (extrasAllowed) {
5151
+ for(let e = 0; e < echoCount; e++){
5152
+ const echoScale = 0.3 - e * 0.08;
5153
+ const echoDist = size * (0.6 + e * 0.4);
5154
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5155
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5156
+ const echoSize = size * Math.max(0.1, echoScale);
5157
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5158
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5159
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5160
+ fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5161
+ strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.4),
5162
+ strokeWidth: strokeWidth * 0.6,
5163
+ size: echoSize,
5164
+ rotation: rotation + (e + 1) * 15,
5165
+ proportionType: "GOLDEN_RATIO",
5166
+ renderStyle: finalRenderStyle,
5167
+ rng: rng
5168
+ });
5169
+ shapePositions.push({
5170
+ x: echoX,
5171
+ y: echoY,
5172
+ size: echoSize,
5173
+ shape: shape
5174
+ });
5175
+ spatialGrid.insert({
5176
+ x: echoX,
5177
+ y: echoY,
5178
+ size: echoSize,
5179
+ shape: shape
5180
+ });
5181
+ }
5182
+ extrasSpent += echoCount * styleCost;
4978
5183
  }
5184
+ // RNG for echoCount + echoAngle consumed above regardless
4979
5185
  }
4980
5186
  // ── 5d. Recursive nesting ──────────────────────────────────
4981
5187
  // Focal depth: shapes near focal points get more detail
@@ -4983,7 +5189,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4983
5189
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4984
5190
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4985
5191
  const innerCount = 1 + Math.floor(rng() * 3);
4986
- for(let n = 0; n < innerCount; n++){
5192
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4987
5193
  // Pick inner shape from palette affinities
4988
5194
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
4989
5195
  const innerShape = (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -4992,6 +5198,10 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4992
5198
  const innerOffY = (rng() - 0.5) * size * 0.4;
4993
5199
  const innerRot = rng() * 360;
4994
5200
  const innerFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5201
+ let innerStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5202
+ // Apply clip-heavy cap to nested shapes too
5203
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(innerStyle);
5204
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
4995
5205
  ctx.globalAlpha = layerOpacity * 0.7;
4996
5206
  (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
4997
5207
  fillColor: innerFill,
@@ -5000,9 +5210,21 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5000
5210
  size: innerSize,
5001
5211
  rotation: innerRot,
5002
5212
  proportionType: "GOLDEN_RATIO",
5003
- renderStyle: (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5213
+ renderStyle: innerStyle,
5004
5214
  rng: rng
5005
5215
  });
5216
+ extrasSpent += $1f63dc64b5593c73$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5217
+ }
5218
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5219
+ for(let n = 0; n < innerCount; n++){
5220
+ rng();
5221
+ rng();
5222
+ rng();
5223
+ rng();
5224
+ rng();
5225
+ rng();
5226
+ rng();
5227
+ rng();
5006
5228
  }
5007
5229
  }
5008
5230
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -5011,40 +5233,55 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5011
5233
  const constellation = $1f63dc64b5593c73$var$CONSTELLATIONS[Math.floor(rng() * $1f63dc64b5593c73$var$CONSTELLATIONS.length)];
5012
5234
  const members = constellation.build(rng, size);
5013
5235
  const groupRotation = rng() * Math.PI * 2;
5014
- const cosR = Math.cos(groupRotation);
5015
- const sinR = Math.sin(groupRotation);
5016
- for (const member of members){
5017
- // Rotate the group offset by the group rotation
5018
- const mx = finalX + member.dx * cosR - member.dy * sinR;
5019
- const my = finalY + member.dx * sinR + member.dy * cosR;
5020
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
5021
- const memberFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5022
- const memberStroke = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5023
- ctx.globalAlpha = layerOpacity * 0.6;
5024
- // Use the member's shape if available, otherwise fall back to palette
5025
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5026
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5027
- fillColor: memberFill,
5028
- strokeColor: memberStroke,
5029
- strokeWidth: strokeWidth * 0.7,
5030
- size: member.size,
5031
- rotation: member.rotation + groupRotation * 180 / Math.PI,
5032
- proportionType: "GOLDEN_RATIO",
5033
- renderStyle: (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
5034
- rng: rng
5035
- });
5036
- shapePositions.push({
5037
- x: mx,
5038
- y: my,
5039
- size: member.size,
5040
- shape: memberShape
5041
- });
5042
- spatialGrid.insert({
5043
- x: mx,
5044
- y: my,
5045
- size: member.size,
5046
- shape: memberShape
5047
- });
5236
+ if (extrasAllowed) {
5237
+ const cosR = Math.cos(groupRotation);
5238
+ const sinR = Math.sin(groupRotation);
5239
+ for (const member of members){
5240
+ // Rotate the group offset by the group rotation
5241
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5242
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5243
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5244
+ const memberFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5245
+ const memberStroke = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5246
+ ctx.globalAlpha = layerOpacity * 0.6;
5247
+ // Use the member's shape if available, otherwise fall back to palette
5248
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5249
+ let memberStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5250
+ // Apply clip-heavy cap to constellation members too
5251
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(memberStyle);
5252
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5253
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5254
+ fillColor: memberFill,
5255
+ strokeColor: memberStroke,
5256
+ strokeWidth: strokeWidth * 0.7,
5257
+ size: member.size,
5258
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5259
+ proportionType: "GOLDEN_RATIO",
5260
+ renderStyle: memberStyle,
5261
+ rng: rng
5262
+ });
5263
+ shapePositions.push({
5264
+ x: mx,
5265
+ y: my,
5266
+ size: member.size,
5267
+ shape: memberShape
5268
+ });
5269
+ spatialGrid.insert({
5270
+ x: mx,
5271
+ y: my,
5272
+ size: member.size,
5273
+ shape: memberShape
5274
+ });
5275
+ extrasSpent += $1f63dc64b5593c73$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5276
+ }
5277
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5278
+ for(let m = 0; m < members.length; m++){
5279
+ rng();
5280
+ rng();
5281
+ rng();
5282
+ rng();
5283
+ rng();
5284
+ rng();
5048
5285
  }
5049
5286
  }
5050
5287
  // ── 5f. Rhythm placement — deliberate geometric progressions ──
@@ -5055,39 +5292,47 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5055
5292
  const rhythmSpacing = size * (0.8 + rng() * 0.6);
5056
5293
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5057
5294
  const rhythmShape = shape; // same shape for visual rhythm
5058
- let rhythmSize = size * 0.6;
5295
+ if (extrasAllowed) {
5296
+ let rhythmSize = size * 0.6;
5297
+ for(let r = 0; r < rhythmCount; r++){
5298
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5299
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5300
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5301
+ if ($1f63dc64b5593c73$var$isInVoidZone(rx, ry, voidZones)) break;
5302
+ rhythmSize *= rhythmDecay;
5303
+ if (rhythmSize < adjustedMinSize) break;
5304
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5305
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5306
+ const rhythmFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5307
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5308
+ fillColor: rhythmFill,
5309
+ strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.5),
5310
+ strokeWidth: strokeWidth * 0.7,
5311
+ size: rhythmSize,
5312
+ rotation: rotation + (r + 1) * 12,
5313
+ proportionType: "GOLDEN_RATIO",
5314
+ renderStyle: finalRenderStyle,
5315
+ rng: rng
5316
+ });
5317
+ shapePositions.push({
5318
+ x: rx,
5319
+ y: ry,
5320
+ size: rhythmSize,
5321
+ shape: rhythmShape
5322
+ });
5323
+ spatialGrid.insert({
5324
+ x: rx,
5325
+ y: ry,
5326
+ size: rhythmSize,
5327
+ shape: rhythmShape
5328
+ });
5329
+ }
5330
+ extrasSpent += rhythmCount * styleCost;
5331
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5059
5332
  for(let r = 0; r < rhythmCount; r++){
5060
- const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5061
- const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5062
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5063
- if ($1f63dc64b5593c73$var$isInVoidZone(rx, ry, voidZones)) break;
5064
- rhythmSize *= rhythmDecay;
5065
- if (rhythmSize < adjustedMinSize) break;
5066
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5067
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5068
- const rhythmFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5069
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5070
- fillColor: rhythmFill,
5071
- strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.5),
5072
- strokeWidth: strokeWidth * 0.7,
5073
- size: rhythmSize,
5074
- rotation: rotation + (r + 1) * 12,
5075
- proportionType: "GOLDEN_RATIO",
5076
- renderStyle: finalRenderStyle,
5077
- rng: rng
5078
- });
5079
- shapePositions.push({
5080
- x: rx,
5081
- y: ry,
5082
- size: rhythmSize,
5083
- shape: rhythmShape
5084
- });
5085
- spatialGrid.insert({
5086
- x: rx,
5087
- y: ry,
5088
- size: rhythmSize,
5089
- shape: rhythmShape
5090
- });
5333
+ rng();
5334
+ rng();
5335
+ rng();
5091
5336
  }
5092
5337
  }
5093
5338
  }
@@ -5153,14 +5398,26 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5153
5398
  }
5154
5399
  }
5155
5400
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5401
+ // Optimized: collect all segments into width-quantized buckets, then
5402
+ // render each bucket as a single batched path. This reduces
5403
+ // beginPath/stroke calls from O(segments) to O(buckets).
5156
5404
  const baseFlowLines = 6 + Math.floor(rng() * 10);
5157
5405
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5406
+ // Width buckets — 6 buckets cover the taper×pressure range
5407
+ const FLOW_WIDTH_BUCKETS = 6;
5408
+ const flowBuckets = [];
5409
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5410
+ // Track the representative width for each bucket
5411
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5412
+ // Pre-compute max possible width for bucket assignment
5413
+ let globalMaxFlowWidth = 0;
5158
5414
  for(let i = 0; i < numFlowLines; i++){
5159
5415
  let fx = rng() * width;
5160
5416
  let fy = rng() * height;
5161
5417
  const steps = 30 + Math.floor(rng() * 40);
5162
5418
  const stepLen = (3 + rng() * 5) * scaleFactor;
5163
5419
  const startWidth = (1 + rng() * 3) * scaleFactor;
5420
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
5164
5421
  // Variable color: interpolate between two hierarchy colors along the stroke
5165
5422
  const lineColorStart = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
5166
5423
  const lineColorEnd = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -5182,19 +5439,22 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5182
5439
  continue;
5183
5440
  }
5184
5441
  const t = s / steps;
5185
- // Taper + pressure
5186
5442
  const taper = 1 - t * 0.8;
5187
5443
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
5188
- ctx.globalAlpha = lineAlpha * taper;
5189
- // Interpolate color along stroke
5444
+ const segWidth = startWidth * taper * pressure;
5445
+ const segAlpha = lineAlpha * taper;
5190
5446
  const lineColor = t < 0.5 ? (0, $b5a262d09b87e373$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $b5a262d09b87e373$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
5191
- ctx.strokeStyle = lineColor;
5192
- ctx.lineWidth = startWidth * taper * pressure;
5193
- ctx.lineCap = "round";
5194
- ctx.beginPath();
5195
- ctx.moveTo(prevX, prevY);
5196
- ctx.lineTo(fx, fy);
5197
- ctx.stroke();
5447
+ // Quantize width into bucket
5448
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5449
+ flowBuckets[bucketIdx].push({
5450
+ x1: prevX,
5451
+ y1: prevY,
5452
+ x2: fx,
5453
+ y2: fy,
5454
+ color: lineColor,
5455
+ alpha: segAlpha
5456
+ });
5457
+ flowBucketWidths[bucketIdx] = segWidth;
5198
5458
  // Branching: ~12% chance per step to spawn a thinner child stroke
5199
5459
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
5200
5460
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -5210,12 +5470,18 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5210
5470
  by += Math.sin(bAngle) * stepLen * 0.8;
5211
5471
  if (bx < 0 || bx > width || by < 0 || by > height) break;
5212
5472
  const bTaper = 1 - bs / branchSteps * 0.9;
5213
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
5214
- ctx.lineWidth = branchWidth * bTaper;
5215
- ctx.beginPath();
5216
- ctx.moveTo(bPrevX, bPrevY);
5217
- ctx.lineTo(bx, by);
5218
- ctx.stroke();
5473
+ const bSegWidth = branchWidth * bTaper;
5474
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5475
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5476
+ flowBuckets[bBucket].push({
5477
+ x1: bPrevX,
5478
+ y1: bPrevY,
5479
+ x2: bx,
5480
+ y2: by,
5481
+ color: lineColor,
5482
+ alpha: bAlpha
5483
+ });
5484
+ flowBucketWidths[bBucket] = bSegWidth;
5219
5485
  bPrevX = bx;
5220
5486
  bPrevY = by;
5221
5487
  }
@@ -5224,7 +5490,40 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5224
5490
  prevY = fy;
5225
5491
  }
5226
5492
  }
5493
+ // Render flow line buckets — one batched path per width bucket
5494
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5495
+ ctx.lineCap = "round";
5496
+ const FLOW_ALPHA_BUCKETS = 4;
5497
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5498
+ const segs = flowBuckets[wb];
5499
+ if (segs.length === 0) continue;
5500
+ ctx.lineWidth = flowBucketWidths[wb];
5501
+ // Sub-bucket by alpha
5502
+ const alphaSubs = [];
5503
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5504
+ let maxAlpha = 0;
5505
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5506
+ for(let j = 0; j < segs.length; j++){
5507
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5508
+ alphaSubs[ai].push(segs[j]);
5509
+ }
5510
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5511
+ const sub = alphaSubs[ai];
5512
+ if (sub.length === 0) continue;
5513
+ // Use the median segment's alpha and color as representative
5514
+ const rep = sub[Math.floor(sub.length / 2)];
5515
+ ctx.globalAlpha = rep.alpha;
5516
+ ctx.strokeStyle = rep.color;
5517
+ ctx.beginPath();
5518
+ for(let j = 0; j < sub.length; j++){
5519
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5520
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5521
+ }
5522
+ ctx.stroke();
5523
+ }
5524
+ }
5227
5525
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5526
+ // Optimized: collect all burst segments, then batch by quantized alpha
5228
5527
  const energyArchetypes = [
5229
5528
  "dense-chaotic",
5230
5529
  "cosmic",
@@ -5235,8 +5534,12 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5235
5534
  if (hasEnergyLines && shapePositions.length > 0) {
5236
5535
  const energyCount = 5 + Math.floor(rng() * 10);
5237
5536
  ctx.lineCap = "round";
5537
+ // Collect all energy segments with their computed state
5538
+ const ENERGY_ALPHA_BUCKETS = 3;
5539
+ const energyBuckets = [];
5540
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5541
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5238
5542
  for(let e = 0; e < energyCount; e++){
5239
- // Pick a random shape to radiate from
5240
5543
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5241
5544
  const burstCount = 2 + Math.floor(rng() * 4);
5242
5545
  const baseAngle = flowAngle(source.x, source.y);
@@ -5248,15 +5551,38 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5248
5551
  const sy = source.y + Math.sin(angle) * startDist;
5249
5552
  const ex = sx + Math.cos(angle) * lineLen;
5250
5553
  const ey = sy + Math.sin(angle) * lineLen;
5251
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5252
- ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5253
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5254
- ctx.beginPath();
5255
- ctx.moveTo(sx, sy);
5256
- ctx.lineTo(ex, ey);
5257
- ctx.stroke();
5554
+ const eAlpha = 0.04 + rng() * 0.06;
5555
+ const eColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5556
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5557
+ // Quantize alpha into bucket
5558
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5559
+ energyBuckets[bi].push({
5560
+ x1: sx,
5561
+ y1: sy,
5562
+ x2: ex,
5563
+ y2: ey,
5564
+ color: eColor,
5565
+ lw: eLw
5566
+ });
5567
+ energyAlphas[bi] = eAlpha;
5258
5568
  }
5259
5569
  }
5570
+ // Render batched energy lines
5571
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5572
+ const segs = energyBuckets[bi];
5573
+ if (segs.length === 0) continue;
5574
+ ctx.globalAlpha = energyAlphas[bi];
5575
+ // Use median segment's color and width as representative
5576
+ const rep = segs[Math.floor(segs.length / 2)];
5577
+ ctx.strokeStyle = rep.color;
5578
+ ctx.lineWidth = rep.lw;
5579
+ ctx.beginPath();
5580
+ for(let j = 0; j < segs.length; j++){
5581
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5582
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5583
+ }
5584
+ ctx.stroke();
5585
+ }
5260
5586
  }
5261
5587
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
5262
5588
  if (symmetryMode !== "none") {
@@ -5279,27 +5605,44 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5279
5605
  ctx.restore();
5280
5606
  }
5281
5607
  // ── 7. Noise texture overlay — batched via ImageData ─────────────
5608
+ // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
5609
+ // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
5282
5610
  const noiseRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 777));
5283
- const noiseDensity = Math.floor(width * height / 800);
5611
+ const rawNoiseDensity = Math.floor(width * height / 800);
5612
+ // Cap at 2500 dots — beyond this the visual effect is indistinguishable
5613
+ // but getImageData/putImageData cost scales with canvas size
5614
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5284
5615
  try {
5285
5616
  const imageData = ctx.getImageData(0, 0, width, height);
5286
5617
  const data = imageData.data;
5287
5618
  const pixelScale = Math.max(1, Math.round(scaleFactor));
5619
+ if (pixelScale === 1) // Fast path — no inner loop, direct pixel write
5620
+ // Pre-compute alpha blend as integer math (avoid float multiply per channel)
5288
5621
  for(let i = 0; i < noiseDensity; i++){
5289
5622
  const nx = Math.floor(noiseRng() * width);
5290
5623
  const ny = Math.floor(noiseRng() * height);
5291
5624
  const brightness = noiseRng() > 0.5 ? 255 : 0;
5292
- const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
5293
- // Write a small block of pixels for scale
5625
+ // srcA in range [0.01, 0.04] multiply by 256 for fixed-point
5626
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5627
+ const invA256 = 256 - srcA256;
5628
+ const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
5629
+ const idx = ny * width + nx << 2;
5630
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5631
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5632
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5633
+ }
5634
+ else for(let i = 0; i < noiseDensity; i++){
5635
+ const nx = Math.floor(noiseRng() * width);
5636
+ const ny = Math.floor(noiseRng() * height);
5637
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5638
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5639
+ const invA256 = 256 - srcA256;
5640
+ const bSrc = brightness * srcA256;
5294
5641
  for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5295
- const idx = ((ny + dy) * width + (nx + dx)) * 4;
5296
- // Alpha-blend the noise dot onto existing pixel data
5297
- const srcA = alpha / 255;
5298
- const invA = 1 - srcA;
5299
- data[idx] = Math.round(data[idx] * invA + brightness * srcA);
5300
- data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
5301
- data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
5302
- // Keep existing alpha
5642
+ const idx = (ny + dy) * width + (nx + dx) << 2;
5643
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5644
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5645
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5303
5646
  }
5304
5647
  }
5305
5648
  ctx.putImageData(imageData, 0, 0);
@@ -5329,10 +5672,18 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5329
5672
  ctx.fillStyle = vigGrad;
5330
5673
  ctx.fillRect(0, 0, width, height);
5331
5674
  // ── 9. Organic connecting curves — proximity-aware ───────────────
5675
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5676
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5332
5677
  if (shapePositions.length > 1) {
5333
5678
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5334
5679
  const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5335
5680
  ctx.lineWidth = 0.8 * scaleFactor;
5681
+ // Collect curves into 3 alpha buckets
5682
+ const CURVE_ALPHA_BUCKETS = 3;
5683
+ const curveBuckets = [];
5684
+ const curveColors = [];
5685
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5686
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5336
5687
  for(let i = 0; i < numCurves; i++){
5337
5688
  const idxA = Math.floor(rng() * shapePositions.length);
5338
5689
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
@@ -5349,11 +5700,32 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5349
5700
  const bulge = (rng() - 0.5) * dist * 0.4;
5350
5701
  const cpx = mx + -dy / (dist || 1) * bulge;
5351
5702
  const cpy = my + dx / (dist || 1) * bulge;
5352
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5353
- ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5703
+ const curveAlpha = 0.06 + rng() * 0.1;
5704
+ const curveColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5705
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5706
+ curveBuckets[bi].push({
5707
+ ax: a.x,
5708
+ ay: a.y,
5709
+ cpx: cpx,
5710
+ cpy: cpy,
5711
+ bx: b.x,
5712
+ by: b.y
5713
+ });
5714
+ curveAlphas[bi] = curveAlpha;
5715
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5716
+ }
5717
+ // Render batched curves
5718
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5719
+ const curves = curveBuckets[bi];
5720
+ if (curves.length === 0) continue;
5721
+ ctx.globalAlpha = curveAlphas[bi];
5722
+ ctx.strokeStyle = curveColors[bi];
5354
5723
  ctx.beginPath();
5355
- ctx.moveTo(a.x, a.y);
5356
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5724
+ for(let j = 0; j < curves.length; j++){
5725
+ const c = curves[j];
5726
+ ctx.moveTo(c.ax, c.ay);
5727
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5728
+ }
5357
5729
  ctx.stroke();
5358
5730
  }
5359
5731
  }
@@ -5471,11 +5843,14 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5471
5843
  }
5472
5844
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5473
5845
  // Vine tendrils — organic curving lines along edges
5846
+ // Optimized: batch all tendrils into a single path
5474
5847
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5475
5848
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5476
5849
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5477
5850
  ctx.lineCap = "round";
5478
5851
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5852
+ ctx.beginPath();
5853
+ const leafPositions = [];
5479
5854
  for(let t = 0; t < tendrilCount; t++){
5480
5855
  // Start from a random edge point
5481
5856
  const edge = Math.floor(borderRng() * 4);
@@ -5493,7 +5868,6 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5493
5868
  tx = width - borderPad;
5494
5869
  ty = borderRng() * height;
5495
5870
  }
5496
- ctx.beginPath();
5497
5871
  ctx.moveTo(tx, ty);
5498
5872
  const segs = 3 + Math.floor(borderRng() * 4);
5499
5873
  for(let s = 0; s < segs; s++){
@@ -5507,14 +5881,23 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5507
5881
  ty = cpy3;
5508
5882
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5509
5883
  }
5510
- ctx.stroke();
5511
- // Small leaf/dot at tendril end
5512
- if (borderRng() < 0.6) {
5513
- ctx.beginPath();
5514
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5515
- ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5516
- ctx.fill();
5884
+ // Collect leaf positions for batch fill
5885
+ if (borderRng() < 0.6) leafPositions.push({
5886
+ x: tx,
5887
+ y: ty,
5888
+ r: borderPad * (0.15 + borderRng() * 0.2)
5889
+ });
5890
+ }
5891
+ ctx.stroke();
5892
+ // Batch all leaf dots into a single fill
5893
+ if (leafPositions.length > 0) {
5894
+ ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5895
+ ctx.beginPath();
5896
+ for (const leaf of leafPositions){
5897
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5898
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5517
5899
  }
5900
+ ctx.fill();
5518
5901
  }
5519
5902
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5520
5903
  // Star-studded arcs along edges
@@ -5529,8 +5912,9 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5529
5912
  ctx.beginPath();
5530
5913
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5531
5914
  ctx.stroke();
5532
- // Scatter small stars along the border region
5915
+ // Scatter small stars along the border region — batched into single path
5533
5916
  const starCount = 15 + Math.floor(borderRng() * 15);
5917
+ ctx.beginPath();
5534
5918
  for(let s = 0; s < starCount; s++){
5535
5919
  const edge = Math.floor(borderRng() * 4);
5536
5920
  let sx, sy;
@@ -5549,7 +5933,6 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5549
5933
  }
5550
5934
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5551
5935
  // 4-point star
5552
- ctx.beginPath();
5553
5936
  for(let p = 0; p < 8; p++){
5554
5937
  const a = p / 8 * Math.PI * 2;
5555
5938
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5559,8 +5942,8 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5559
5942
  else ctx.lineTo(px2, py2);
5560
5943
  }
5561
5944
  ctx.closePath();
5562
- ctx.fill();
5563
5945
  }
5946
+ ctx.fill();
5564
5947
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5565
5948
  // Thin single rule — understated elegance
5566
5949
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);