@sarmal/core 0.5.0 → 0.7.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,8 +44,8 @@ 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,
@@ -53,15 +53,37 @@ function createEngine(curveDef, trailLength = 120) {
53
53
  skeleton: curveDef.skeleton,
54
54
  skeletonFn: curveDef.skeletonFn
55
55
  };
56
+ }
57
+ function createEngine(curveDef, trailLength = 120) {
58
+ let curve = resolveCurve(curveDef);
56
59
  const trail = new CircularBuffer(trailLength);
57
60
  let t = 0;
58
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
+ }
59
74
  return {
60
75
  tick(deltaTime) {
61
76
  t = (t + curve.speed * deltaTime) % curve.period;
62
77
  actualTime += deltaTime;
63
- const point = curve.fn(t, actualTime, {});
64
- 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
+ }
65
87
  return trail.toArray();
66
88
  },
67
89
  get trailCount() {
@@ -70,6 +92,9 @@ function createEngine(curveDef, trailLength = 120) {
70
92
  get isLiveSkeleton() {
71
93
  return curve.skeleton === "live";
72
94
  },
95
+ get morphAlpha() {
96
+ return _morphAlpha;
97
+ },
73
98
  reset() {
74
99
  t = 0;
75
100
  actualTime = 0;
@@ -98,24 +123,62 @@ function createEngine(curveDef, trailLength = 120) {
98
123
  trail.push(point.x, point.y);
99
124
  }
100
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 = frozenStrategy === "normalized" ? sampleT / frozenA.period * frozenB.period : sampleT;
138
+ const b = frozenB.fn(tB, time, params);
139
+ return {
140
+ x: a.x + (b.x - a.x) * frozenAlpha,
141
+ y: a.y + (b.y - a.y) * frozenAlpha
142
+ };
143
+ }
144
+ };
145
+ }
146
+ _morphStrategy = strategy;
147
+ morphCurveB = resolvedTarget;
148
+ _morphAlpha = 0;
149
+ },
150
+ setMorphAlpha(alpha) {
151
+ _morphAlpha = alpha;
152
+ },
153
+ completeMorph() {
154
+ if (morphCurveB !== null) {
155
+ if (_morphStrategy === "normalized" && curve.period !== morphCurveB.period) {
156
+ t = t / curve.period * morphCurveB.period;
157
+ }
158
+ curve = morphCurveB;
159
+ }
160
+ morphCurveB = null;
161
+ _morphAlpha = null;
162
+ },
101
163
  getSarmalSkeleton() {
102
164
  const steps = Math.ceil(curve.period * POINTS_PER_PERIOD_UNIT);
103
165
  const points = new Array(steps);
104
- if (curve.skeletonFn) {
166
+ if (morphCurveB !== null && _morphAlpha !== null) {
105
167
  for (let i = 0; i < steps; i++) {
106
168
  const sampleT = i / (steps - 1) * curve.period;
107
- points[i] = curve.skeletonFn(sampleT);
108
- }
109
- } else if (curve.skeleton === "live") {
110
- for (let i = 0; i < steps; i++) {
111
- const sampleT = i / (steps - 1) * curve.period;
112
- points[i] = curve.fn(sampleT, actualTime, {});
113
- }
114
- } else {
115
- for (let i = 0; i < steps; i++) {
116
- const sampleT = i / (steps - 1) * curve.period;
117
- points[i] = curve.fn(sampleT, 0, {});
169
+ const a = sampleSkeleton(curve, sampleT);
170
+ const tB = _morphStrategy === "normalized" ? sampleT / curve.period * morphCurveB.period : sampleT;
171
+ const b = sampleSkeleton(morphCurveB, tB);
172
+ points[i] = {
173
+ x: a.x + (b.x - a.x) * _morphAlpha,
174
+ y: a.y + (b.y - a.y) * _morphAlpha
175
+ };
118
176
  }
177
+ return points;
178
+ }
179
+ for (let i = 0; i < steps; i++) {
180
+ const sampleT = i / (steps - 1) * curve.period;
181
+ points[i] = sampleSkeleton(curve, sampleT);
119
182
  }
120
183
  return points;
121
184
  }
@@ -123,6 +186,7 @@ function createEngine(curveDef, trailLength = 120) {
123
186
  }
124
187
 
125
188
  // src/renderer.ts
189
+ var DEFAULT_MORPH_DURATION_MS = 300;
126
190
  var DEFAULT_HEAD_RADIUS = 4;
127
191
  var DEFAULT_GLOW_SIZE = 20;
128
192
  var DEFAULT_SKELETON_COLOR = "#ffffff";
@@ -165,25 +229,20 @@ function createRenderer(options) {
165
229
  let offsetY = 0;
166
230
  let animationId = null;
167
231
  let lastTime = 0;
168
- function calculateBoundaries() {
169
- if (skeleton.length === 0) {
170
- return;
171
- }
172
- const first = skeleton[0];
232
+ let morphResolve = null;
233
+ let morphDurationMs = DEFAULT_MORPH_DURATION_MS;
234
+ let morphAlpha = 0;
235
+ let morphBoundsA = null;
236
+ let morphBoundsB = null;
237
+ function computeBoundaries(pts) {
238
+ if (pts.length === 0) return null;
239
+ const first = pts[0];
173
240
  let minX = first.x, maxX = first.x, minY = first.y, maxY = first.y;
174
- for (const p of skeleton) {
175
- if (p.x < minX) {
176
- minX = p.x;
177
- }
178
- if (p.x > maxX) {
179
- maxX = p.x;
180
- }
181
- if (p.y < minY) {
182
- minY = p.y;
183
- }
184
- if (p.y > maxY) {
185
- maxY = p.y;
186
- }
241
+ for (const p of pts) {
242
+ if (p.x < minX) minX = p.x;
243
+ if (p.x > maxX) maxX = p.x;
244
+ if (p.y < minY) minY = p.y;
245
+ if (p.y > maxY) maxY = p.y;
187
246
  }
188
247
  const width = maxX - minX;
189
248
  const height = maxY - minY;
@@ -191,11 +250,22 @@ function createRenderer(options) {
191
250
  const canvasHeight = canvas.height;
192
251
  const scaleX = canvasWidth / (width * (1 + FIT_PADDING * 2));
193
252
  const scaleY = canvasHeight / (height * (1 + FIT_PADDING * 2));
194
- scale = Math.min(scaleX, scaleY);
195
- const boundsWidth = width * scale;
196
- const boundsHeight = height * scale;
197
- offsetX = (canvasWidth - boundsWidth) / 2 - minX * scale;
198
- offsetY = (canvasHeight - boundsHeight) / 2 - minY * scale;
253
+ const s = Math.min(scaleX, scaleY);
254
+ const boundsWidth = width * s;
255
+ const boundsHeight = height * s;
256
+ return {
257
+ scale: s,
258
+ offsetX: (canvasWidth - boundsWidth) / 2 - minX * s,
259
+ offsetY: (canvasHeight - boundsHeight) / 2 - minY * s
260
+ };
261
+ }
262
+ function calculateBoundaries() {
263
+ const b = computeBoundaries(skeleton);
264
+ if (b) {
265
+ scale = b.scale;
266
+ offsetX = b.offsetX;
267
+ offsetY = b.offsetY;
268
+ }
199
269
  }
200
270
  function buildSkeletonCanvas() {
201
271
  if (skeleton.length < 2) return;
@@ -212,10 +282,25 @@ function createRenderer(options) {
212
282
  }
213
283
  skeletonCtx.stroke();
214
284
  }
285
+ function drawSkeletonPath(pts, opacity) {
286
+ if (pts.length < 2) return;
287
+ ctx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${opacity})`;
288
+ ctx.lineWidth = 1.5;
289
+ ctx.beginPath();
290
+ ctx.moveTo(pts[0].x * scale + offsetX, pts[0].y * scale + offsetY);
291
+ for (let i = 1; i < pts.length; i++) {
292
+ ctx.lineTo(pts[i].x * scale + offsetX, pts[i].y * scale + offsetY);
293
+ }
294
+ ctx.stroke();
295
+ }
215
296
  function drawSkeleton() {
216
297
  if (opts.skeletonColor === "transparent") {
217
298
  return;
218
299
  }
300
+ if (engine.morphAlpha !== null) {
301
+ drawSkeletonPath(engine.getSarmalSkeleton(), DEFAULT_SKELETON_OPACITY);
302
+ return;
303
+ }
219
304
  if (engine.isLiveSkeleton) {
220
305
  if (skeleton.length < 2) {
221
306
  return;
@@ -282,11 +367,34 @@ function createRenderer(options) {
282
367
  const now = performance.now();
283
368
  const deltaTime = Math.min((now - lastTime) / 1e3, 1 / 30);
284
369
  lastTime = now;
370
+ if (engine.morphAlpha !== null) {
371
+ morphAlpha = Math.min(1, morphAlpha + deltaTime / (morphDurationMs / 1e3));
372
+ engine.setMorphAlpha(morphAlpha);
373
+ if (morphBoundsA && morphBoundsB) {
374
+ const a = morphBoundsA;
375
+ const b = morphBoundsB;
376
+ scale = a.scale + (b.scale - a.scale) * morphAlpha;
377
+ offsetX = a.offsetX + (b.offsetX - a.offsetX) * morphAlpha;
378
+ offsetY = a.offsetY + (b.offsetY - a.offsetY) * morphAlpha;
379
+ }
380
+ if (morphAlpha >= 1) {
381
+ engine.completeMorph();
382
+ morphResolve?.();
383
+ morphResolve = null;
384
+ morphAlpha = 0;
385
+ morphBoundsA = null;
386
+ morphBoundsB = null;
387
+ skeleton = engine.getSarmalSkeleton();
388
+ if (!engine.isLiveSkeleton) {
389
+ buildSkeletonCanvas();
390
+ }
391
+ }
392
+ }
285
393
  trail = engine.tick(deltaTime);
286
394
  trailCount = engine.trailCount;
287
395
  head = trailCount > 0 ? trail[trailCount - 1] : null;
288
396
  ctx.clearRect(0, 0, canvas.width, canvas.height);
289
- if (engine.isLiveSkeleton) {
397
+ if (engine.isLiveSkeleton && engine.morphAlpha === null) {
290
398
  skeleton = engine.getSarmalSkeleton();
291
399
  calculateBoundaries();
292
400
  }
@@ -331,11 +439,38 @@ function createRenderer(options) {
331
439
  },
332
440
  seekWithTrail(t) {
333
441
  engine.seekWithTrail(t);
442
+ },
443
+ morphTo(target, options2) {
444
+ const interruptBounds = morphResolve !== null ? { scale, offsetX, offsetY } : null;
445
+ if (morphResolve !== null) {
446
+ engine.completeMorph();
447
+ morphResolve();
448
+ morphResolve = null;
449
+ morphAlpha = 0;
450
+ morphBoundsA = null;
451
+ morphBoundsB = null;
452
+ }
453
+ morphDurationMs = options2?.duration ?? DEFAULT_MORPH_DURATION_MS;
454
+ morphAlpha = 0;
455
+ morphBoundsA = interruptBounds ?? computeBoundaries(engine.getSarmalSkeleton()) ?? { scale, offsetX, offsetY };
456
+ engine.startMorph(target, options2?.morphStrategy);
457
+ const period = target.period ?? Math.PI * 2;
458
+ const samples = Math.max(50, Math.round(period * 20));
459
+ const skeletonFn = target.skeletonFn ?? ((t) => target.fn(t, 0, {}));
460
+ const skeletonB = Array.from(
461
+ { length: samples + 1 },
462
+ (_, i) => skeletonFn(i / samples * period)
463
+ );
464
+ morphBoundsB = computeBoundaries(skeletonB) ?? { scale, offsetX, offsetY };
465
+ return new Promise((resolve) => {
466
+ morphResolve = resolve;
467
+ });
334
468
  }
335
469
  };
336
470
  }
337
471
 
338
472
  // src/renderer-svg.ts
473
+ var DEFAULT_MORPH_DURATION_MS2 = 300;
339
474
  var TRAIL_BATCH_COUNT = 12;
340
475
  var TRAIL_FADE_CURVE2 = 1.5;
341
476
  var TRAIL_MAX_OPACITY2 = 0.88;
@@ -400,6 +535,20 @@ function createSVGRenderer(options) {
400
535
  skeletonPath.setAttribute("stroke-opacity", String(DEFAULT_SKELETON_OPACITY2));
401
536
  skeletonPath.setAttribute("stroke-width", "1.5");
402
537
  svg.appendChild(skeletonPath);
538
+ const skeletonPathA = el("path");
539
+ skeletonPathA.setAttribute("fill", "none");
540
+ skeletonPathA.setAttribute("stroke", opts.skeletonColor);
541
+ skeletonPathA.setAttribute("stroke-width", "1.5");
542
+ skeletonPathA.setAttribute("visibility", "hidden");
543
+ svg.appendChild(skeletonPathA);
544
+ const skeletonPathB = el("path");
545
+ skeletonPathB.setAttribute("fill", "none");
546
+ skeletonPathB.setAttribute("stroke", opts.skeletonColor);
547
+ skeletonPathB.setAttribute("stroke-width", "1.5");
548
+ skeletonPathB.setAttribute("visibility", "hidden");
549
+ svg.appendChild(skeletonPathB);
550
+ let morphPathABuilt = "";
551
+ let morphPathBBuilt = "";
403
552
  const trailPaths = [];
404
553
  for (let i = 0; i < TRAIL_BATCH_COUNT; i++) {
405
554
  const path = el("path");
@@ -515,13 +664,69 @@ function createSVGRenderer(options) {
515
664
  let animationId = null;
516
665
  let lastTime = 0;
517
666
  const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
667
+ let morphResolve = null;
668
+ let morphDurationMs = DEFAULT_MORPH_DURATION_MS2;
669
+ let morphTarget = null;
670
+ let morphAlpha = 0;
671
+ function buildSkeletonPath(target, scale2, offsetX2, offsetY2) {
672
+ const period = target.period ?? Math.PI * 2;
673
+ const samples = Math.max(50, Math.round(period * 20));
674
+ const points = [];
675
+ for (let i = 0; i <= samples; i++) {
676
+ const t = i / samples * period;
677
+ const p = target.fn(t, 0, {});
678
+ points.push(p);
679
+ }
680
+ if (points.length < 2) {
681
+ return "";
682
+ }
683
+ const px2 = (p) => (p.x * scale2 + offsetX2).toFixed(2);
684
+ const py2 = (p) => (p.y * scale2 + offsetY2).toFixed(2);
685
+ let d = `M${px2(points[0])} ${py2(points[0])}`;
686
+ for (let i = 1; i < points.length; i++) {
687
+ d += ` L${px2(points[i])} ${py2(points[i])}`;
688
+ }
689
+ d += " Z";
690
+ return d;
691
+ }
518
692
  function renderFrame() {
519
693
  const now = performance.now();
520
694
  const dt = Math.min((now - lastTime) / 1e3, 1 / 30);
521
695
  lastTime = now;
696
+ if (engine.morphAlpha !== null) {
697
+ morphAlpha = Math.min(1, morphAlpha + dt / (morphDurationMs / 1e3));
698
+ engine.setMorphAlpha(morphAlpha);
699
+ if (morphPathABuilt) {
700
+ skeletonPathA.setAttribute("d", morphPathABuilt);
701
+ skeletonPathA.setAttribute("visibility", "visible");
702
+ skeletonPathA.setAttribute(
703
+ "stroke-opacity",
704
+ String((1 - morphAlpha) * DEFAULT_SKELETON_OPACITY2)
705
+ );
706
+ }
707
+ if (morphPathBBuilt) {
708
+ skeletonPathB.setAttribute("d", morphPathBBuilt);
709
+ skeletonPathB.setAttribute("visibility", "visible");
710
+ skeletonPathB.setAttribute("stroke-opacity", String(morphAlpha * DEFAULT_SKELETON_OPACITY2));
711
+ }
712
+ if (morphAlpha >= 1) {
713
+ engine.completeMorph();
714
+ morphResolve?.();
715
+ morphResolve = null;
716
+ morphTarget = null;
717
+ morphAlpha = 0;
718
+ morphPathABuilt = "";
719
+ morphPathBBuilt = "";
720
+ skeletonPathA.setAttribute("visibility", "hidden");
721
+ skeletonPathB.setAttribute("visibility", "hidden");
722
+ const newSkeleton = engine.getSarmalSkeleton();
723
+ calculateBoundaries(newSkeleton);
724
+ updateSkeleton(newSkeleton);
725
+ }
726
+ }
522
727
  const trail = engine.tick(dt);
523
728
  const trailCount = engine.trailCount;
524
- if (engine.isLiveSkeleton) {
729
+ if (engine.isLiveSkeleton && engine.morphAlpha === null) {
525
730
  const liveSkeleton = engine.getSarmalSkeleton();
526
731
  calculateBoundaries(liveSkeleton);
527
732
  updateSkeleton(liveSkeleton);
@@ -562,6 +767,38 @@ function createSVGRenderer(options) {
562
767
  },
563
768
  seekWithTrail(t) {
564
769
  engine.seekWithTrail(t);
770
+ },
771
+ morphTo(target, options2) {
772
+ if (morphResolve !== null) {
773
+ engine.completeMorph();
774
+ morphResolve();
775
+ morphResolve = null;
776
+ morphAlpha = 0;
777
+ skeletonPathA.setAttribute("visibility", "hidden");
778
+ skeletonPathB.setAttribute("visibility", "hidden");
779
+ }
780
+ morphDurationMs = options2?.duration ?? DEFAULT_MORPH_DURATION_MS2;
781
+ morphTarget = target;
782
+ morphAlpha = 0;
783
+ const currentSkeleton = engine.getSarmalSkeleton();
784
+ if (currentSkeleton.length >= 2) {
785
+ const px2 = (p) => (p.x * scale + offsetX).toFixed(2);
786
+ const py2 = (p) => (p.y * scale + offsetY).toFixed(2);
787
+ morphPathABuilt = `M${px2(currentSkeleton[0])} ${py2(currentSkeleton[0])}`;
788
+ for (let i = 1; i < currentSkeleton.length; i++) {
789
+ morphPathABuilt += ` L${px2(currentSkeleton[i])} ${py2(currentSkeleton[i])}`;
790
+ }
791
+ morphPathABuilt += " Z";
792
+ } else {
793
+ morphPathABuilt = "";
794
+ }
795
+ engine.startMorph(target, options2?.morphStrategy);
796
+ if (morphTarget) {
797
+ morphPathBBuilt = buildSkeletonPath(morphTarget, scale, offsetX, offsetY);
798
+ }
799
+ return new Promise((resolve) => {
800
+ morphResolve = resolve;
801
+ });
565
802
  }
566
803
  };
567
804
  }