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/module.js CHANGED
@@ -517,13 +517,21 @@ class $9d614e7d77fc2947$export$ab958c550f521376 {
517
517
  }
518
518
  }
519
519
  // ── Standalone color utilities ──────────────────────────────────────
520
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $9d614e7d77fc2947$var$hexToRgb(hex) {
521
- const c = hex.replace("#", "");
522
- return [
520
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
521
+ const $9d614e7d77fc2947$var$_rgbCache = new Map();
522
+ const $9d614e7d77fc2947$var$_RGB_CACHE_MAX = 512;
523
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $9d614e7d77fc2947$var$hexToRgb(hex) {
524
+ let cached = $9d614e7d77fc2947$var$_rgbCache.get(hex);
525
+ if (cached) return cached;
526
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
527
+ cached = [
523
528
  parseInt(c.substring(0, 2), 16),
524
529
  parseInt(c.substring(2, 4), 16),
525
530
  parseInt(c.substring(4, 6), 16)
526
531
  ];
532
+ if ($9d614e7d77fc2947$var$_rgbCache.size >= $9d614e7d77fc2947$var$_RGB_CACHE_MAX) $9d614e7d77fc2947$var$_rgbCache.clear();
533
+ $9d614e7d77fc2947$var$_rgbCache.set(hex, cached);
534
+ return cached;
527
535
  }
528
536
  /** Format [r, g, b] back to #RRGGBB. */ function $9d614e7d77fc2947$var$rgbToHex(r, g, b) {
529
537
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -580,7 +588,9 @@ class $9d614e7d77fc2947$export$ab958c550f521376 {
580
588
  }
581
589
  function $9d614e7d77fc2947$export$f2121afcad3d553f(hex, alpha) {
582
590
  const [r, g, b] = $9d614e7d77fc2947$var$hexToRgb(hex);
583
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
591
+ // Quantize alpha to 3 decimal places without toFixed overhead
592
+ const a = Math.round(alpha * 1000) / 1000;
593
+ return `rgba(${r},${g},${b},${a})`;
584
594
  }
585
595
  function $9d614e7d77fc2947$export$fabac4600b87056(colors, rng) {
586
596
  if (colors.length < 3) return {
@@ -660,12 +670,21 @@ function $9d614e7d77fc2947$export$51ea55f869b7e0d3(hex, target, amount) {
660
670
  const [h, s, l] = $9d614e7d77fc2947$var$hexToHsl(hex);
661
671
  return $9d614e7d77fc2947$var$hslToHex($9d614e7d77fc2947$var$shiftHueToward(h, target, amount), s, l);
662
672
  }
673
+ /**
674
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
675
+ * Uses the sRGB luminance formula from WCAG. Cached.
676
+ */ const $9d614e7d77fc2947$var$_lumCache = new Map();
663
677
  function $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe(hex) {
678
+ let cached = $9d614e7d77fc2947$var$_lumCache.get(hex);
679
+ if (cached !== undefined) return cached;
664
680
  const [r, g, b] = $9d614e7d77fc2947$var$hexToRgb(hex).map((c)=>{
665
681
  const s = c / 255;
666
682
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
667
683
  });
668
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
684
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
685
+ if ($9d614e7d77fc2947$var$_lumCache.size >= 512) $9d614e7d77fc2947$var$_lumCache.clear();
686
+ $9d614e7d77fc2947$var$_lumCache.set(hex, cached);
687
+ return cached;
669
688
  }
670
689
  function $9d614e7d77fc2947$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
671
690
  const fgLum = $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1101,21 +1120,31 @@ const $8bde0a7ee87832b5$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1101
1120
  (0, $79312e33271883e9$export$e46c5570db033611)(ctx, size, finalConfig);
1102
1121
  const gridSize = 8;
1103
1122
  const unit = size / gridSize;
1123
+ const radius = unit / 2;
1124
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1125
+ const starPoints = [];
1126
+ for(let k = 0; k < 8; k++){
1127
+ const angle = Math.PI / 4 * k;
1128
+ const angle2 = angle + Math.PI / 4;
1129
+ starPoints.push({
1130
+ c1: Math.cos(angle) * radius,
1131
+ s1: Math.sin(angle) * radius,
1132
+ c2: Math.cos(angle2) * radius,
1133
+ s2: Math.sin(angle2) * radius
1134
+ });
1135
+ }
1104
1136
  ctx.beginPath();
1105
1137
  // Create base grid
1106
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1138
+ for(let i = 0; i <= gridSize; i++){
1107
1139
  const x = (i - gridSize / 2) * unit;
1108
- const y = (j - gridSize / 2) * unit;
1109
- // Draw star pattern at each intersection
1110
- const radius = unit / 2;
1111
- for(let k = 0; k < 8; k++){
1112
- const angle = Math.PI / 4 * k;
1113
- const x1 = x + radius * Math.cos(angle);
1114
- const y1 = y + radius * Math.sin(angle);
1115
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1116
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1117
- ctx.moveTo(x1, y1);
1118
- ctx.lineTo(x2, y2);
1140
+ for(let j = 0; j <= gridSize; j++){
1141
+ const y = (j - gridSize / 2) * unit;
1142
+ // Draw star pattern at each intersection using pre-computed offsets
1143
+ for(let k = 0; k < 8; k++){
1144
+ const sp = starPoints[k];
1145
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1146
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1147
+ }
1119
1148
  }
1120
1149
  }
1121
1150
  ctx.stroke();
@@ -1435,20 +1464,23 @@ const $d63629e16208c310$export$eeae7765f05012e2 = (ctx, size)=>{
1435
1464
  const $d63629e16208c310$export$3355220a8108efc3 = (ctx, size)=>{
1436
1465
  const outerRadius = size / 2;
1437
1466
  const innerRadius = size / 4;
1438
- const steps = 36;
1467
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1468
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1469
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1470
+ const TWO_PI = Math.PI * 2;
1471
+ const angleStep = TWO_PI / steps;
1439
1472
  ctx.beginPath();
1440
1473
  for(let i = 0; i < steps; i++){
1441
- const angle1 = i / steps * Math.PI * 2;
1442
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1474
+ const angle1 = i * angleStep;
1475
+ const cosA = Math.cos(angle1);
1476
+ const sinA = Math.sin(angle1);
1443
1477
  for(let j = 0; j < steps; j++){
1444
- const phi1 = j / steps * Math.PI * 2;
1445
- const phi2 = (j + 1) / steps * Math.PI * 2;
1446
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1447
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1448
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1449
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1450
- ctx.moveTo(x1, y1);
1451
- ctx.lineTo(x2, y2);
1478
+ const phi1 = j * angleStep;
1479
+ const phi2 = phi1 + angleStep;
1480
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1481
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1482
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1483
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1452
1484
  }
1453
1485
  }
1454
1486
  };
@@ -2011,6 +2043,43 @@ const $9beb8f41637c29fd$var$RENDER_STYLES = [
2011
2043
  function $9beb8f41637c29fd$export$9fd4e64b2acd410e(rng) {
2012
2044
  return $9beb8f41637c29fd$var$RENDER_STYLES[Math.floor(rng() * $9beb8f41637c29fd$var$RENDER_STYLES.length)];
2013
2045
  }
2046
+ const $9beb8f41637c29fd$export$2f738f61a8c15e07 = {
2047
+ "fill-and-stroke": 1,
2048
+ "fill-only": 0.5,
2049
+ "stroke-only": 1,
2050
+ "double-stroke": 1.5,
2051
+ "dashed": 1,
2052
+ "watercolor": 7,
2053
+ "hatched": 3,
2054
+ "incomplete": 1,
2055
+ "stipple": 90,
2056
+ "stencil": 2,
2057
+ "noise-grain": 400,
2058
+ "wood-grain": 10,
2059
+ "marble-vein": 4,
2060
+ "fabric-weave": 6,
2061
+ "hand-drawn": 5
2062
+ };
2063
+ function $9beb8f41637c29fd$export$909ab0580e273f19(style) {
2064
+ switch(style){
2065
+ case "noise-grain":
2066
+ return "hatched";
2067
+ case "stipple":
2068
+ return "dashed";
2069
+ case "wood-grain":
2070
+ return "hatched";
2071
+ case "watercolor":
2072
+ return "fill-and-stroke";
2073
+ case "fabric-weave":
2074
+ return "hatched";
2075
+ case "hand-drawn":
2076
+ return "fill-and-stroke";
2077
+ case "marble-vein":
2078
+ return "stroke-only";
2079
+ default:
2080
+ return style;
2081
+ }
2082
+ }
2014
2083
  function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2015
2084
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2016
2085
  ctx.save();
@@ -2130,6 +2199,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2130
2199
  case "hatched":
2131
2200
  {
2132
2201
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2202
+ // Optimized: batch all parallel lines into a single path per pass
2133
2203
  const savedAlphaH = ctx.globalAlpha;
2134
2204
  ctx.globalAlpha = savedAlphaH * 0.3;
2135
2205
  ctx.fill();
@@ -2141,28 +2211,28 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2141
2211
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2142
2212
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2143
2213
  ctx.globalAlpha = savedAlphaH * 0.6;
2144
- // Draw parallel lines across the bounding box
2214
+ // Draw parallel lines across the bounding box — batched into single path
2145
2215
  const extent = size * 0.8;
2146
2216
  const cos = Math.cos(hatchAngle);
2147
2217
  const sin = Math.sin(hatchAngle);
2218
+ ctx.beginPath();
2148
2219
  for(let d = -extent; d <= extent; d += hatchSpacing){
2149
- ctx.beginPath();
2150
2220
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2151
2221
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2152
- ctx.stroke();
2153
2222
  }
2223
+ ctx.stroke();
2154
2224
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2155
2225
  if (!rng || rng() < 0.5) {
2156
2226
  const crossAngle = hatchAngle + Math.PI / 2;
2157
2227
  const cos2 = Math.cos(crossAngle);
2158
2228
  const sin2 = Math.sin(crossAngle);
2159
2229
  ctx.globalAlpha = savedAlphaH * 0.35;
2230
+ ctx.beginPath();
2160
2231
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2161
- ctx.beginPath();
2162
2232
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2163
2233
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2164
- ctx.stroke();
2165
2234
  }
2235
+ ctx.stroke();
2166
2236
  }
2167
2237
  ctx.restore();
2168
2238
  ctx.globalAlpha = savedAlphaH;
@@ -2200,6 +2270,8 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2200
2270
  case "stipple":
2201
2271
  {
2202
2272
  // Dot-fill texture — clip to shape, then scatter dots
2273
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2274
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2203
2275
  const savedAlphaS = ctx.globalAlpha;
2204
2276
  ctx.globalAlpha = savedAlphaS * 0.15;
2205
2277
  ctx.fill(); // ghost fill
@@ -2207,16 +2279,20 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2207
2279
  ctx.save();
2208
2280
  ctx.clip();
2209
2281
  const dotSpacing = Math.max(2, size * 0.03);
2210
- const extent = size * 0.55;
2282
+ const extentS = size * 0.55;
2283
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2284
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2285
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2211
2286
  ctx.globalAlpha = savedAlphaS * 0.7;
2212
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2213
- // Jitter each dot position for organic feel
2214
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2215
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2216
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2217
- ctx.beginPath();
2218
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2219
- ctx.fill();
2287
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2288
+ const dx = -extentS + xi * actualSpacing;
2289
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2290
+ const dy = -extentS + yi * actualSpacing;
2291
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2292
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2293
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2294
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2295
+ }
2220
2296
  }
2221
2297
  ctx.restore();
2222
2298
  ctx.globalAlpha = savedAlphaS;
@@ -2249,6 +2325,9 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2249
2325
  case "noise-grain":
2250
2326
  {
2251
2327
  // Procedural noise grain texture clipped to shape boundary
2328
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2329
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2330
+ // and batch dots by brightness (black/white) × alpha bucket
2252
2331
  const savedAlphaN = ctx.globalAlpha;
2253
2332
  ctx.globalAlpha = savedAlphaN * 0.25;
2254
2333
  ctx.fill(); // base tint
@@ -2257,17 +2336,47 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2257
2336
  ctx.clip();
2258
2337
  const grainSpacing = Math.max(1.5, size * 0.015);
2259
2338
  const extentN = size * 0.55;
2260
- ctx.globalAlpha = savedAlphaN * 0.6;
2261
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2262
- if (!rng) break;
2263
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2264
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2265
- const brightness = rng() > 0.5 ? 255 : 0;
2266
- const dotAlpha = 0.15 + rng() * 0.35;
2267
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2268
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2269
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2270
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2339
+ if (rng) {
2340
+ // Cap grid to max 40 dots per axis beyond this the grain is
2341
+ // visually indistinguishable but cost scales quadratically.
2342
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2343
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2344
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2345
+ const BUCKETS = 4;
2346
+ const bucketMin = 0.15;
2347
+ const bucketRange = 0.35;
2348
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2349
+ const buckets = [];
2350
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2351
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2352
+ const gx = -extentN + xi * actualGrainSpacing;
2353
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2354
+ const gy = -extentN + yi * actualGrainSpacing;
2355
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2356
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2357
+ const isWhite = rng() > 0.5;
2358
+ const dotAlpha = bucketMin + rng() * bucketRange;
2359
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2360
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2361
+ const offset = isWhite ? BUCKETS : 0;
2362
+ buckets[offset + bucketIdx].push({
2363
+ x: gx + jx,
2364
+ y: gy + jy,
2365
+ s: dotSize
2366
+ });
2367
+ }
2368
+ }
2369
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2370
+ for(let color = 0; color < 2; color++){
2371
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2372
+ for(let b = 0; b < BUCKETS; b++){
2373
+ const dots = buckets[color * BUCKETS + b];
2374
+ if (dots.length === 0) continue;
2375
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2376
+ ctx.globalAlpha = savedAlphaN * alpha;
2377
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2378
+ }
2379
+ }
2271
2380
  }
2272
2381
  ctx.restore();
2273
2382
  ctx.fillStyle = fillColor;
@@ -2280,6 +2389,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2280
2389
  case "wood-grain":
2281
2390
  {
2282
2391
  // Parallel wavy lines simulating wood grain, clipped to shape
2392
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2283
2393
  const savedAlphaW = ctx.globalAlpha;
2284
2394
  ctx.globalAlpha = savedAlphaW * 0.2;
2285
2395
  ctx.fill(); // base tint
@@ -2295,17 +2405,19 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2295
2405
  ctx.globalAlpha = savedAlphaW * 0.5;
2296
2406
  const cosG = Math.cos(grainAngle);
2297
2407
  const sinG = Math.sin(grainAngle);
2408
+ const waveCoeff = waveFreq * Math.PI;
2409
+ const invExtentW = 1 / extentW;
2410
+ // Batch all grain lines into a single path
2411
+ ctx.beginPath();
2298
2412
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2299
- ctx.beginPath();
2300
- for(let t = -extentW; t <= extentW; t += 2){
2301
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2302
- const px = t * cosG - (d + wave) * sinG;
2303
- const py = t * sinG + (d + wave) * cosG;
2304
- if (t === -extentW) ctx.moveTo(px, py);
2305
- else ctx.lineTo(px, py);
2413
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2414
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2415
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2416
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2417
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2306
2418
  }
2307
- ctx.stroke();
2308
2419
  }
2420
+ ctx.stroke();
2309
2421
  ctx.restore();
2310
2422
  ctx.globalAlpha = savedAlphaW;
2311
2423
  ctx.globalAlpha *= 0.35;
@@ -2367,6 +2479,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2367
2479
  case "fabric-weave":
2368
2480
  {
2369
2481
  // Interlocking horizontal/vertical threads clipped to shape
2482
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2370
2483
  const savedAlphaF = ctx.globalAlpha;
2371
2484
  ctx.globalAlpha = savedAlphaF * 0.15;
2372
2485
  ctx.fill(); // ghost base
@@ -2376,26 +2489,24 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2376
2489
  const threadSpacing = Math.max(2, size * 0.04);
2377
2490
  const extentF = size * 0.55;
2378
2491
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2492
+ // Horizontal threads — batched
2379
2493
  ctx.globalAlpha = savedAlphaF * 0.55;
2380
- // Horizontal threads
2494
+ ctx.beginPath();
2381
2495
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2382
- ctx.beginPath();
2383
2496
  ctx.moveTo(-extentF, y);
2384
2497
  ctx.lineTo(extentF, y);
2385
- ctx.stroke();
2386
2498
  }
2387
- // Vertical threads (offset by half spacing for weave effect)
2499
+ ctx.stroke();
2500
+ // Vertical threads (offset by half spacing for weave effect) — batched
2388
2501
  ctx.globalAlpha = savedAlphaF * 0.45;
2389
2502
  ctx.strokeStyle = fillColor;
2390
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2391
- ctx.beginPath();
2392
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2393
- // Over-under: draw segment, skip segment
2394
- ctx.moveTo(x, y);
2395
- ctx.lineTo(x, y + threadSpacing);
2396
- }
2397
- ctx.stroke();
2503
+ ctx.beginPath();
2504
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2505
+ // Over-under: draw segment, skip segment
2506
+ ctx.moveTo(x, y);
2507
+ ctx.lineTo(x, y + threadSpacing);
2398
2508
  }
2509
+ ctx.stroke();
2399
2510
  ctx.strokeStyle = strokeColor;
2400
2511
  ctx.restore();
2401
2512
  ctx.globalAlpha = savedAlphaF;
@@ -2461,14 +2572,17 @@ function $9beb8f41637c29fd$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2461
2572
  ctx.translate(x, y);
2462
2573
  ctx.rotate(rotation * Math.PI / 180);
2463
2574
  // ── Drop shadow — soft colored shadow offset along light direction ──
2464
- if (lightAngle !== undefined && size > 10) {
2575
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2576
+ // and visually imperceptible at that scale.
2577
+ const useShadow = size >= 20;
2578
+ if (useShadow && lightAngle !== undefined) {
2465
2579
  const shadowDist = size * 0.035;
2466
2580
  const shadowBlurR = size * 0.06;
2467
2581
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2468
2582
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2469
2583
  ctx.shadowBlur = shadowBlurR;
2470
2584
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2471
- } else if (glowRadius > 0) {
2585
+ } else if (useShadow && glowRadius > 0) {
2472
2586
  // Glow / shadow effect (legacy path)
2473
2587
  ctx.shadowBlur = glowRadius;
2474
2588
  ctx.shadowColor = glowColor || fillColor;
@@ -2492,30 +2606,27 @@ function $9beb8f41637c29fd$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2492
2606
  $9beb8f41637c29fd$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2493
2607
  }
2494
2608
  // Reset shadow so patterns and highlight aren't double-shadowed
2495
- ctx.shadowBlur = 0;
2496
- ctx.shadowOffsetX = 0;
2497
- ctx.shadowOffsetY = 0;
2498
- ctx.shadowColor = "transparent";
2609
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2610
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2611
+ ctx.shadowBlur = 0;
2612
+ ctx.shadowOffsetX = 0;
2613
+ ctx.shadowOffsetY = 0;
2614
+ ctx.shadowColor = "transparent";
2615
+ }
2499
2616
  // ── Specular highlight — tinted arc on the light-facing side ──
2500
- if (lightAngle !== undefined && size > 15 && rng) {
2617
+ // Skip for small shapes (< 30px) gradient creation + composite op
2618
+ // switch is expensive and the highlight is invisible at small sizes.
2619
+ if (lightAngle !== undefined && size > 30 && rng) {
2501
2620
  const hlRadius = size * 0.35;
2502
2621
  const hlDist = size * 0.15;
2503
2622
  const hlX = Math.cos(lightAngle) * hlDist;
2504
2623
  const hlY = Math.sin(lightAngle) * hlDist;
2505
2624
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2506
- // Tint highlight warm/cool based on fill color for cohesion
2507
- // Parse fill to detect warmth fallback to white for non-parseable
2508
- let hlBase = "255,255,255";
2509
- if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
2510
- const r = parseInt(fillColor.slice(1, 3), 16);
2511
- const g = parseInt(fillColor.slice(3, 5), 16);
2512
- const b = parseInt(fillColor.slice(5, 7), 16);
2513
- // Blend toward white but keep a hint of the fill's warmth
2514
- hlBase = `${Math.round(r * 0.15 + 216.75)},${Math.round(g * 0.15 + 216.75)},${Math.round(b * 0.15 + 216.75)}`;
2515
- }
2516
- hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
2517
- hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
2518
- hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
2625
+ // Use a simple white highlight the per-shape hex parse was expensive
2626
+ // and the visual difference from tinted highlights is negligible.
2627
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2628
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2629
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
2519
2630
  const savedOp = ctx.globalCompositeOperation;
2520
2631
  ctx.globalCompositeOperation = "soft-light";
2521
2632
  ctx.fillStyle = hlGrad;
@@ -4057,6 +4168,46 @@ function $3faa2521b78398cf$export$f1142fd7da4d6590(rng) {
4057
4168
  }
4058
4169
 
4059
4170
 
4171
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4172
+ // Based on benchmark measurements. Used by the complexity budget to
4173
+ // cap total rendering work and downgrade expensive styles when needed.
4174
+ const $b623126c6e9cbb71$var$RENDER_STYLE_COST = {
4175
+ "fill-and-stroke": 1,
4176
+ "fill-only": 0.5,
4177
+ "stroke-only": 1,
4178
+ "double-stroke": 1.5,
4179
+ "dashed": 1,
4180
+ "watercolor": 7,
4181
+ "hatched": 3,
4182
+ "incomplete": 1,
4183
+ "stipple": 90,
4184
+ "stencil": 2,
4185
+ "noise-grain": 400,
4186
+ "wood-grain": 10,
4187
+ "marble-vein": 4,
4188
+ "fabric-weave": 6,
4189
+ "hand-drawn": 5
4190
+ };
4191
+ function $b623126c6e9cbb71$var$downgradeRenderStyle(style) {
4192
+ switch(style){
4193
+ case "noise-grain":
4194
+ return "hatched";
4195
+ case "stipple":
4196
+ return "dashed";
4197
+ case "wood-grain":
4198
+ return "hatched";
4199
+ case "watercolor":
4200
+ return "fill-and-stroke";
4201
+ case "fabric-weave":
4202
+ return "hatched";
4203
+ case "hand-drawn":
4204
+ return "fill-and-stroke";
4205
+ case "marble-vein":
4206
+ return "stroke-only";
4207
+ default:
4208
+ return style;
4209
+ }
4210
+ }
4060
4211
  // ── Shape categories for weighted selection (legacy fallback) ───────
4061
4212
  const $b623126c6e9cbb71$var$SACRED_SHAPES = [
4062
4213
  "mandala",
@@ -4668,19 +4819,20 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4668
4819
  ctx.beginPath();
4669
4820
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4670
4821
  ctx.stroke();
4671
- // ~50% chance: scatter tiny dots inside the void
4822
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4672
4823
  if (rng() < 0.5) {
4673
4824
  const dotCount = 3 + Math.floor(rng() * 6);
4674
4825
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4675
4826
  ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4827
+ ctx.beginPath();
4676
4828
  for(let d = 0; d < dotCount; d++){
4677
4829
  const angle = rng() * Math.PI * 2;
4678
4830
  const dist = rng() * zone.radius * 0.7;
4679
4831
  const dotR = (1 + rng() * 3) * scaleFactor;
4680
- ctx.beginPath();
4832
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4681
4833
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4682
- ctx.fill();
4683
4834
  }
4835
+ ctx.fill();
4684
4836
  }
4685
4837
  // ~30% chance: thin concentric ring inside
4686
4838
  if (rng() < 0.3) {
@@ -4772,6 +4924,26 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4772
4924
  }
4773
4925
  // ── 5. Shape layers ────────────────────────────────────────────
4774
4926
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4927
+ // ── Complexity budget — caps total rendering work ──────────────
4928
+ // Budget scales with pixel area so larger canvases get proportionally
4929
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4930
+ // constellations, rhythm) are gated behind the budget; when it runs
4931
+ // low they are skipped. When it's exhausted, expensive render styles
4932
+ // are downgraded to cheaper alternatives.
4933
+ //
4934
+ // RNG values are always consumed even when skipping, so the
4935
+ // deterministic sequence for shapes that *do* render is preserved.
4936
+ const pixelArea = width * height;
4937
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4938
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4939
+ const totalBudget = complexityBudget;
4940
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4941
+ let extrasSpent = 0;
4942
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4943
+ // These generate O(size²) fillRect calls per shape and dominate
4944
+ // worst-case render time. Cap scales with pixel area.
4945
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4946
+ let clipHeavyCount = 0;
4775
4947
  for(let layer = 0; layer < layers; layer++){
4776
4948
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4777
4949
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4860,7 +5032,26 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4860
5032
  const shapeRenderStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4861
5033
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4862
5034
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4863
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5035
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5036
+ // Budget check: downgrade expensive styles proportionally —
5037
+ // the more expensive the style, the earlier it gets downgraded.
5038
+ // noise-grain (400) downgrades when budget < 20% remaining,
5039
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5040
+ let styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5041
+ if (styleCost > 3) {
5042
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5043
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5044
+ finalRenderStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(finalRenderStyle);
5045
+ styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5046
+ }
5047
+ }
5048
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5049
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5050
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5051
+ finalRenderStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(finalRenderStyle);
5052
+ styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5053
+ }
5054
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4864
5055
  // Consistent light direction — subtle shadow offset
4865
5056
  const shadowDist = hasGlow ? 0 : size * 0.02;
4866
5057
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4915,30 +5106,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4915
5106
  lightAngle: lightAngle,
4916
5107
  scaleFactor: scaleFactor
4917
5108
  };
4918
- if (shouldMirror) (0, $9beb8f41637c29fd$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4919
- ...shapeConfig,
4920
- mirrorAxis: mirrorAxis,
4921
- mirrorGap: size * (0.1 + rng() * 0.3)
4922
- });
4923
- else (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5109
+ if (shouldMirror) {
5110
+ (0, $9beb8f41637c29fd$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5111
+ ...shapeConfig,
5112
+ mirrorAxis: mirrorAxis,
5113
+ mirrorGap: size * (0.1 + rng() * 0.3)
5114
+ });
5115
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5116
+ } else {
5117
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5118
+ complexityBudget -= styleCost;
5119
+ }
5120
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5121
+ const extrasAllowed = extrasSpent < budgetForExtras;
4924
5122
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4925
5123
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4926
5124
  const glazePasses = 2 + Math.floor(rng() * 2);
4927
- for(let g = 0; g < glazePasses; g++){
4928
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4929
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4930
- ctx.globalAlpha = glazeAlpha;
4931
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4932
- fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4933
- strokeColor: "rgba(0,0,0,0)",
4934
- strokeWidth: 0,
4935
- size: size * glazeScale,
4936
- rotation: rotation,
4937
- proportionType: "GOLDEN_RATIO",
4938
- renderStyle: "fill-only",
4939
- rng: rng
4940
- });
5125
+ if (extrasAllowed) {
5126
+ for(let g = 0; g < glazePasses; g++){
5127
+ const glazeScale = 1 - (g + 1) * 0.12;
5128
+ const glazeAlpha = 0.08 + g * 0.04;
5129
+ ctx.globalAlpha = glazeAlpha;
5130
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5131
+ fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5132
+ strokeColor: "rgba(0,0,0,0)",
5133
+ strokeWidth: 0,
5134
+ size: size * glazeScale,
5135
+ rotation: rotation,
5136
+ proportionType: "GOLDEN_RATIO",
5137
+ renderStyle: "fill-only",
5138
+ rng: rng
5139
+ });
5140
+ }
5141
+ extrasSpent += glazePasses;
4941
5142
  }
