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