@sarmal/core 0.4.2 → 0.6.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/index.cjs CHANGED
@@ -44,27 +44,57 @@ var CircularBuffer = class {
44
44
  return this.count;
45
45
  }
46
46
  };
47
- function createEngine(curveDef, trailLength = 120) {
48
- const curve = {
47
+ function resolveCurve(curveDef) {
48
+ return {
49
49
  name: curveDef.name,
50
50
  fn: curveDef.fn,
51
51
  period: curveDef.period ?? TWO_PI,
52
52
  speed: curveDef.speed ?? 1,
53
+ skeleton: curveDef.skeleton,
54
+ skeletonFn: curveDef.skeletonFn,
53
55
  };
56
+ }
57
+ function createEngine(curveDef, trailLength = 120) {
58
+ let curve = resolveCurve(curveDef);
54
59
  const trail = new CircularBuffer(trailLength);
55
60
  let t = 0;
56
61
  let actualTime = 0;
62
+ let morphCurveB = null;
63
+ let _morphAlpha = null;
64
+ let _morphStrategy = "normalized";
65
+ function sampleSkeleton(c, sampleT) {
66
+ if (c.skeletonFn) {
67
+ return c.skeletonFn(sampleT);
68
+ }
69
+ if (c.skeleton === "live") {
70
+ return c.fn(sampleT, actualTime, {});
71
+ }
72
+ return c.fn(sampleT, 0, {});
73
+ }
57
74
  return {
58
75
  tick(deltaTime) {
59
76
  t = (t + curve.speed * deltaTime) % curve.period;
60
77
  actualTime += deltaTime;
61
- const point = curve.fn(t, actualTime, {});
62
- trail.push(point.x, point.y);
78
+ if (morphCurveB !== null && _morphAlpha !== null) {
79
+ const a = curve.fn(t, actualTime, {});
80
+ const tB = _morphStrategy === "normalized" ? (t / curve.period) * morphCurveB.period : t;
81
+ const b = morphCurveB.fn(tB, actualTime, {});
82
+ trail.push(a.x + (b.x - a.x) * _morphAlpha, a.y + (b.y - a.y) * _morphAlpha);
83
+ } else {
84
+ const point = curve.fn(t, actualTime, {});
85
+ trail.push(point.x, point.y);
86
+ }
63
87
  return trail.toArray();
64
88
  },
65
89
  get trailCount() {
66
90
  return trail.length;
67
91
  },
92
+ get isLiveSkeleton() {
93
+ return curve.skeleton === "live";
94
+ },
95
+ get morphAlpha() {
96
+ return _morphAlpha;
97
+ },
68
98
  reset() {
69
99
  t = 0;
70
100
  actualTime = 0;
@@ -93,13 +123,65 @@ function createEngine(curveDef, trailLength = 120) {
93
123
  trail.push(point.x, point.y);
94
124
  }
95
125
  },
126
+ startMorph(target, strategy = "normalized") {
127
+ const resolvedTarget = resolveCurve(target);
128
+ if (morphCurveB !== null && _morphAlpha !== null) {
129
+ const frozenAlpha = _morphAlpha;
130
+ const frozenA = curve;
131
+ const frozenB = morphCurveB;
132
+ const frozenStrategy = _morphStrategy;
133
+ curve = {
134
+ ...frozenB,
135
+ fn: (sampleT, time, params) => {
136
+ const a = frozenA.fn(sampleT, time, params);
137
+ const tB =
138
+ frozenStrategy === "normalized"
139
+ ? (sampleT / frozenA.period) * frozenB.period
140
+ : sampleT;
141
+ const b = frozenB.fn(tB, time, params);
142
+ return {
143
+ x: a.x + (b.x - a.x) * frozenAlpha,
144
+ y: a.y + (b.y - a.y) * frozenAlpha,
145
+ };
146
+ },
147
+ };
148
+ }
149
+ _morphStrategy = strategy;
150
+ morphCurveB = resolvedTarget;
151
+ _morphAlpha = 0;
152
+ },
153
+ setMorphAlpha(alpha) {
154
+ _morphAlpha = alpha;
155
+ },
156
+ completeMorph() {
157
+ if (morphCurveB !== null) {
158
+ curve = morphCurveB;
159
+ }
160
+ morphCurveB = null;
161
+ _morphAlpha = null;
162
+ },
96
163
  getSarmalSkeleton() {
97
164
  const steps = Math.ceil(curve.period * POINTS_PER_PERIOD_UNIT);
98
165
  const points = new Array(steps);
166
+ if (morphCurveB !== null && _morphAlpha !== null) {
167
+ for (let i = 0; i < steps; i++) {
168
+ const sampleT = (i / (steps - 1)) * curve.period;
169
+ const a = sampleSkeleton(curve, sampleT);
170
+ const tB =
171
+ _morphStrategy === "normalized"
172
+ ? (sampleT / curve.period) * morphCurveB.period
173
+ : sampleT;
174
+ const b = sampleSkeleton(morphCurveB, tB);
175
+ points[i] = {
176
+ x: a.x + (b.x - a.x) * _morphAlpha,
177
+ y: a.y + (b.y - a.y) * _morphAlpha,
178
+ };
179
+ }
180
+ return points;
181
+ }
99
182
  for (let i = 0; i < steps; i++) {
100
183
  const sampleT = (i / (steps - 1)) * curve.period;
101
- const point = curve.fn(sampleT, 0, {});
102
- points[i] = point;
184
+ points[i] = sampleSkeleton(curve, sampleT);
103
185
  }
104
186
  return points;
105
187
  },
@@ -107,6 +189,7 @@ function createEngine(curveDef, trailLength = 120) {
107
189
  }
108
190
 
109
191
  // src/renderer.ts
192
+ var DEFAULT_MORPH_DURATION_MS = 300;
110
193
  var DEFAULT_HEAD_RADIUS = 4;
111
194
  var DEFAULT_GLOW_SIZE = 20;
112
195
  var DEFAULT_SKELETON_COLOR = "#ffffff";
@@ -149,6 +232,12 @@ function createRenderer(options) {
149
232
  let offsetY = 0;
150
233
  let animationId = null;
151
234
  let lastTime = 0;
235
+ let morphResolve = null;
236
+ let morphDurationMs = DEFAULT_MORPH_DURATION_MS;
237
+ let morphTarget = null;
238
+ let morphAlpha = 0;
239
+ let skeletonCanvasA = null;
240
+ let skeletonCanvasB = null;
152
241
  function calculateBoundaries() {
153
242
  if (skeleton.length === 0) {
154
243
  return;
@@ -200,10 +289,38 @@ function createRenderer(options) {
200
289
  skeletonCtx.stroke();
201
290
  }
202
291
  function drawSkeleton() {
203
- if (!skeletonCanvas || opts.skeletonColor === "transparent") {
292
+ if (opts.skeletonColor === "transparent") {
293
+ return;
294
+ }
295
+ if (engine.morphAlpha !== null) {
296
+ if (skeletonCanvasA) {
297
+ ctx.globalAlpha = (1 - morphAlpha) * DEFAULT_SKELETON_OPACITY;
298
+ ctx.drawImage(skeletonCanvasA, 0, 0);
299
+ }
300
+ if (skeletonCanvasB) {
301
+ ctx.globalAlpha = morphAlpha * DEFAULT_SKELETON_OPACITY;
302
+ ctx.drawImage(skeletonCanvasB, 0, 0);
303
+ }
304
+ ctx.globalAlpha = 1;
204
305
  return;
205
306
  }
206
- ctx.drawImage(skeletonCanvas, 0, 0);
307
+ if (engine.isLiveSkeleton) {
308
+ if (skeleton.length < 2) {
309
+ return;
310
+ }
311
+ ctx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
312
+ ctx.lineWidth = 1.5;
313
+ ctx.beginPath();
314
+ const first = skeleton[0];
315
+ ctx.moveTo(first.x * scale + offsetX, first.y * scale + offsetY);
316
+ for (let i = 1; i < skeleton.length; i++) {
317
+ const p = skeleton[i];
318
+ ctx.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
319
+ }
320
+ ctx.stroke();
321
+ } else if (skeletonCanvas) {
322
+ ctx.drawImage(skeletonCanvas, 0, 0);
323
+ }
207
324
  }
208
325
  function drawTrail() {
209
326
  if (trailCount < 2) {
@@ -253,10 +370,29 @@ function createRenderer(options) {
253
370
  const now = performance.now();
254
371
  const deltaTime = Math.min((now - lastTime) / 1e3, 1 / 30);
255
372
  lastTime = now;
373
+ if (engine.morphAlpha !== null) {
374
+ morphAlpha = Math.min(1, morphAlpha + deltaTime / (morphDurationMs / 1e3));
375
+ engine.setMorphAlpha(morphAlpha);
376
+ skeleton = engine.getSarmalSkeleton();
377
+ calculateBoundaries();
378
+ if (morphAlpha >= 1) {
379
+ engine.completeMorph();
380
+ morphResolve?.();
381
+ morphResolve = null;
382
+ morphTarget = null;
383
+ morphAlpha = 0;
384
+ skeletonCanvasA = null;
385
+ skeletonCanvasB = null;
386
+ }
387
+ }
256
388
  trail = engine.tick(deltaTime);
257
389
  trailCount = engine.trailCount;
258
390
  head = trailCount > 0 ? trail[trailCount - 1] : null;
259
391
  ctx.clearRect(0, 0, canvas.width, canvas.height);
392
+ if (engine.isLiveSkeleton || engine.morphAlpha !== null) {
393
+ skeleton = engine.getSarmalSkeleton();
394
+ calculateBoundaries();
395
+ }
260
396
  drawSkeleton();
261
397
  drawTrail();
262
398
  drawHead();
@@ -264,7 +400,9 @@ function createRenderer(options) {
264
400
  }
265
401
  skeleton = engine.getSarmalSkeleton();
266
402
  calculateBoundaries();
267
- buildSkeletonCanvas();
403
+ if (!engine.isLiveSkeleton) {
404
+ buildSkeletonCanvas();
405
+ }
268
406
  return {
269
407
  start() {
270
408
  if (animationId !== null) {
@@ -297,10 +435,60 @@ function createRenderer(options) {
297
435
  seekWithTrail(t) {
298
436
  engine.seekWithTrail(t);
299
437
  },
438
+ morphTo(target, options2) {
439
+ if (morphResolve !== null) {
440
+ engine.completeMorph();
441
+ morphResolve();
442
+ morphResolve = null;
443
+ morphAlpha = 0;
444
+ skeletonCanvasA = null;
445
+ skeletonCanvasB = null;
446
+ }
447
+ morphDurationMs = options2?.duration ?? DEFAULT_MORPH_DURATION_MS;
448
+ morphTarget = target;
449
+ morphAlpha = 0;
450
+ const currentSkeleton = engine.getSarmalSkeleton();
451
+ if (currentSkeleton.length >= 2) {
452
+ skeletonCanvasA = new OffscreenCanvas(canvas.width, canvas.height);
453
+ const ctxA = skeletonCanvasA.getContext("2d");
454
+ ctxA.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
455
+ ctxA.lineWidth = 1.5;
456
+ ctxA.beginPath();
457
+ const first = currentSkeleton[0];
458
+ ctxA.moveTo(first.x * scale + offsetX, first.y * scale + offsetY);
459
+ for (let i = 1; i < currentSkeleton.length; i++) {
460
+ const p = currentSkeleton[i];
461
+ ctxA.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
462
+ }
463
+ ctxA.stroke();
464
+ }
465
+ engine.startMorph(target, options2?.morphStrategy);
466
+ if (morphTarget && !engine.isLiveSkeleton) {
467
+ skeletonCanvasB = new OffscreenCanvas(canvas.width, canvas.height);
468
+ const skeletonCtx = skeletonCanvasB.getContext("2d");
469
+ skeletonCtx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
470
+ skeletonCtx.lineWidth = 1.5;
471
+ skeletonCtx.beginPath();
472
+ const period = morphTarget.period ?? Math.PI * 2;
473
+ const samples = Math.max(50, Math.round(period * 20));
474
+ const firstB = morphTarget.fn(0, 0, {});
475
+ skeletonCtx.moveTo(firstB.x * scale + offsetX, firstB.y * scale + offsetY);
476
+ for (let i = 1; i <= samples; i++) {
477
+ const t = (i / samples) * period;
478
+ const p = morphTarget.fn(t, 0, {});
479
+ skeletonCtx.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
480
+ }
481
+ skeletonCtx.stroke();
482
+ }
483
+ return new Promise((resolve) => {
484
+ morphResolve = resolve;
485
+ });
486
+ },
300
487
  };
301
488
  }
302
489
 
303
490
  // src/renderer-svg.ts
491
+ var DEFAULT_MORPH_DURATION_MS2 = 300;
304
492
  var TRAIL_BATCH_COUNT = 12;
305
493
  var TRAIL_FADE_CURVE2 = 1.5;
306
494
  var TRAIL_MAX_OPACITY2 = 0.88;
@@ -365,6 +553,20 @@ function createSVGRenderer(options) {
365
553
  skeletonPath.setAttribute("stroke-opacity", String(DEFAULT_SKELETON_OPACITY2));
366
554
  skeletonPath.setAttribute("stroke-width", "1.5");
367
555
  svg.appendChild(skeletonPath);
556
+ const skeletonPathA = el("path");
557
+ skeletonPathA.setAttribute("fill", "none");
558
+ skeletonPathA.setAttribute("stroke", opts.skeletonColor);
559
+ skeletonPathA.setAttribute("stroke-width", "1.5");
560
+ skeletonPathA.setAttribute("visibility", "hidden");
561
+ svg.appendChild(skeletonPathA);
562
+ const skeletonPathB = el("path");
563
+ skeletonPathB.setAttribute("fill", "none");
564
+ skeletonPathB.setAttribute("stroke", opts.skeletonColor);
565
+ skeletonPathB.setAttribute("stroke-width", "1.5");
566
+ skeletonPathB.setAttribute("visibility", "hidden");
567
+ svg.appendChild(skeletonPathB);
568
+ let morphPathABuilt = "";
569
+ let morphPathBBuilt = "";
368
570
  const trailPaths = [];
369
571
  for (let i = 0; i < TRAIL_BATCH_COUNT; i++) {
370
572
  const path = el("path");
@@ -424,16 +626,23 @@ function createSVGRenderer(options) {
424
626
  function py(p) {
425
627
  return (p.y * scale + offsetY).toFixed(2);
426
628
  }
427
- const skeleton = engine.getSarmalSkeleton();
428
- calculateBoundaries(skeleton);
429
- if (skeleton.length >= 2) {
430
- let d = `M${px(skeleton[0])} ${py(skeleton[0])}`;
431
- for (let i = 1; i < skeleton.length; i++) {
432
- d += ` L${px(skeleton[i])} ${py(skeleton[i])}`;
629
+ function updateSkeleton(skeleton2) {
630
+ if (skeleton2.length < 2) {
631
+ skeletonPath.setAttribute("d", "");
632
+ return;
633
+ }
634
+ let d = `M${px(skeleton2[0])} ${py(skeleton2[0])}`;
635
+ for (let i = 1; i < skeleton2.length; i++) {
636
+ d += ` L${px(skeleton2[i])} ${py(skeleton2[i])}`;
433
637
  }
434
638
  d += " Z";
435
639
  skeletonPath.setAttribute("d", d);
436
640
  }
641
+ const skeleton = engine.getSarmalSkeleton();
642
+ calculateBoundaries(skeleton);
643
+ if (!engine.isLiveSkeleton) {
644
+ updateSkeleton(skeleton);
645
+ }
437
646
  function updateTrail(trail, trailCount) {
438
647
  if (trailCount < 2) {
439
648
  for (const p of trailPaths) {
@@ -477,12 +686,78 @@ function createSVGRenderer(options) {
477
686
  let lastTime = 0;
478
687
  const prefersReducedMotion =
479
688
  typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
689
+ let morphResolve = null;
690
+ let morphDurationMs = DEFAULT_MORPH_DURATION_MS2;
691
+ let morphTarget = null;
692
+ let morphAlpha = 0;
693
+ function buildSkeletonPath(target, scale2, offsetX2, offsetY2) {
694
+ const period = target.period ?? Math.PI * 2;
695
+ const samples = Math.max(50, Math.round(period * 20));
696
+ const points = [];
697
+ for (let i = 0; i <= samples; i++) {
698
+ const t = (i / samples) * period;
699
+ const p = target.fn(t, 0, {});
700
+ points.push(p);
701
+ }
702
+ if (points.length < 2) {
703
+ return "";
704
+ }
705
+ const px2 = (p) => (p.x * scale2 + offsetX2).toFixed(2);
706
+ const py2 = (p) => (p.y * scale2 + offsetY2).toFixed(2);
707
+ let d = `M${px2(points[0])} ${py2(points[0])}`;
708
+ for (let i = 1; i < points.length; i++) {
709
+ d += ` L${px2(points[i])} ${py2(points[i])}`;
710
+ }
711
+ d += " Z";
712
+ return d;
713
+ }
480
714
  function renderFrame() {
481
715
  const now = performance.now();
482
716
  const dt = Math.min((now - lastTime) / 1e3, 1 / 30);
483
717
  lastTime = now;
718
+ if (engine.morphAlpha !== null) {
719
+ morphAlpha = Math.min(1, morphAlpha + dt / (morphDurationMs / 1e3));
720
+ engine.setMorphAlpha(morphAlpha);
721
+ const morphSkeleton = engine.getSarmalSkeleton();
722
+ calculateBoundaries(morphSkeleton);
723
+ if (!engine.isLiveSkeleton) {
724
+ updateSkeleton(morphSkeleton);
725
+ }
726
+ if (morphPathABuilt) {
727
+ skeletonPathA.setAttribute("d", morphPathABuilt);
728
+ skeletonPathA.setAttribute("visibility", "visible");
729
+ skeletonPathA.setAttribute(
730
+ "stroke-opacity",
731
+ String((1 - morphAlpha) * DEFAULT_SKELETON_OPACITY2),
732
+ );
733
+ }
734
+ if (morphPathBBuilt) {
735
+ skeletonPathB.setAttribute("d", morphPathBBuilt);
736
+ skeletonPathB.setAttribute("visibility", "visible");
737
+ skeletonPathB.setAttribute(
738
+ "stroke-opacity",
739
+ String(morphAlpha * DEFAULT_SKELETON_OPACITY2),
740
+ );
741
+ }
742
+ if (morphAlpha >= 1) {
743
+ engine.completeMorph();
744
+ morphResolve?.();
745
+ morphResolve = null;
746
+ morphTarget = null;
747
+ morphAlpha = 0;
748
+ morphPathABuilt = "";
749
+ morphPathBBuilt = "";
750
+ skeletonPathA.setAttribute("visibility", "hidden");
751
+ skeletonPathB.setAttribute("visibility", "hidden");
752
+ }
753
+ }
484
754
  const trail = engine.tick(dt);
485
755
  const trailCount = engine.trailCount;
756
+ if (engine.isLiveSkeleton) {
757
+ const liveSkeleton = engine.getSarmalSkeleton();
758
+ calculateBoundaries(liveSkeleton);
759
+ updateSkeleton(liveSkeleton);
760
+ }
486
761
  updateTrail(trail, trailCount);
487
762
  updateHead(trail, trailCount);
488
763
  if (!prefersReducedMotion) {
@@ -520,6 +795,38 @@ function createSVGRenderer(options) {
520
795
  seekWithTrail(t) {
521
796
  engine.seekWithTrail(t);
522
797
  },
798
+ morphTo(target, options2) {
799
+ if (morphResolve !== null) {
800
+ engine.completeMorph();
801
+ morphResolve();
802
+ morphResolve = null;
803
+ morphAlpha = 0;
804
+ skeletonPathA.setAttribute("visibility", "hidden");
805
+ skeletonPathB.setAttribute("visibility", "hidden");
806
+ }
807
+ morphDurationMs = options2?.duration ?? DEFAULT_MORPH_DURATION_MS2;
808
+ morphTarget = target;
809
+ morphAlpha = 0;
810
+ const currentSkeleton = engine.getSarmalSkeleton();
811
+ if (currentSkeleton.length >= 2) {
812
+ const px2 = (p) => (p.x * scale + offsetX).toFixed(2);
813
+ const py2 = (p) => (p.y * scale + offsetY).toFixed(2);
814
+ morphPathABuilt = `M${px2(currentSkeleton[0])} ${py2(currentSkeleton[0])}`;
815
+ for (let i = 1; i < currentSkeleton.length; i++) {
816
+ morphPathABuilt += ` L${px2(currentSkeleton[i])} ${py2(currentSkeleton[i])}`;
817
+ }
818
+ morphPathABuilt += " Z";
819
+ } else {
820
+ morphPathABuilt = "";
821
+ }
822
+ engine.startMorph(target, options2?.morphStrategy);
823
+ if (morphTarget) {
824
+ morphPathBBuilt = buildSkeletonPath(morphTarget, scale, offsetX, offsetY);
825
+ }
826
+ return new Promise((resolve) => {
827
+ morphResolve = resolve;
828
+ });
829
+ },
523
830
  };
524
831
  }
525
832
  function createSarmalSVG(container, curveDef, options) {
@@ -549,6 +856,13 @@ function epitrochoid7(t, _time, _params) {
549
856
  y: 7 * Math.sin(t) - d * Math.sin(7 * t),
550
857
  };
551
858
  }
859
+ function epitrochoid7Skeleton(t) {
860
+ const d = 1.275;
861
+ return {
862
+ x: 7 * Math.cos(t) - d * Math.cos(7 * t),
863
+ y: 7 * Math.sin(t) - d * Math.sin(7 * t),
864
+ };
865
+ }
552
866
  function astroid(t, _time, _params) {
553
867
  const c = Math.cos(t);
554
868
  const s = Math.sin(t);
@@ -618,6 +932,7 @@ var curves = {
618
932
  fn: epitrochoid7,
619
933
  period: TWO_PI2,
620
934
  speed: 1.4,
935
+ skeletonFn: epitrochoid7Skeleton,
621
936
  },
622
937
  astroid: {
623
938
  name: "Astroid",
@@ -648,12 +963,14 @@ var curves = {
648
963
  fn: lissajous32,
649
964
  period: TWO_PI2,
650
965
  speed: 2,
966
+ skeleton: "live",
651
967
  },
652
968
  lissajous43: {
653
969
  name: "Lissajous 4:3",
654
970
  fn: lissajous43,
655
971
  period: TWO_PI2,
656
972
  speed: 1.8,
973
+ skeleton: "live",
657
974
  },
658
975
  epicycloid3: {
659
976
  name: "Epicycloid (n=3)",
@@ -666,6 +983,7 @@ var curves = {
666
983
  fn: lame,
667
984
  period: TWO_PI2,
668
985
  speed: 1,
986
+ skeleton: "live",
669
987
  },
670
988
  };
671
989