5143
+ // RNG consumed by glazePasses calculation above regardless
4942
5144
  }
4943
5145
  shapePositions.push({
4944
5146
  x: finalX,
@@ -4956,37 +5158,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4956
5158
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4957
5159
  const echoCount = 2 + Math.floor(rng() * 2);
4958
5160
  const echoAngle = rng() * Math.PI * 2;
4959
- for(let e = 0; e < echoCount; e++){
4960
- const echoScale = 0.3 - e * 0.08;
4961
- const echoDist = size * (0.6 + e * 0.4);
4962
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4963
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4964
- const echoSize = size * Math.max(0.1, echoScale);
4965
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4966
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4967
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4968
- fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4969
- strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.4),
4970
- strokeWidth: strokeWidth * 0.6,
4971
- size: echoSize,
4972
- rotation: rotation + (e + 1) * 15,
4973
- proportionType: "GOLDEN_RATIO",
4974
- renderStyle: finalRenderStyle,
4975
- rng: rng
4976
- });
4977
- shapePositions.push({
4978
- x: echoX,
4979
- y: echoY,
4980
- size: echoSize,
4981
- shape: shape
4982
- });
4983
- spatialGrid.insert({
4984
- x: echoX,
4985
- y: echoY,
4986
- size: echoSize,
4987
- shape: shape
4988
- });
5161
+ if (extrasAllowed) {
5162
+ for(let e = 0; e < echoCount; e++){
5163
+ const echoScale = 0.3 - e * 0.08;
5164
+ const echoDist = size * (0.6 + e * 0.4);
5165
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5166
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5167
+ const echoSize = size * Math.max(0.1, echoScale);
5168
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5169
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5170
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5171
+ fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5172
+ strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.4),
5173
+ strokeWidth: strokeWidth * 0.6,
5174
+ size: echoSize,
5175
+ rotation: rotation + (e + 1) * 15,
5176
+ proportionType: "GOLDEN_RATIO",
5177
+ renderStyle: finalRenderStyle,
5178
+ rng: rng
5179
+ });
5180
+ shapePositions.push({
5181
+ x: echoX,
5182
+ y: echoY,
5183
+ size: echoSize,
5184
+ shape: shape
5185
+ });
5186
+ spatialGrid.insert({
5187
+ x: echoX,
5188
+ y: echoY,
5189
+ size: echoSize,
5190
+ shape: shape
5191
+ });
5192
+ }
5193
+ extrasSpent += echoCount * styleCost;
4989
5194
  }
