@sarmal/core 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,11 @@
4
4
  <strong>Parametric curve animations for loading/thinking indicators</strong>
5
5
  </p>
6
6
 
7
- <p align="center">
8
- <a href="https://sarmal.art" target="_blank">Live Demo at sarmal.art</a>
9
- </p>
7
+ <div align="center">
8
+ <a href="https://sarmal.art">
9
+ <img src="../../docs/public/curves-light.gif" alt="Sarmal demo" width="100%" max-width="720">
10
+ </a>
11
+ </div>
10
12
 
11
13
  ---
12
14
 
package/dist/index.cjs CHANGED
@@ -1418,7 +1418,6 @@ function createSarmalSVG(container, curveDef, options) {
1418
1418
  }
1419
1419
 
1420
1420
  // src/renderer-dot-matrix.ts
1421
- var NUM_BUCKETS = 8;
1422
1421
  function createSarmalDotMatrix(canvas, curveDef, options) {
1423
1422
  const {
1424
1423
  cols = 32,
@@ -1442,14 +1441,8 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1442
1441
  const cellW = W / cols;
1443
1442
  const cellH = H / rows;
1444
1443
  const dotR = Math.min(cellW, cellH) * 0.36;
1445
- let gradientRgb;
1446
- if (Array.isArray(initialColor)) {
1447
- validateBaseRenderOptions({ trailColor: initialColor });
1448
- gradientRgb = initialColor.map(colorToRgb);
1449
- } else {
1450
- gradientRgb = null;
1451
- }
1452
- let colorRgb = gradientRgb ? gradientRgb[0] : colorToRgb(initialColor);
1444
+ let gradientOklab = null;
1445
+ let colorRgb = { r: 255, g: 255, b: 255 };
1453
1446
  let currentTrailStyle = initialTrailStyle;
1454
1447
  let animTime = 0;
1455
1448
  const ANIM_PERIOD = 6;
@@ -1457,7 +1450,12 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1457
1450
  let scale = 1;
1458
1451
  let offsetX = 0;
1459
1452
  let offsetY = 0;
1460
- let bgCanvas = null;
1453
+ let pixelMaskStarts = new Uint32Array(0);
1454
+ let pixelMaskLengths = new Uint32Array(0);
1455
+ let pixelMaskIndices = new Uint32Array(0);
1456
+ let pixelMaskCoverages = new Float32Array(0);
1457
+ let bgImageData = null;
1458
+ let frameImageData = null;
1461
1459
  let animationId = null;
1462
1460
  let lastTime = 0;
1463
1461
  let pausedByVisibility = false;
@@ -1465,33 +1463,82 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1465
1463
  let morphReject = null;
1466
1464
  let morphDurationMs = DEFAULT_MORPH_DURATION_MS;
1467
1465
  let morphProgress = 0;
1468
- function buildBgCanvas() {
1469
- bgCanvas = new OffscreenCanvas(W, H);
1470
- const bgCtx = bgCanvas.getContext("2d");
1471
- const bg = gradientRgb ? gradientRgb[0] : colorRgb;
1472
- bgCtx.fillStyle = `rgba(${bg.r},${bg.g},${bg.b},0.05)`;
1473
- bgCtx.beginPath();
1466
+ function computePixelMask() {
1467
+ const starts = new Uint32Array(cols * rows);
1468
+ const lengths = new Uint32Array(cols * rows);
1469
+ const allIndices = [];
1470
+ const allCoverages = [];
1471
+ const cornerR = roundness * dotR;
1472
+ const cornerR2 = cornerR * cornerR;
1473
+ const SSAA = 4;
1474
+ const SSAA2 = SSAA * SSAA;
1474
1475
  for (let row = 0; row < rows; row++) {
1475
1476
  for (let col = 0; col < cols; col++) {
1477
+ const dotIdx = row * cols + col;
1476
1478
  const cx = (col + 0.5) * cellW;
1477
1479
  const cy = (row + 0.5) * cellH;
1478
- bgCtx.roundRect(cx - dotR, cy - dotR, dotR * 2, dotR * 2, roundness * dotR);
1480
+ const x0 = Math.max(0, Math.floor(cx - dotR - 1));
1481
+ const x1 = Math.min(W - 1, Math.ceil(cx + dotR + 1));
1482
+ const y0 = Math.max(0, Math.floor(cy - dotR - 1));
1483
+ const y1 = Math.min(H - 1, Math.ceil(cy + dotR + 1));
1484
+ starts[dotIdx] = allIndices.length;
1485
+ let count = 0;
1486
+ for (let py = y0; py <= y1; py++) {
1487
+ for (let px = x0; px <= x1; px++) {
1488
+ let hits = 0;
1489
+ for (let sy = 0; sy < SSAA; sy++) {
1490
+ const spyCenter = py + (sy + 0.5) / SSAA;
1491
+ for (let sx = 0; sx < SSAA; sx++) {
1492
+ const spxCenter = px + (sx + 0.5) / SSAA;
1493
+ const dx = Math.max(Math.abs(spxCenter - cx) - (dotR - cornerR), 0);
1494
+ const dy = Math.max(Math.abs(spyCenter - cy) - (dotR - cornerR), 0);
1495
+ if (dx * dx + dy * dy <= cornerR2) {
1496
+ hits++;
1497
+ }
1498
+ }
1499
+ }
1500
+ if (hits > 0) {
1501
+ allIndices.push((py * W + px) * 4);
1502
+ allCoverages.push(hits / SSAA2);
1503
+ count++;
1504
+ }
1505
+ }
1506
+ }
1507
+ lengths[dotIdx] = count;
1508
+ }
1509
+ }
1510
+ pixelMaskStarts = starts;
1511
+ pixelMaskLengths = lengths;
1512
+ pixelMaskIndices = new Uint32Array(allIndices);
1513
+ pixelMaskCoverages = new Float32Array(allCoverages);
1514
+ }
1515
+ function buildBgImageData() {
1516
+ bgImageData = new ImageData(W, H);
1517
+ const bg = colorRgb;
1518
+ const baseAlpha = 0.05 * 255;
1519
+ const { data } = bgImageData;
1520
+ const n = cols * rows;
1521
+ for (let dotIdx = 0; dotIdx < n; dotIdx++) {
1522
+ const start = pixelMaskStarts[dotIdx];
1523
+ const len = pixelMaskLengths[dotIdx];
1524
+ for (let k = 0; k < len; k++) {
1525
+ const px = pixelMaskIndices[start + k];
1526
+ const coverage = pixelMaskCoverages[start + k];
1527
+ data[px] = bg.r;
1528
+ data[px + 1] = bg.g;
1529
+ data[px + 2] = bg.b;
1530
+ data[px + 3] = Math.round(baseAlpha * coverage);
1479
1531
  }
1480
1532
  }
1481
- bgCtx.fill();
1482
- }
1483
- function sampleGradientRgb(stops, t) {
1484
- const n = stops.length;
1485
- const scaled = Math.max(0, Math.min(1, t)) * (n - 1);
1486
- const i = Math.min(Math.floor(scaled), n - 2);
1487
- const a = stops[i];
1488
- const bStop = stops[i + 1];
1489
- const mix = scaled - i;
1490
- return {
1491
- r: Math.round(a.r + (bStop.r - a.r) * mix),
1492
- g: Math.round(a.g + (bStop.g - a.g) * mix),
1493
- b: Math.round(a.b + (bStop.b - a.b) * mix)
1494
- };
1533
+ }
1534
+ function applyColor(color) {
1535
+ if (Array.isArray(color)) {
1536
+ gradientOklab = color.map((c) => parseColorToOklab(c));
1537
+ colorRgb = oklabToRgb(gradientOklab[0]);
1538
+ } else {
1539
+ gradientOklab = null;
1540
+ colorRgb = colorToRgb(color);
1541
+ }
1495
1542
  }
1496
1543
  function calculateBoundaries(skel) {
1497
1544
  const b = computeBoundaries(skel, W, H);
@@ -1540,43 +1587,44 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1540
1587
  }
1541
1588
  }
1542
1589
  function draw() {
1543
- ctx.clearRect(0, 0, W, H);
1544
- if (bgCanvas) {
1545
- ctx.drawImage(bgCanvas, 0, 0);
1590
+ if (!bgImageData || !frameImageData) {
1591
+ return;
1546
1592
  }
1547
- const animOffset = currentTrailStyle === "gradient-animated" ? Math.abs(animTime / ANIM_PERIOD % 2 - 1) * 0.35 : 0;
1548
- for (let bucket = 0; bucket < NUM_BUCKETS; bucket++) {
1549
- const lo = bucket / NUM_BUCKETS;
1550
- const hi = (bucket + 1) / NUM_BUCKETS;
1551
- const midpoint = (lo + hi) / 2;
1552
- const alpha = 0.08 + midpoint * 0.92;
1553
- let hasLit = false;
1554
- ctx.beginPath();
1555
- for (let row = 0; row < rows; row++) {
1556
- for (let col = 0; col < cols; col++) {
1557
- const intensity = grid[row * cols + col];
1558
- if (intensity > lo && intensity <= hi) {
1559
- const cx = (col + 0.5) * cellW;
1560
- const cy = (row + 0.5) * cellH;
1561
- ctx.roundRect(cx - dotR, cy - dotR, dotR * 2, dotR * 2, roundness * dotR);
1562
- hasLit = true;
1563
- }
1564
- }
1565
- }
1566
- if (hasLit) {
1567
- if (gradientRgb !== null) {
1568
- const t = ((midpoint + animOffset) % 1 + 1) % 1;
1569
- const { r, g, b } = sampleGradientRgb(gradientRgb, t);
1570
- ctx.fillStyle = `rgb(${r},${g},${b})`;
1571
- } else {
1572
- const { r, g, b } = colorRgb;
1573
- ctx.fillStyle = `rgb(${r},${g},${b})`;
1574
- }
1575
- ctx.globalAlpha = alpha;
1576
- ctx.fill();
1593
+ frameImageData.data.set(bgImageData.data);
1594
+ const { data } = frameImageData;
1595
+ const timeOffset = currentTrailStyle === "gradient-animated" ? animTime / ANIM_PERIOD : 0;
1596
+ const n = cols * rows;
1597
+ for (let dotIdx = 0; dotIdx < n; dotIdx++) {
1598
+ const intensity = grid[dotIdx];
1599
+ if (intensity <= 0) {
1600
+ continue;
1601
+ }
1602
+ let r, g, b;
1603
+ if (gradientOklab !== null) {
1604
+ ({ r, g, b } = oklabToRgb(getPaletteColor(gradientOklab, intensity, timeOffset)));
1605
+ } else {
1606
+ ({ r, g, b } = colorRgb);
1607
+ }
1608
+ const baseA = (0.08 + intensity * 0.92) * 255;
1609
+ const start = pixelMaskStarts[dotIdx];
1610
+ const len = pixelMaskLengths[dotIdx];
1611
+ for (let k = 0; k < len; k++) {
1612
+ const px = pixelMaskIndices[start + k];
1613
+ const coverage = pixelMaskCoverages[start + k];
1614
+ data[px] = r;
1615
+ data[px + 1] = g;
1616
+ data[px + 2] = b;
1617
+ data[px + 3] = Math.round(baseA * coverage);
1577
1618
  }
1578
1619
  }
1579
- ctx.globalAlpha = 1;
1620
+ ctx.putImageData(frameImageData, 0, 0);
1621
+ }
1622
+ function completeMorphNow() {
1623
+ engine.completeMorph();
1624
+ morphResolve?.();
1625
+ morphResolve = null;
1626
+ morphReject = null;
1627
+ morphProgress = 0;
1580
1628
  }
1581
1629
  function renderFrame(deltaTime) {
1582
1630
  if (engine.morphAlpha !== null) {
@@ -1584,11 +1632,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1584
1632
  engine.setMorphAlpha(morphProgress);
1585
1633
  calculateBoundaries(engine.getSarmalSkeleton());
1586
1634
  if (morphProgress >= 1) {
1587
- engine.completeMorph();
1588
- morphResolve?.();
1589
- morphResolve = null;
1590
- morphReject = null;
1591
- morphProgress = 0;
1635
+ completeMorphNow();
1592
1636
  calculateBoundaries(engine.getSarmalSkeleton());
1593
1637
  }
1594
1638
  } else if (engine.isLiveSkeleton) {
@@ -1606,8 +1650,12 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1606
1650
  renderFrame(deltaTime);
1607
1651
  animationId = requestAnimationFrame(loop);
1608
1652
  }
1653
+ validateBaseRenderOptions({ trailColor: initialColor });
1654
+ applyColor(initialColor);
1609
1655
  calculateBoundaries(engine.getSarmalSkeleton());
1610
- buildBgCanvas();
1656
+ computePixelMask();
1657
+ frameImageData = new ImageData(W, H);
1658
+ buildBgImageData();
1611
1659
  if (initialPhase !== void 0) {
1612
1660
  engine.seek(initialPhase);
1613
1661
  }
@@ -1652,11 +1700,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1652
1700
  */
1653
1701
  morphTo(target, opts) {
1654
1702
  if (morphResolve !== null) {
1655
- engine.completeMorph();
1656
- morphResolve();
1657
- morphResolve = null;
1658
- morphReject = null;
1659
- morphProgress = 0;
1703
+ completeMorphNow();
1660
1704
  }
1661
1705
  morphDurationMs = opts?.duration ?? DEFAULT_MORPH_DURATION_MS;
1662
1706
  morphProgress = 0;
@@ -1677,13 +1721,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1677
1721
  validateBaseRenderOptions(partial);
1678
1722
  let needsRebuildBg = false;
1679
1723
  if (partial.trailColor !== void 0) {
1680
- if (Array.isArray(partial.trailColor)) {
1681
- gradientRgb = partial.trailColor.map(colorToRgb);
1682
- colorRgb = gradientRgb[0];
1683
- } else {
1684
- gradientRgb = null;
1685
- colorRgb = colorToRgb(partial.trailColor);
1686
- }
1724
+ applyColor(partial.trailColor);
1687
1725
  needsRebuildBg = true;
1688
1726
  }
1689
1727
  if (partial.trailStyle !== void 0) {
@@ -1693,11 +1731,15 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1693
1731
  }
1694
1732
  }
1695
1733
  if (needsRebuildBg) {
1696
- buildBgCanvas();
1734
+ buildBgImageData();
1697
1735
  }
1698
- if (currentTrailStyle !== "default" && gradientRgb === null) {
1736
+ if (currentTrailStyle !== "default" && gradientOklab === null) {
1737
+ console.warn(
1738
+ `[sarmal] dot matrix: trailColor is a single color but trailStyle is "${currentTrailStyle}"; the trail will render as a solid color. Pass an array of hex colors to use a real gradient.`
1739
+ );
1740
+ } else if (currentTrailStyle === "default" && gradientOklab !== null) {
1699
1741
  console.warn(
1700
- "[sarmal] dot matrix: gradient trailStyle has no effect without a trailColor array"
1742
+ '[sarmal] dot matrix: trailColor is an array but trailStyle is "default"; only the first color will be used. Pass a gradient trailStyle to use the whole palette.'
1701
1743
  );
1702
1744
  }
1703
1745
  }