@sarmal/core 0.2.1 → 0.4.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
@@ -9,7 +9,7 @@ var CircularBuffer = class {
9
9
  this.data = Array.from({ length: capacity }, () => ({ x: 0, y: 0 }));
10
10
  this.result = Array.from({ length: capacity }, () => ({ x: 0, y: 0 }));
11
11
  }
12
- /** Mutates the pre-allocated slot in-place — no allocation */
12
+ /** Mutates in-place */
13
13
  push(x, y) {
14
14
  const slot = this.data[this.head];
15
15
  slot.x = x;
@@ -20,9 +20,9 @@ var CircularBuffer = class {
20
20
  }
21
21
  }
22
22
  /**
23
- * Copies ordered points into the pre-allocated result buffer and returns it.
24
- * The same array reference is returned every call — result.length is always `capacity`.
25
- * Read only indices 0..count-1; the rest are stale pre-allocated slots.
23
+ * Copies ordered points into the pre-allocated result buffer and returns it
24
+ * Note: The *same* array reference is returned every call,
25
+ * so `result.length` is also always `capacity`
26
26
  */
27
27
  toArray() {
28
28
  const start = this.count < this.capacity ? 0 : this.head;
@@ -68,6 +68,30 @@ function createEngine(curveDef, trailLength = 120) {
68
68
  actualTime = 0;
69
69
  trail.clear();
70
70
  },
71
+ seek(newT, { clearTrail = false } = {}) {
72
+ t = (newT % curve.period + curve.period) % curve.period;
73
+ if (clearTrail) {
74
+ trail.clear();
75
+ }
76
+ },
77
+ seekWithTrail(targetT, { wrap = false } = {}) {
78
+ const STEP = 1 / 60;
79
+ const advance = curve.speed * STEP;
80
+ const target = (targetT % curve.period + curve.period) % curve.period;
81
+ const targetTime = target / curve.speed;
82
+ t = target;
83
+ actualTime = targetTime;
84
+ trail.clear();
85
+ const pointsFromStart = Math.floor(target / advance) + 1;
86
+ const count = wrap ? trailLength : Math.min(trailLength, pointsFromStart);
87
+ for (let step = count - 1; step >= 0; step--) {
88
+ const sampleT = target - step * advance;
89
+ const wrappedT = sampleT < 0 ? sampleT + curve.period : sampleT;
90
+ const time = targetTime - step * STEP;
91
+ const point = curve.fn(wrappedT, time, {});
92
+ trail.push(point.x, point.y);
93
+ }
94
+ },
71
95
  getSarmalSkeleton() {
72
96
  const steps = Math.ceil(curve.period * POINTS_PER_PERIOD_UNIT);
73
97
  const points = new Array(steps);
@@ -131,10 +155,18 @@ function createRenderer(options) {
131
155
  const first = skeleton[0];
132
156
  let minX = first.x, maxX = first.x, minY = first.y, maxY = first.y;
133
157
  for (const p of skeleton) {
134
- if (p.x < minX) minX = p.x;
135
- if (p.x > maxX) maxX = p.x;
136
- if (p.y < minY) minY = p.y;
137
- if (p.y > maxY) maxY = p.y;
158
+ if (p.x < minX) {
159
+ minX = p.x;
160
+ }
161
+ if (p.x > maxX) {
162
+ maxX = p.x;
163
+ }
164
+ if (p.y < minY) {
165
+ minY = p.y;
166
+ }
167
+ if (p.y > maxY) {
168
+ maxY = p.y;
169
+ }
138
170
  }
139
171
  const width = maxX - minX;
140
172
  const height = maxY - minY;
@@ -151,38 +183,42 @@ function createRenderer(options) {
151
183
  function buildSkeletonCanvas() {
152
184
  if (skeleton.length < 2) return;
153
185
  skeletonCanvas = new OffscreenCanvas(canvas.width, canvas.height);
154
- const sCtx = skeletonCanvas.getContext("2d");
155
- sCtx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
156
- sCtx.lineWidth = 1.5;
157
- sCtx.beginPath();
186
+ const skeletonCtx = skeletonCanvas.getContext("2d");
187
+ skeletonCtx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
188
+ skeletonCtx.lineWidth = 1.5;
189
+ skeletonCtx.beginPath();
158
190
  const first = skeleton[0];
159
- sCtx.moveTo(first.x * scale + offsetX, first.y * scale + offsetY);
191
+ skeletonCtx.moveTo(first.x * scale + offsetX, first.y * scale + offsetY);
160
192
  for (let i = 1; i < skeleton.length; i++) {
161
193
  const p = skeleton[i];
162
- sCtx.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
194
+ skeletonCtx.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
163
195
  }
164
- sCtx.stroke();
196
+ skeletonCtx.stroke();
165
197
  }
166
198
  function drawSkeleton() {
167
- if (!skeletonCanvas) return;
199
+ if (!skeletonCanvas) {
200
+ return;
201
+ }
168
202
  ctx.drawImage(skeletonCanvas, 0, 0);
169
203
  }
170
204
  function drawTrail() {
171
- if (trailCount < 2) return;
205
+ if (trailCount < 2) {
206
+ return;
207
+ }
172
208
  ctx.lineJoin = "round";
173
209
  ctx.lineCap = "round";
174
- for (let b = 0; b < trailCount - 1; b += TRAIL_BATCH_SIZE) {
175
- const bEnd = Math.min(b + TRAIL_BATCH_SIZE, trailCount - 1);
176
- const progress = (b + bEnd) / 2 / (trailCount - 1);
210
+ for (let batchIndex = 0; batchIndex < trailCount - 1; batchIndex += TRAIL_BATCH_SIZE) {
211
+ const bEnd = Math.min(batchIndex + TRAIL_BATCH_SIZE, trailCount - 1);
212
+ const progress = (batchIndex + bEnd) / 2 / (trailCount - 1);
177
213
  const alpha = Math.pow(progress, TRAIL_FADE_CURVE) * TRAIL_MAX_OPACITY;
178
214
  const lineWidth = TRAIL_MIN_WIDTH + progress * (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH);
179
215
  ctx.beginPath();
180
- for (let i = b; i <= bEnd; i++) {
181
- const p = trail[i];
182
- if (i === b) {
183
- ctx.moveTo(p.x * scale + offsetX, p.y * scale + offsetY);
216
+ for (let i = batchIndex; i <= bEnd; i++) {
217
+ const point = trail[i];
218
+ if (i === batchIndex) {
219
+ ctx.moveTo(point.x * scale + offsetX, point.y * scale + offsetY);
184
220
  } else {
185
- ctx.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
221
+ ctx.lineTo(point.x * scale + offsetX, point.y * scale + offsetY);
186
222
  }
187
223
  }
188
224
  ctx.strokeStyle = `rgba(${trailRgb},${alpha})`;
@@ -191,7 +227,9 @@ function createRenderer(options) {
191
227
  }
192
228
  }
193
229
  function drawHead() {
194
- if (!head) return;
230
+ if (!head) {
231
+ return;
232
+ }
195
233
  const x = head.x * scale + offsetX;
196
234
  const y = head.y * scale + offsetY;
197
235
  const gradient = ctx.createRadialGradient(x, y, 0, x, y, opts.glowSize);
@@ -209,7 +247,7 @@ function createRenderer(options) {
209
247
  }
210
248
  function render() {
211
249
  const now = performance.now();
212
- const deltaTime = (now - lastTime) / 1e3;
250
+ const deltaTime = Math.min((now - lastTime) / 1e3, 1 / 30);
213
251
  lastTime = now;
214
252
  trail = engine.tick(deltaTime);
215
253
  trailCount = engine.trailCount;
@@ -225,12 +263,16 @@ function createRenderer(options) {
225
263
  buildSkeletonCanvas();
226
264
  return {
227
265
  start() {
228
- if (animationId !== null) return;
266
+ if (animationId !== null) {
267
+ return;
268
+ }
229
269
  lastTime = performance.now();
230
270
  render();
231
271
  },
232
272
  stop() {
233
- if (animationId === null) return;
273
+ if (animationId === null) {
274
+ return;
275
+ }
234
276
  cancelAnimationFrame(animationId);
235
277
  animationId = null;
236
278
  },
@@ -244,10 +286,240 @@ function createRenderer(options) {
244
286
  cancelAnimationFrame(animationId);
245
287
  animationId = null;
246
288
  }
289
+ },
290
+ seek(t, options2) {
291
+ engine.seek(t, options2);
292
+ },
293
+ seekWithTrail(t) {
294
+ engine.seekWithTrail(t);
247
295
  }
248
296
  };
249
297
  }
250
298
 
299
+ // src/renderer-svg.ts
300
+ var TRAIL_BATCH_COUNT = 12;
301
+ var TRAIL_FADE_CURVE2 = 1.5;
302
+ var TRAIL_MAX_OPACITY2 = 0.88;
303
+ var TRAIL_MIN_WIDTH2 = 0.5;
304
+ var TRAIL_MAX_WIDTH2 = 2.5;
305
+ var DEFAULT_SKELETON_OPACITY2 = 0.15;
306
+ var DEFAULT_GLOW_INNER_STOP = 0.4;
307
+ var DEFAULT_GLOW_FALLOFF_OPACITY = 0.53;
308
+ var FIT_PADDING2 = 0.1;
309
+ var instanceCount = 0;
310
+ function el(tag) {
311
+ return document.createElementNS("http://www.w3.org/2000/svg", tag);
312
+ }
313
+ function createSVGRenderer(options) {
314
+ const { container, engine } = options;
315
+ const opts = {
316
+ skeletonColor: options.skeletonColor ?? "#ffffff",
317
+ trailColor: options.trailColor ?? "#ffffff",
318
+ headColor: options.headColor ?? "#ffffff",
319
+ headRadius: options.headRadius ?? 4,
320
+ glowSize: options.glowSize ?? 20,
321
+ ariaLabel: options.ariaLabel ?? "Loading"
322
+ };
323
+ const uid = ++instanceCount;
324
+ const gradientId = `sarmal-glow-${uid}`;
325
+ const rect = container.getBoundingClientRect();
326
+ const width = rect.width || 200;
327
+ const height = rect.height || 200;
328
+ const svg = el("svg");
329
+ svg.setAttribute("width", String(width));
330
+ svg.setAttribute("height", String(height));
331
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
332
+ svg.setAttribute("role", "img");
333
+ svg.setAttribute("aria-label", opts.ariaLabel);
334
+ const titleEl = el("title");
335
+ titleEl.textContent = opts.ariaLabel;
336
+ svg.appendChild(titleEl);
337
+ const defs = el("defs");
338
+ const gradient = el("radialGradient");
339
+ gradient.id = gradientId;
340
+ gradient.setAttribute("cx", "50%");
341
+ gradient.setAttribute("cy", "50%");
342
+ gradient.setAttribute("r", "50%");
343
+ const stop0 = el("stop");
344
+ stop0.setAttribute("offset", "0%");
345
+ stop0.setAttribute("stop-color", opts.headColor);
346
+ stop0.setAttribute("stop-opacity", "1");
347
+ const stopMid = el("stop");
348
+ stopMid.setAttribute("offset", `${DEFAULT_GLOW_INNER_STOP * 100}%`);
349
+ stopMid.setAttribute("stop-color", opts.headColor);
350
+ stopMid.setAttribute("stop-opacity", String(DEFAULT_GLOW_FALLOFF_OPACITY));
351
+ const stop1 = el("stop");
352
+ stop1.setAttribute("offset", "100%");
353
+ stop1.setAttribute("stop-color", opts.headColor);
354
+ stop1.setAttribute("stop-opacity", "0");
355
+ gradient.append(stop0, stopMid, stop1);
356
+ defs.appendChild(gradient);
357
+ svg.appendChild(defs);
358
+ const skeletonPath = el("path");
359
+ skeletonPath.setAttribute("fill", "none");
360
+ skeletonPath.setAttribute("stroke", opts.skeletonColor);
361
+ skeletonPath.setAttribute("stroke-opacity", String(DEFAULT_SKELETON_OPACITY2));
362
+ skeletonPath.setAttribute("stroke-width", "1.5");
363
+ svg.appendChild(skeletonPath);
364
+ const trailPaths = [];
365
+ for (let i = 0; i < TRAIL_BATCH_COUNT; i++) {
366
+ const path = el("path");
367
+ path.setAttribute("fill", "none");
368
+ path.setAttribute("stroke", opts.trailColor);
369
+ path.setAttribute("stroke-linecap", "round");
370
+ path.setAttribute("stroke-linejoin", "round");
371
+ svg.appendChild(path);
372
+ trailPaths.push(path);
373
+ }
374
+ const glowCircle = el("circle");
375
+ glowCircle.setAttribute("fill", `url(#${gradientId})`);
376
+ glowCircle.setAttribute("r", String(opts.glowSize));
377
+ svg.appendChild(glowCircle);
378
+ const headCircle = el("circle");
379
+ headCircle.setAttribute("fill", opts.headColor);
380
+ headCircle.setAttribute("r", String(opts.headRadius));
381
+ svg.appendChild(headCircle);
382
+ container.appendChild(svg);
383
+ let scale = 1;
384
+ let offsetX = 0;
385
+ let offsetY = 0;
386
+ function calculateBoundaries(skeleton2) {
387
+ if (skeleton2.length === 0) {
388
+ return;
389
+ }
390
+ const first = skeleton2[0];
391
+ let minX = first.x, maxX = first.x, minY = first.y, maxY = first.y;
392
+ for (const p of skeleton2) {
393
+ if (p.x < minX) {
394
+ minX = p.x;
395
+ }
396
+ if (p.x > maxX) {
397
+ maxX = p.x;
398
+ }
399
+ if (p.y < minY) {
400
+ minY = p.y;
401
+ }
402
+ if (p.y > maxY) {
403
+ maxY = p.y;
404
+ }
405
+ }
406
+ const w = maxX - minX;
407
+ const h = maxY - minY;
408
+ const scaleX = width / (w * (1 + FIT_PADDING2 * 2));
409
+ const scaleY = height / (h * (1 + FIT_PADDING2 * 2));
410
+ scale = Math.min(scaleX, scaleY);
411
+ offsetX = (width - w * scale) / 2 - minX * scale;
412
+ offsetY = (height - h * scale) / 2 - minY * scale;
413
+ }
414
+ function px(p) {
415
+ return (p.x * scale + offsetX).toFixed(2);
416
+ }
417
+ function py(p) {
418
+ return (p.y * scale + offsetY).toFixed(2);
419
+ }
420
+ const skeleton = engine.getSarmalSkeleton();
421
+ calculateBoundaries(skeleton);
422
+ if (skeleton.length >= 2) {
423
+ let d = `M${px(skeleton[0])} ${py(skeleton[0])}`;
424
+ for (let i = 1; i < skeleton.length; i++) {
425
+ d += ` L${px(skeleton[i])} ${py(skeleton[i])}`;
426
+ }
427
+ d += " Z";
428
+ skeletonPath.setAttribute("d", d);
429
+ }
430
+ function updateTrail(trail, trailCount) {
431
+ if (trailCount < 2) {
432
+ for (const p of trailPaths) {
433
+ p.setAttribute("d", "");
434
+ }
435
+ return;
436
+ }
437
+ const batchSize = Math.ceil(trailCount / TRAIL_BATCH_COUNT);
438
+ for (let b = 0; b < TRAIL_BATCH_COUNT; b++) {
439
+ const start = b * batchSize;
440
+ const end = Math.min(start + batchSize, trailCount - 1);
441
+ if (start >= trailCount - 1) {
442
+ trailPaths[b].setAttribute("d", "");
443
+ continue;
444
+ }
445
+ const progress = (start + end) / 2 / (trailCount - 1);
446
+ const opacity = Math.pow(progress, TRAIL_FADE_CURVE2) * TRAIL_MAX_OPACITY2;
447
+ const strokeWidth = TRAIL_MIN_WIDTH2 + progress * (TRAIL_MAX_WIDTH2 - TRAIL_MIN_WIDTH2);
448
+ let d = `M${px(trail[start])} ${py(trail[start])}`;
449
+ for (let i = start + 1; i <= end; i++) {
450
+ d += ` L${px(trail[i])} ${py(trail[i])}`;
451
+ }
452
+ trailPaths[b].setAttribute("d", d);
453
+ trailPaths[b].setAttribute("stroke-opacity", opacity.toFixed(3));
454
+ trailPaths[b].setAttribute("stroke-width", strokeWidth.toFixed(2));
455
+ }
456
+ }
457
+ function updateHead(trail, trailCount) {
458
+ if (trailCount === 0) {
459
+ return;
460
+ }
461
+ const head = trail[trailCount - 1];
462
+ const x = px(head);
463
+ const y = py(head);
464
+ glowCircle.setAttribute("cx", x);
465
+ glowCircle.setAttribute("cy", y);
466
+ headCircle.setAttribute("cx", x);
467
+ headCircle.setAttribute("cy", y);
468
+ }
469
+ let animationId = null;
470
+ let lastTime = 0;
471
+ const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
472
+ function renderFrame() {
473
+ const now = performance.now();
474
+ const dt = Math.min((now - lastTime) / 1e3, 1 / 30);
475
+ lastTime = now;
476
+ const trail = engine.tick(dt);
477
+ const trailCount = engine.trailCount;
478
+ updateTrail(trail, trailCount);
479
+ updateHead(trail, trailCount);
480
+ if (!prefersReducedMotion) {
481
+ animationId = requestAnimationFrame(renderFrame);
482
+ }
483
+ }
484
+ return {
485
+ start() {
486
+ if (animationId !== null) {
487
+ return;
488
+ }
489
+ lastTime = performance.now();
490
+ renderFrame();
491
+ },
492
+ stop() {
493
+ if (animationId === null) {
494
+ return;
495
+ }
496
+ cancelAnimationFrame(animationId);
497
+ animationId = null;
498
+ },
499
+ reset() {
500
+ engine.reset();
501
+ },
502
+ destroy() {
503
+ if (animationId !== null) {
504
+ cancelAnimationFrame(animationId);
505
+ animationId = null;
506
+ }
507
+ svg.remove();
508
+ },
509
+ seek(t, options2) {
510
+ engine.seek(t, options2);
511
+ },
512
+ seekWithTrail(t) {
513
+ engine.seekWithTrail(t);
514
+ }
515
+ };
516
+ }
517
+ function createSarmalSVG(container, curveDef, options) {
518
+ const { trailLength, ...rendererOpts } = options ?? {};
519
+ const engine = createEngine(curveDef, trailLength);
520
+ return createSVGRenderer({ container, engine, ...rendererOpts });
521
+ }
522
+
251
523
  // src/curves.ts
252
524
  var TWO_PI2 = Math.PI * 2;
253
525
  function artemis2(t, _time, _params) {
@@ -294,6 +566,34 @@ function rose3(t, _time, _params) {
294
566
  y: r * Math.sin(t)
295
567
  };
296
568
  }
569
+ function lissajous32(t, time, _params) {
570
+ const phi = time * 0.45;
571
+ return {
572
+ x: Math.sin(3 * t + phi),
573
+ y: Math.sin(2 * t)
574
+ };
575
+ }
576
+ function lissajous43(t, time, _params) {
577
+ const phi = time * 0.38;
578
+ return {
579
+ x: Math.sin(4 * t + phi),
580
+ y: Math.sin(3 * t)
581
+ };
582
+ }
583
+ function epicycloid3(t, _time, _params) {
584
+ return {
585
+ x: 4 * Math.cos(t) - Math.cos(4 * t),
586
+ y: 4 * Math.sin(t) - Math.sin(4 * t)
587
+ };
588
+ }
589
+ function lame(t, time, _params) {
590
+ const p = 1.75 + 1.25 * Math.sin(time * 0.48);
591
+ const c = Math.cos(t), s = Math.sin(t);
592
+ return {
593
+ x: Math.sign(c) * Math.pow(Math.abs(c), p),
594
+ y: Math.sign(s) * Math.pow(Math.abs(s), p)
595
+ };
596
+ }
297
597
  var curves = {
298
598
  artemis2: {
299
599
  name: "Artemis II",
@@ -330,6 +630,30 @@ var curves = {
330
630
  fn: rose3,
331
631
  period: TWO_PI2,
332
632
  speed: 1.15
633
+ },
634
+ lissajous32: {
635
+ name: "Lissajous 3:2",
636
+ fn: lissajous32,
637
+ period: TWO_PI2,
638
+ speed: 2
639
+ },
640
+ lissajous43: {
641
+ name: "Lissajous 4:3",
642
+ fn: lissajous43,
643
+ period: TWO_PI2,
644
+ speed: 1.8
645
+ },
646
+ epicycloid3: {
647
+ name: "Epicycloid (n=3)",
648
+ fn: epicycloid3,
649
+ period: TWO_PI2,
650
+ speed: 0.75
651
+ },
652
+ lame: {
653
+ name: "Lam\xE9 Curve",
654
+ fn: lame,
655
+ period: TWO_PI2,
656
+ speed: 1
333
657
  }
334
658
  };
335
659
 
@@ -340,6 +664,6 @@ function createSarmal(canvas, curveDef, options) {
340
664
  return createRenderer({ canvas, engine, ...rendererOpts });
341
665
  }
342
666
 
343
- export { createEngine, createRenderer, createSarmal, curves };
667
+ export { createEngine, createRenderer, createSVGRenderer, createSarmal, createSarmalSVG, curves };
344
668
  //# sourceMappingURL=index.js.map
345
669
  //# sourceMappingURL=index.js.map