5195
+ // RNG for echoCount + echoAngle consumed above regardless
4990
5196
  }
4991
5197
  // ── 5d. Recursive nesting ──────────────────────────────────
4992
5198
  // Focal depth: shapes near focal points get more detail
@@ -4994,7 +5200,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4994
5200
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4995
5201
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4996
5202
  const innerCount = 1 + Math.floor(rng() * 3);
4997
- for(let n = 0; n < innerCount; n++){
5203
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4998
5204
  // Pick inner shape from palette affinities
4999
5205
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
5000
5206
  const innerShape = (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -5003,6 +5209,10 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5003
5209
  const innerOffY = (rng() - 0.5) * size * 0.4;
5004
5210
  const innerRot = rng() * 360;
5005
5211
  const innerFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5212
+ let innerStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5213
+ // Apply clip-heavy cap to nested shapes too
5214
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(innerStyle);
5215
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
5006
5216
  ctx.globalAlpha = layerOpacity * 0.7;
5007
5217
  (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
5008
5218
  fillColor: innerFill,
@@ -5011,9 +5221,21 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5011
5221
  size: innerSize,
5012
5222
  rotation: innerRot,
5013
5223
  proportionType: "GOLDEN_RATIO",
5014
- renderStyle: (0, $24064302523652b1$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5224
+ renderStyle: innerStyle,
5015
5225
  rng: rng
5016
5226
  });
5227
+ extrasSpent += $b623126c6e9cbb71$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5228
+ }
5229
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5230
+ for(let n = 0; n < innerCount; n++){
5231
+ rng();
5232
+ rng();
5233
+ rng();
5234
+ rng();
5235
+ rng();
5236
+ rng();
5237
+ rng();
5238
+ rng();
5017
5239
  }
5018
5240
  }
