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