5019
5241
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -5022,40 +5244,55 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5022
5244
  const constellation = $b623126c6e9cbb71$var$CONSTELLATIONS[Math.floor(rng() * $b623126c6e9cbb71$var$CONSTELLATIONS.length)];
5023
5245
  const members = constellation.build(rng, size);
5024
5246
  const groupRotation = rng() * Math.PI * 2;
5025
- const cosR = Math.cos(groupRotation);
5026
- const sinR = Math.sin(groupRotation);
5027
- for (const member of members){
5028
- // Rotate the group offset by the group rotation
5029
- const mx = finalX + member.dx * cosR - member.dy * sinR;
5030
- const my = finalY + member.dx * sinR + member.dy * cosR;
5031
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
5032
- const memberFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5033
- const memberStroke = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5034
- ctx.globalAlpha = layerOpacity * 0.6;
5035
- // Use the member's shape if available, otherwise fall back to palette
5036
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5037
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5038
- fillColor: memberFill,
5039
- strokeColor: memberStroke,
5040
- strokeWidth: strokeWidth * 0.7,
5041
- size: member.size,
5042
- rotation: member.rotation + groupRotation * 180 / Math.PI,
5043
- proportionType: "GOLDEN_RATIO",
5044
- renderStyle: (0, $24064302523652b1$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
5045
- rng: rng
5046
- });
5047
- shapePositions.push({
5048
- x: mx,
5049
- y: my,
5050
- size: member.size,
5051
- shape: memberShape
5052
- });
5053
- spatialGrid.insert({
5054
- x: mx,
5055
- y: my,
5056
- size: member.size,
5057
- shape: memberShape
5058
- });
5247
+ if (extrasAllowed) {
5248
+ const cosR = Math.cos(groupRotation);
5249
+ const sinR = Math.sin(groupRotation);
5250
+ for (const member of members){
5251
+ // Rotate the group offset by the group rotation
5252
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5253
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5254
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5255
+ const memberFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5256
+ const memberStroke = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5257
+ ctx.globalAlpha = layerOpacity * 0.6;
5258
+ // Use the member's shape if available, otherwise fall back to palette
5259
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5260
+ let memberStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5261
+ // Apply clip-heavy cap to constellation members too
5262
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(memberStyle);
5263
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5264
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5265
+ fillColor: memberFill,
5266
+ strokeColor: memberStroke,
5267
+ strokeWidth: strokeWidth * 0.7,
5268
+ size: member.size,
5269
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5270
+ proportionType: "GOLDEN_RATIO",
5271
+ renderStyle: memberStyle,
5272
+ rng: rng
5273
+ });
5274
+ shapePositions.push({
5275
+ x: mx,
5276
+ y: my,
5277
+ size: member.size,
5278
+ shape: memberShape
5279
+ });
5280
+ spatialGrid.insert({
5281
+ x: mx,
5282
+ y: my,
5283
+ size: member.size,
5284
+ shape: memberShape
5285
+ });
5286
+ extrasSpent += $b623126c6e9cbb71$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5287
+ }
5288
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5289
+ for(let m = 0; m < members.length; m++){
5290
+ rng();
5291
+ rng();
5292
+ rng();
5293
+ rng();
5294
+ rng();
5295
+ rng();
5059
5296
  }
5060
5297
  }
5061
5298
  // ── 5f. Rhythm placement — deliberate geometric progressions ──
@@ -5066,39 +5303,47 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5066
5303
  const rhythmSpacing = size * (0.8 + rng() * 0.6);
5067
5304
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5068
5305
  const rhythmShape = shape; // same shape for visual rhythm
5069
- let rhythmSize = size * 0.6;
5306
+ if (extrasAllowed) {
5307
+ let rhythmSize = size * 0.6;
5308
+ for(let r = 0; r < rhythmCount; r++){
5309
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5310
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5311
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5312
+ if ($b623126c6e9cbb71$var$isInVoidZone(rx, ry, voidZones)) break;
5313
+ rhythmSize *= rhythmDecay;
5314
+ if (rhythmSize < adjustedMinSize) break;
5315
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5316
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5317
+ const rhythmFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5318
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5319
+ fillColor: rhythmFill,
5320
+ strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.5),
5321
+ strokeWidth: strokeWidth * 0.7,
5322
+ size: rhythmSize,
5323
+ rotation: rotation + (r + 1) * 12,
5324
+ proportionType: "GOLDEN_RATIO",
5325
+ renderStyle: finalRenderStyle,
5326
+ rng: rng
5327
+ });
5328
+ shapePositions.push({
5329
+ x: rx,
5330
+ y: ry,
5331
+ size: rhythmSize,
5332
+ shape: rhythmShape
5333
+ });
5334
+ spatialGrid.insert({
5335
+ x: rx,
5336
+ y: ry,
5337
+ size: rhythmSize,
5338
+ shape: rhythmShape
5339
+ });
5340
+ }
5341
+ extrasSpent += rhythmCount * styleCost;
5342
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5070
5343
  for(let r = 0; r < rhythmCount; r++){
5071
- const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5072
- const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5073
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5074
- if ($b623126c6e9cbb71$var$isInVoidZone(rx, ry, voidZones)) break;
5075
- rhythmSize *= rhythmDecay;
5076
- if (rhythmSize < adjustedMinSize) break;
5077
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5078
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5079
- const rhythmFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5080
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5081
- fillColor: rhythmFill,
5082
- strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.5),
5083
- strokeWidth: strokeWidth * 0.7,
5084
- size: rhythmSize,
5085
- rotation: rotation + (r + 1) * 12,
5086
- proportionType: "GOLDEN_RATIO",
5087
- renderStyle: finalRenderStyle,
5088
- rng: rng
5089
- });
5090
- shapePositions.push({
5091
- x: rx,
5092
- y: ry,
5093
- size: rhythmSize,
5094
- shape: rhythmShape
5095
- });
5096
- spatialGrid.insert({
5097
- x: rx,
5098
- y: ry,
5099
- size: rhythmSize,
5100
- shape: rhythmShape
5101
- });
5344
+ rng();
5345
+ rng();
5346
+ rng();
5102
5347
  }
5103
5348
  }
5104
5349
  }
@@ -5164,14 +5409,26 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5164
5409
  }
5165
5410
  }
5166
5411
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5412
+ // Optimized: collect all segments into width-quantized buckets, then
5413
+ // render each bucket as a single batched path. This reduces
5414
+ // beginPath/stroke calls from O(segments) to O(buckets).
5167
5415
  const baseFlowLines = 6 + Math.floor(rng() * 10);
5168
5416
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5417
+ // Width buckets — 6 buckets cover the taper×pressure range
5418
+ const FLOW_WIDTH_BUCKETS = 6;
5419
+ const flowBuckets = [];
5420
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5421
+ // Track the representative width for each bucket
5422
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5423
+ // Pre-compute max possible width for bucket assignment
5424
+ let globalMaxFlowWidth = 0;
5169
5425
  for(let i = 0; i < numFlowLines; i++){
5170
5426
  let fx = rng() * width;
5171
5427
  let fy = rng() * height;
5172
5428
  const steps = 30 + Math.floor(rng() * 40);
5173
5429
  const stepLen = (3 + rng() * 5) * scaleFactor;
5174
5430
  const startWidth = (1 + rng() * 3) * scaleFactor;
5431
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
5175
5432
  // Variable color: interpolate between two hierarchy colors along the stroke
5176
5433
  const lineColorStart = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
5177
5434
  const lineColorEnd = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -5193,19 +5450,22 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5193
5450
  continue;
5194
5451
  }
5195
5452
  const t = s / steps;
5196
- // Taper + pressure
5197
5453
  const taper = 1 - t * 0.8;
5198
5454
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
5199
- ctx.globalAlpha = lineAlpha * taper;
5200
- // Interpolate color along stroke
5455
+ const segWidth = startWidth * taper * pressure;
5456
+ const segAlpha = lineAlpha * taper;
5201
5457
  const lineColor = t < 0.5 ? (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
5202
- ctx.strokeStyle = lineColor;
5203
- ctx.lineWidth = startWidth * taper * pressure;
5204
- ctx.lineCap = "round";
5205
- ctx.beginPath();
5206
- ctx.moveTo(prevX, prevY);
5207
- ctx.lineTo(fx, fy);
5208
- ctx.stroke();
5458
+ // Quantize width into bucket
5459
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5460
+ flowBuckets[bucketIdx].push({
5461
+ x1: prevX,
5462
+ y1: prevY,
5463
+ x2: fx,
5464
+ y2: fy,
5465
+ color: lineColor,
5466
+ alpha: segAlpha
5467
+ });
5468
+ flowBucketWidths[bucketIdx] = segWidth;
5209
5469
  // Branching: ~12% chance per step to spawn a thinner child stroke
5210
5470
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
5211
5471
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -5221,12 +5481,18 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5221
5481
  by += Math.sin(bAngle) * stepLen * 0.8;
5222
5482
  if (bx < 0 || bx > width || by < 0 || by > height) break;
5223
5483
  const bTaper = 1 - bs / branchSteps * 0.9;
5224
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
5225
- ctx.lineWidth = branchWidth * bTaper;
5226
- ctx.beginPath();
5227
- ctx.moveTo(bPrevX, bPrevY);
5228
- ctx.lineTo(bx, by);
5229
- ctx.stroke();
5484
+ const bSegWidth = branchWidth * bTaper;
5485
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5486
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5487
+ flowBuckets[bBucket].push({
5488
+ x1: bPrevX,
5489
+ y1: bPrevY,
5490
+ x2: bx,
5491
+ y2: by,
5492
+ color: lineColor,
5493
+ alpha: bAlpha
5494
+ });
5495
+ flowBucketWidths[bBucket] = bSegWidth;
5230
5496
  bPrevX = bx;
5231
5497
  bPrevY = by;
5232
5498
  }
@@ -5235,7 +5501,40 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5235
5501
  prevY = fy;
5236
5502
  }
5237
5503
  }
5504
+ // Render flow line buckets — one batched path per width bucket
5505
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5506
+ ctx.lineCap = "round";
5507
+ const FLOW_ALPHA_BUCKETS = 4;
5508
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5509
+ const segs = flowBuckets[wb];
5510
+ if (segs.length === 0) continue;
5511
+ ctx.lineWidth = flowBucketWidths[wb];
5512
+ // Sub-bucket by alpha
5513
+ const alphaSubs = [];
5514
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5515
+ let maxAlpha = 0;
5516
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5517
+ for(let j = 0; j < segs.length; j++){
5518
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5519
+ alphaSubs[ai].push(segs[j]);
5520
+ }
5521
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5522
+ const sub = alphaSubs[ai];
5523
+ if (sub.length === 0) continue;
5524
+ // Use the median segment's alpha and color as representative
5525
+ const rep = sub[Math.floor(sub.length / 2)];
5526
+ ctx.globalAlpha = rep.alpha;
5527
+ ctx.strokeStyle = rep.color;
5528
+ ctx.beginPath();
5529
+ for(let j = 0; j < sub.length; j++){
5530
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5531
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5532
+ }
5533
+ ctx.stroke();
5534
+ }
5535
+ }
5238
5536
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5537
+ // Optimized: collect all burst segments, then batch by quantized alpha
5239
5538
  const energyArchetypes = [
5240
5539
  "dense-chaotic",
5241
5540
  "cosmic",
@@ -5246,8 +5545,12 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5246
5545
  if (hasEnergyLines && shapePositions.length > 0) {
5247
5546
  const energyCount = 5 + Math.floor(rng() * 10);
5248
5547
  ctx.lineCap = "round";
5548
+ // Collect all energy segments with their computed state
5549
+ const ENERGY_ALPHA_BUCKETS = 3;
5550
+ const energyBuckets = [];
5551
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5552
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5249
5553
  for(let e = 0; e < energyCount; e++){
5250
- // Pick a random shape to radiate from
5251
5554
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5252
5555
  const burstCount = 2 + Math.floor(rng() * 4);
5253
5556
  const baseAngle = flowAngle(source.x, source.y);
@@ -5259,15 +5562,38 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5259
5562
  const sy = source.y + Math.sin(angle) * startDist;
5260
5563
  const ex = sx + Math.cos(angle) * lineLen;
5261
5564
  const ey = sy + Math.sin(angle) * lineLen;
5262
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5263
- ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5264
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5265
- ctx.beginPath();
5266
- ctx.moveTo(sx, sy);
5267
- ctx.lineTo(ex, ey);
5268
- ctx.stroke();
5565
+ const eAlpha = 0.04 + rng() * 0.06;
5566
+ const eColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5567
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5568
+ // Quantize alpha into bucket
5569
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5570
+ energyBuckets[bi].push({
5571
+ x1: sx,
5572
+ y1: sy,
5573
+ x2: ex,
5574
+ y2: ey,
5575
+ color: eColor,
5576
+ lw: eLw
5577
+ });
5578
+ energyAlphas[bi] = eAlpha;
5269
5579
  }
5270
5580
  }
5581
+ // Render batched energy lines
5582
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5583
+ const segs = energyBuckets[bi];
5584
+ if (segs.length === 0) continue;
5585
+ ctx.globalAlpha = energyAlphas[bi];
5586
+ // Use median segment's color and width as representative
5587
+ const rep = segs[Math.floor(segs.length / 2)];
5588
+ ctx.strokeStyle = rep.color;
5589
+ ctx.lineWidth = rep.lw;
5590
+ ctx.beginPath();
5591
+ for(let j = 0; j < segs.length; j++){
5592
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5593
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5594
+ }
5595
+ ctx.stroke();
5596
+ }
5271
5597
  }
5272
5598
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
5273
5599
  if (symmetryMode !== "none") {
@@ -5290,27 +5616,44 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5290
5616
  ctx.restore();
5291
5617
  }
5292
5618
  // ── 7. Noise texture overlay — batched via ImageData ─────────────
5619
+ // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
5620
+ // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
5293
5621
  const noiseRng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash, 777));
5294
- const noiseDensity = Math.floor(width * height / 800);
5622
+ const rawNoiseDensity = Math.floor(width * height / 800);
5623
+ // Cap at 2500 dots — beyond this the visual effect is indistinguishable
5624
+ // but getImageData/putImageData cost scales with canvas size
5625
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5295
5626
  try {
5296
5627
  const imageData = ctx.getImageData(0, 0, width, height);
5297
5628
  const data = imageData.data;
5298
5629
  const pixelScale = Math.max(1, Math.round(scaleFactor));
5630
+ if (pixelScale === 1) // Fast path — no inner loop, direct pixel write
5631
+ // Pre-compute alpha blend as integer math (avoid float multiply per channel)
5299
5632
  for(let i = 0; i < noiseDensity; i++){
5300
5633
  const nx = Math.floor(noiseRng() * width);
5301
5634
  const ny = Math.floor(noiseRng() * height);
5302
5635
  const brightness = noiseRng() > 0.5 ? 255 : 0;
5303
- const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
5304
- // Write a small block of pixels for scale
5636
+ // srcA in range [0.01, 0.04] multiply by 256 for fixed-point
5637
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5638
+ const invA256 = 256 - srcA256;
5639
+ const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
5640
+ const idx = ny * width + nx << 2;
5641
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5642
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5643
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5644
+ }
5645
+ else for(let i = 0; i < noiseDensity; i++){
5646
+ const nx = Math.floor(noiseRng() * width);
5647
+ const ny = Math.floor(noiseRng() * height);
5648
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5649
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5650
+ const invA256 = 256 - srcA256;
5651
+ const bSrc = brightness * srcA256;
5305
5652
  for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5306
- const idx = ((ny + dy) * width + (nx + dx)) * 4;
5307
- // Alpha-blend the noise dot onto existing pixel data
5308
- const srcA = alpha / 255;
5309
- const invA = 1 - srcA;
5310
- data[idx] = Math.round(data[idx] * invA + brightness * srcA);
5311
- data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
5312
- data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
5313
- // Keep existing alpha
5653
+ const idx = (ny + dy) * width + (nx + dx) << 2;
5654
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5655
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5656
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5314
5657
  }
5315
5658
  }
5316
5659
  ctx.putImageData(imageData, 0, 0);
@@ -5340,10 +5683,18 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5340
5683
  ctx.fillStyle = vigGrad;
5341
5684
  ctx.fillRect(0, 0, width, height);
5342
5685
  // ── 9. Organic connecting curves — proximity-aware ───────────────
5686
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5687
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5343
5688
  if (shapePositions.length > 1) {
5344
5689
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5345
5690
  const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5346
5691
  ctx.lineWidth = 0.8 * scaleFactor;
5692
+ // Collect curves into 3 alpha buckets
5693
+ const CURVE_ALPHA_BUCKETS = 3;
5694
+ const curveBuckets = [];
5695
+ const curveColors = [];
5696
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5697
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5347
5698
  for(let i = 0; i < numCurves; i++){
5348
5699
  const idxA = Math.floor(rng() * shapePositions.length);
5349
5700
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
@@ -5360,11 +5711,32 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5360
5711
  const bulge = (rng() - 0.5) * dist * 0.4;
5361
5712
  const cpx = mx + -dy / (dist || 1) * bulge;
5362
5713
  const cpy = my + dx / (dist || 1) * bulge;
5363
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5364
- ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5714
+ const curveAlpha = 0.06 + rng() * 0.1;
5715
+ const curveColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5716
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5717
+ curveBuckets[bi].push({
5718
+ ax: a.x,
5719
+ ay: a.y,
5720
+ cpx: cpx,
5721
+ cpy: cpy,
5722
+ bx: b.x,
5723
+ by: b.y
5724
+ });
5725
+ curveAlphas[bi] = curveAlpha;
5726
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5727
+ }
5728
+ // Render batched curves
5729
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5730
+ const curves = curveBuckets[bi];
5731
+ if (curves.length === 0) continue;
5732
+ ctx.globalAlpha = curveAlphas[bi];
5733
+ ctx.strokeStyle = curveColors[bi];
5365
5734
  ctx.beginPath();
5366
- ctx.moveTo(a.x, a.y);
5367
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5735
+ for(let j = 0; j < curves.length; j++){
5736
+ const c = curves[j];
5737
+ ctx.moveTo(c.ax, c.ay);
5738
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5739
+ }
5368
5740
  ctx.stroke();
5369
5741
  }
5370
5742
  }
@@ -5482,11 +5854,14 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5482
5854
  }
5483
5855
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5484
5856
  // Vine tendrils — organic curving lines along edges
5857
+ // Optimized: batch all tendrils into a single path
5485
5858
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5486
5859
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5487
5860
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5488
5861
  ctx.lineCap = "round";
5489
5862
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5863
+ ctx.beginPath();
5864
+ const leafPositions = [];
5490
5865
  for(let t = 0; t < tendrilCount; t++){
5491
5866
  // Start from a random edge point
5492
5867
  const edge = Math.floor(borderRng() * 4);
@@ -5504,7 +5879,6 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5504
5879
  tx = width - borderPad;
5505
5880
  ty = borderRng() * height;
5506
5881
  }
5507
- ctx.beginPath();
5508
5882
  ctx.moveTo(tx, ty);
5509
5883
  const segs = 3 + Math.floor(borderRng() * 4);
5510
5884
  for(let s = 0; s < segs; s++){
@@ -5518,14 +5892,23 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5518
5892
  ty = cpy3;
5519
5893
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5520
5894
  }
5521
- ctx.stroke();
5522
- // Small leaf/dot at tendril end
5523
- if (borderRng() < 0.6) {
5524
- ctx.beginPath();
5525
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5526
- ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5527
- ctx.fill();
5895
+ // Collect leaf positions for batch fill
5896
+ if (borderRng() < 0.6) leafPositions.push({
5897
+ x: tx,
5898
+ y: ty,
5899
+ r: borderPad * (0.15 + borderRng() * 0.2)
5900
+ });
5901
+ }
5902
+ ctx.stroke();
5903
+ // Batch all leaf dots into a single fill
5904
+ if (leafPositions.length > 0) {
5905
+ ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5906
+ ctx.beginPath();
5907
+ for (const leaf of leafPositions){
5908
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5909
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5528
5910
  }
5911
+ ctx.fill();
5529
5912
  }
5530
5913
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5531
5914
  // Star-studded arcs along edges
@@ -5540,8 +5923,9 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5540
5923
  ctx.beginPath();
5541
5924
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5542
5925
  ctx.stroke();
5543
- // Scatter small stars along the border region
5926
+ // Scatter small stars along the border region — batched into single path
5544
5927
  const starCount = 15 + Math.floor(borderRng() * 15);
5928
+ ctx.beginPath();
5545
5929
  for(let s = 0; s < starCount; s++){
5546
5930
  const edge = Math.floor(borderRng() * 4);
5547
5931
  let sx, sy;
@@ -5560,7 +5944,6 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5560
5944
  }
5561
5945
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5562
5946
  // 4-point star
5563
- ctx.beginPath();
5564
5947
  for(let p = 0; p < 8; p++){
5565
5948
  const a = p / 8 * Math.PI * 2;
5566
5949
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5570,8 +5953,8 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5570
5953
  else ctx.lineTo(px2, py2);
5571
5954
  }
5572
5955
  ctx.closePath();
5573
- ctx.fill();
5574
5956
  }
5957
+ ctx.fill();
5575
5958
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5576
5959
  // Thin single rule — understated elegance
5577
5960
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);