@sarmal/core 0.2.0 → 0.3.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
@@ -9,28 +9,32 @@ var CircularBuffer = class {
9
9
  this.count = 0;
10
10
  this.capacity = capacity;
11
11
  this.data = Array.from({ length: capacity }, () => ({ x: 0, y: 0 }));
12
+ this.result = Array.from({ length: capacity }, () => ({ x: 0, y: 0 }));
12
13
  }
13
- /**
14
- * Array elements are pre-allocated and `head` pointer is manually assigned,
15
- * because AI said using `Array.shift` would be `O(n)` and this would be `O(1)`
16
- * and I don't know any better.
17
- */
14
+ /** Mutates in-place */
18
15
  push(x, y) {
19
- this.data[this.head] = { x, y };
16
+ const slot = this.data[this.head];
17
+ slot.x = x;
18
+ slot.y = y;
20
19
  this.head = (this.head + 1) % this.capacity;
21
20
  if (this.count < this.capacity) {
22
21
  this.count++;
23
22
  }
24
23
  }
24
+ /**
25
+ * Copies ordered points into the pre-allocated result buffer and returns it
26
+ * Note: The *same* array reference is returned every call,
27
+ * so `result.length` is also always `capacity`
28
+ */
25
29
  toArray() {
26
- const result = new Array(this.count);
27
30
  const start = this.count < this.capacity ? 0 : this.head;
28
31
  for (let i = 0; i < this.count; i++) {
29
- const index = (start + i) % this.capacity;
30
- const p = this.data[index];
31
- result[i] = { x: p.x, y: p.y };
32
+ const src = this.data[(start + i) % this.capacity];
33
+ const dst = this.result[i];
34
+ dst.x = src.x;
35
+ dst.y = src.y;
32
36
  }
33
- return result;
37
+ return this.result;
34
38
  }
35
39
  clear() {
36
40
  this.head = 0;
@@ -58,6 +62,9 @@ function createEngine(curveDef, trailLength = 120) {
58
62
  trail.push(point.x, point.y);
59
63
  return trail.toArray();
60
64
  },
65
+ get trailCount() {
66
+ return trail.length;
67
+ },
61
68
  reset() {
62
69
  t = 0;
63
70
  actualTime = 0;
@@ -82,16 +89,16 @@ var DEFAULT_GLOW_SIZE = 20;
82
89
  var DEFAULT_SKELETON_COLOR = "#ffffff";
83
90
  var DEFAULT_SKELETON_OPACITY = 0.15;
84
91
  var FIT_PADDING = 0.1;
85
- var TRAIL_BATCH_SIZE = 10;
92
+ var TRAIL_BATCH_SIZE = 20;
86
93
  var TRAIL_FADE_CURVE = 1.5;
87
94
  var TRAIL_MAX_OPACITY = 0.88;
88
95
  var TRAIL_MIN_WIDTH = 0.5;
89
96
  var TRAIL_MAX_WIDTH = 2.5;
90
97
  var GLOW_INNER_EDGE = 0.4;
91
98
  var GLOW_FALLOFF_OPACITY = 0.53;
92
- function hexToRgba(hex, alpha) {
99
+ function hexToRgbComponents(hex) {
93
100
  const n = parseInt(hex.slice(1), 16);
94
- return `rgba(${n >> 16},${n >> 8 & 255},${n & 255},${alpha})`;
101
+ return `${n >> 16},${n >> 8 & 255},${n & 255}`;
95
102
  }
96
103
  function createRenderer(options) {
97
104
  const canvas = options.canvas;
@@ -107,8 +114,12 @@ function createRenderer(options) {
107
114
  headRadius: options.headRadius ?? DEFAULT_HEAD_RADIUS,
108
115
  glowSize: options.glowSize ?? DEFAULT_GLOW_SIZE
109
116
  };
117
+ const trailRgb = hexToRgbComponents(opts.trailColor);
118
+ const headRgbFalloff = `rgba(${hexToRgbComponents(opts.headColor)},${GLOW_FALLOFF_OPACITY})`;
110
119
  let skeleton = [];
120
+ let skeletonCanvas = null;
111
121
  let trail = [];
122
+ let trailCount = 0;
112
123
  let head = null;
113
124
  let scale = 1;
114
125
  let offsetX = 0;
@@ -147,49 +158,49 @@ function createRenderer(options) {
147
158
  offsetX = (canvasWidth - boundsWidth) / 2 - minX * scale;
148
159
  offsetY = (canvasHeight - boundsHeight) / 2 - minY * scale;
149
160
  }
150
- function transformCoordinateToPixel(p) {
151
- return {
152
- x: p.x * scale + offsetX,
153
- y: p.y * scale + offsetY
154
- };
161
+ function buildSkeletonCanvas() {
162
+ if (skeleton.length < 2) return;
163
+ skeletonCanvas = new OffscreenCanvas(canvas.width, canvas.height);
164
+ const skeletonCtx = skeletonCanvas.getContext("2d");
165
+ skeletonCtx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
166
+ skeletonCtx.lineWidth = 1.5;
167
+ skeletonCtx.beginPath();
168
+ const first = skeleton[0];
169
+ skeletonCtx.moveTo(first.x * scale + offsetX, first.y * scale + offsetY);
170
+ for (let i = 1; i < skeleton.length; i++) {
171
+ const p = skeleton[i];
172
+ skeletonCtx.lineTo(p.x * scale + offsetX, p.y * scale + offsetY);
173
+ }
174
+ skeletonCtx.stroke();
155
175
  }
156
176
  function drawSkeleton() {
157
- if (skeleton.length < 2) {
177
+ if (!skeletonCanvas) {
158
178
  return;
159
179
  }
160
- ctx.strokeStyle = hexToRgba(opts.skeletonColor, DEFAULT_SKELETON_OPACITY);
161
- ctx.lineWidth = 1.5;
162
- ctx.beginPath();
163
- const firstPixel = transformCoordinateToPixel(skeleton[0]);
164
- ctx.moveTo(firstPixel.x, firstPixel.y);
165
- for (let i = 1; i < skeleton.length; i++) {
166
- const pixel = transformCoordinateToPixel(skeleton[i]);
167
- ctx.lineTo(pixel.x, pixel.y);
168
- }
169
- ctx.stroke();
180
+ ctx.drawImage(skeletonCanvas, 0, 0);
170
181
  }
171
182
  function drawTrail() {
172
- if (trail.length < 2) {
183
+ if (trailCount < 2) {
173
184
  return;
174
185
  }
175
- for (let b = 0; b < trail.length - 1; b += TRAIL_BATCH_SIZE) {
176
- const bEnd = Math.min(b + TRAIL_BATCH_SIZE, trail.length - 1);
177
- const progress = (b + bEnd) / 2 / (trail.length - 1);
186
+ ctx.lineJoin = "round";
187
+ ctx.lineCap = "round";
188
+ for (let batchIndex = 0; batchIndex < trailCount - 1; batchIndex += TRAIL_BATCH_SIZE) {
189
+ const bEnd = Math.min(batchIndex + TRAIL_BATCH_SIZE, trailCount - 1);
190
+ const progress = (batchIndex + bEnd) / 2 / (trailCount - 1);
178
191
  const alpha = Math.pow(progress, TRAIL_FADE_CURVE) * TRAIL_MAX_OPACITY;
179
192
  const lineWidth = TRAIL_MIN_WIDTH + progress * (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH);
180
193
  ctx.beginPath();
181
- for (let i = b; i <= bEnd; i++) {
182
- const pixel = transformCoordinateToPixel(trail[i]);
183
- if (i === b) {
184
- ctx.moveTo(pixel.x, pixel.y);
194
+ for (let i = batchIndex; i <= bEnd; i++) {
195
+ const point = trail[i];
196
+ if (i === batchIndex) {
197
+ ctx.moveTo(point.x * scale + offsetX, point.y * scale + offsetY);
185
198
  } else {
186
- ctx.lineTo(pixel.x, pixel.y);
199
+ ctx.lineTo(point.x * scale + offsetX, point.y * scale + offsetY);
187
200
  }
188
201
  }
189
- ctx.strokeStyle = hexToRgba(opts.trailColor, alpha);
202
+ ctx.strokeStyle = `rgba(${trailRgb},${alpha})`;
190
203
  ctx.lineWidth = lineWidth;
191
- ctx.lineJoin = "round";
192
- ctx.lineCap = "round";
193
204
  ctx.stroke();
194
205
  }
195
206
  }
@@ -197,10 +208,11 @@ function createRenderer(options) {
197
208
  if (!head) {
198
209
  return;
199
210
  }
200
- const { x, y } = transformCoordinateToPixel(head);
211
+ const x = head.x * scale + offsetX;
212
+ const y = head.y * scale + offsetY;
201
213
  const gradient = ctx.createRadialGradient(x, y, 0, x, y, opts.glowSize);
202
214
  gradient.addColorStop(0, opts.headColor);
203
- gradient.addColorStop(GLOW_INNER_EDGE, hexToRgba(opts.headColor, GLOW_FALLOFF_OPACITY));
215
+ gradient.addColorStop(GLOW_INNER_EDGE, headRgbFalloff);
204
216
  gradient.addColorStop(1, "transparent");
205
217
  ctx.fillStyle = gradient;
206
218
  ctx.beginPath();
@@ -216,7 +228,8 @@ function createRenderer(options) {
216
228
  const deltaTime = (now - lastTime) / 1e3;
217
229
  lastTime = now;
218
230
  trail = engine.tick(deltaTime);
219
- head = trail.length > 0 ? trail[trail.length - 1] : null;
231
+ trailCount = engine.trailCount;
232
+ head = trailCount > 0 ? trail[trailCount - 1] : null;
220
233
  ctx.clearRect(0, 0, canvas.width, canvas.height);
221
234
  drawSkeleton();
222
235
  drawTrail();
@@ -225,6 +238,7 @@ function createRenderer(options) {
225
238
  }
226
239
  skeleton = engine.getSarmalSkeleton();
227
240
  calculateBoundaries();
241
+ buildSkeletonCanvas();
228
242
  return {
229
243
  start() {
230
244
  if (animationId !== null) {
@@ -254,6 +268,224 @@ function createRenderer(options) {
254
268
  };
255
269
  }
256
270
 
271
+ // src/renderer-svg.ts
272
+ var TRAIL_BATCH_COUNT = 12;
273
+ var TRAIL_FADE_CURVE2 = 1.5;
274
+ var TRAIL_MAX_OPACITY2 = 0.88;
275
+ var TRAIL_MIN_WIDTH2 = 0.5;
276
+ var TRAIL_MAX_WIDTH2 = 2.5;
277
+ var DEFAULT_SKELETON_OPACITY2 = 0.15;
278
+ var DEFAULT_GLOW_INNER_STOP = 0.4;
279
+ var DEFAULT_GLOW_FALLOFF_OPACITY = 0.53;
280
+ var FIT_PADDING2 = 0.1;
281
+ var instanceCount = 0;
282
+ function el(tag) {
283
+ return document.createElementNS("http://www.w3.org/2000/svg", tag);
284
+ }
285
+ function createSVGRenderer(options) {
286
+ const { container, engine } = options;
287
+ const opts = {
288
+ skeletonColor: options.skeletonColor ?? "#ffffff",
289
+ trailColor: options.trailColor ?? "#ffffff",
290
+ headColor: options.headColor ?? "#ffffff",
291
+ headRadius: options.headRadius ?? 4,
292
+ glowSize: options.glowSize ?? 20,
293
+ ariaLabel: options.ariaLabel ?? "Loading"
294
+ };
295
+ const uid = ++instanceCount;
296
+ const gradientId = `sarmal-glow-${uid}`;
297
+ const rect = container.getBoundingClientRect();
298
+ const width = rect.width || 200;
299
+ const height = rect.height || 200;
300
+ const svg = el("svg");
301
+ svg.setAttribute("width", String(width));
302
+ svg.setAttribute("height", String(height));
303
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
304
+ svg.setAttribute("role", "img");
305
+ svg.setAttribute("aria-label", opts.ariaLabel);
306
+ const titleEl = el("title");
307
+ titleEl.textContent = opts.ariaLabel;
308
+ svg.appendChild(titleEl);
309
+ const defs = el("defs");
310
+ const gradient = el("radialGradient");
311
+ gradient.id = gradientId;
312
+ gradient.setAttribute("cx", "50%");
313
+ gradient.setAttribute("cy", "50%");
314
+ gradient.setAttribute("r", "50%");
315
+ const stop0 = el("stop");
316
+ stop0.setAttribute("offset", "0%");
317
+ stop0.setAttribute("stop-color", opts.headColor);
318
+ stop0.setAttribute("stop-opacity", "1");
319
+ const stopMid = el("stop");
320
+ stopMid.setAttribute("offset", `${DEFAULT_GLOW_INNER_STOP * 100}%`);
321
+ stopMid.setAttribute("stop-color", opts.headColor);
322
+ stopMid.setAttribute("stop-opacity", String(DEFAULT_GLOW_FALLOFF_OPACITY));
323
+ const stop1 = el("stop");
324
+ stop1.setAttribute("offset", "100%");
325
+ stop1.setAttribute("stop-color", opts.headColor);
326
+ stop1.setAttribute("stop-opacity", "0");
327
+ gradient.append(stop0, stopMid, stop1);
328
+ defs.appendChild(gradient);
329
+ svg.appendChild(defs);
330
+ const skeletonPath = el("path");
331
+ skeletonPath.setAttribute("fill", "none");
332
+ skeletonPath.setAttribute("stroke", opts.skeletonColor);
333
+ skeletonPath.setAttribute("stroke-opacity", String(DEFAULT_SKELETON_OPACITY2));
334
+ skeletonPath.setAttribute("stroke-width", "1.5");
335
+ svg.appendChild(skeletonPath);
336
+ const trailPaths = [];
337
+ for (let i = 0; i < TRAIL_BATCH_COUNT; i++) {
338
+ const path = el("path");
339
+ path.setAttribute("fill", "none");
340
+ path.setAttribute("stroke", opts.trailColor);
341
+ path.setAttribute("stroke-linecap", "round");
342
+ path.setAttribute("stroke-linejoin", "round");
343
+ svg.appendChild(path);
344
+ trailPaths.push(path);
345
+ }
346
+ const glowCircle = el("circle");
347
+ glowCircle.setAttribute("fill", `url(#${gradientId})`);
348
+ glowCircle.setAttribute("r", String(opts.glowSize));
349
+ svg.appendChild(glowCircle);
350
+ const headCircle = el("circle");
351
+ headCircle.setAttribute("fill", opts.headColor);
352
+ headCircle.setAttribute("r", String(opts.headRadius));
353
+ svg.appendChild(headCircle);
354
+ container.appendChild(svg);
355
+ let scale = 1;
356
+ let offsetX = 0;
357
+ let offsetY = 0;
358
+ function calculateBoundaries(skeleton2) {
359
+ if (skeleton2.length === 0) {
360
+ return;
361
+ }
362
+ const first = skeleton2[0];
363
+ let minX = first.x, maxX = first.x, minY = first.y, maxY = first.y;
364
+ for (const p of skeleton2) {
365
+ if (p.x < minX) {
366
+ minX = p.x;
367
+ }
368
+ if (p.x > maxX) {
369
+ maxX = p.x;
370
+ }
371
+ if (p.y < minY) {
372
+ minY = p.y;
373
+ }
374
+ if (p.y > maxY) {
375
+ maxY = p.y;
376
+ }
377
+ }
378
+ const w = maxX - minX;
379
+ const h = maxY - minY;
380
+ const scaleX = width / (w * (1 + FIT_PADDING2 * 2));
381
+ const scaleY = height / (h * (1 + FIT_PADDING2 * 2));
382
+ scale = Math.min(scaleX, scaleY);
383
+ offsetX = (width - w * scale) / 2 - minX * scale;
384
+ offsetY = (height - h * scale) / 2 - minY * scale;
385
+ }
386
+ function px(p) {
387
+ return (p.x * scale + offsetX).toFixed(2);
388
+ }
389
+ function py(p) {
390
+ return (p.y * scale + offsetY).toFixed(2);
391
+ }
392
+ const skeleton = engine.getSarmalSkeleton();
393
+ calculateBoundaries(skeleton);
394
+ if (skeleton.length >= 2) {
395
+ let d = `M${px(skeleton[0])} ${py(skeleton[0])}`;
396
+ for (let i = 1; i < skeleton.length; i++) {
397
+ d += ` L${px(skeleton[i])} ${py(skeleton[i])}`;
398
+ }
399
+ d += " Z";
400
+ skeletonPath.setAttribute("d", d);
401
+ }
402
+ function updateTrail(trail, trailCount) {
403
+ if (trailCount < 2) {
404
+ for (const p of trailPaths) {
405
+ p.setAttribute("d", "");
406
+ }
407
+ return;
408
+ }
409
+ const batchSize = Math.ceil(trailCount / TRAIL_BATCH_COUNT);
410
+ for (let b = 0; b < TRAIL_BATCH_COUNT; b++) {
411
+ const start = b * batchSize;
412
+ const end = Math.min(start + batchSize, trailCount - 1);
413
+ if (start >= trailCount - 1) {
414
+ trailPaths[b].setAttribute("d", "");
415
+ continue;
416
+ }
417
+ const progress = (start + end) / 2 / (trailCount - 1);
418
+ const opacity = Math.pow(progress, TRAIL_FADE_CURVE2) * TRAIL_MAX_OPACITY2;
419
+ const strokeWidth = TRAIL_MIN_WIDTH2 + progress * (TRAIL_MAX_WIDTH2 - TRAIL_MIN_WIDTH2);
420
+ let d = `M${px(trail[start])} ${py(trail[start])}`;
421
+ for (let i = start + 1; i <= end; i++) {
422
+ d += ` L${px(trail[i])} ${py(trail[i])}`;
423
+ }
424
+ trailPaths[b].setAttribute("d", d);
425
+ trailPaths[b].setAttribute("stroke-opacity", opacity.toFixed(3));
426
+ trailPaths[b].setAttribute("stroke-width", strokeWidth.toFixed(2));
427
+ }
428
+ }
429
+ function updateHead(trail, trailCount) {
430
+ if (trailCount === 0) {
431
+ return;
432
+ }
433
+ const head = trail[trailCount - 1];
434
+ const x = px(head);
435
+ const y = py(head);
436
+ glowCircle.setAttribute("cx", x);
437
+ glowCircle.setAttribute("cy", y);
438
+ headCircle.setAttribute("cx", x);
439
+ headCircle.setAttribute("cy", y);
440
+ }
441
+ let animationId = null;
442
+ let lastTime = 0;
443
+ const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
444
+ function renderFrame() {
445
+ const now = performance.now();
446
+ const dt = (now - lastTime) / 1e3;
447
+ lastTime = now;
448
+ const trail = engine.tick(dt);
449
+ const trailCount = engine.trailCount;
450
+ updateTrail(trail, trailCount);
451
+ updateHead(trail, trailCount);
452
+ if (!prefersReducedMotion) {
453
+ animationId = requestAnimationFrame(renderFrame);
454
+ }
455
+ }
456
+ return {
457
+ start() {
458
+ if (animationId !== null) {
459
+ return;
460
+ }
461
+ lastTime = performance.now();
462
+ renderFrame();
463
+ },
464
+ stop() {
465
+ if (animationId === null) {
466
+ return;
467
+ }
468
+ cancelAnimationFrame(animationId);
469
+ animationId = null;
470
+ },
471
+ reset() {
472
+ engine.reset();
473
+ },
474
+ destroy() {
475
+ if (animationId !== null) {
476
+ cancelAnimationFrame(animationId);
477
+ animationId = null;
478
+ }
479
+ svg.remove();
480
+ }
481
+ };
482
+ }
483
+ function createSarmalSVG(container, curveDef, options) {
484
+ const { trailLength, ...rendererOpts } = options ?? {};
485
+ const engine = createEngine(curveDef, trailLength);
486
+ return createSVGRenderer({ container, engine, ...rendererOpts });
487
+ }
488
+
257
489
  // src/curves.ts
258
490
  var TWO_PI2 = Math.PI * 2;
259
491
  function artemis2(t, _time, _params) {
@@ -300,6 +532,34 @@ function rose3(t, _time, _params) {
300
532
  y: r * Math.sin(t)
301
533
  };
302
534
  }
535
+ function lissajous32(t, time, _params) {
536
+ const phi = time * 0.45;
537
+ return {
538
+ x: Math.sin(3 * t + phi),
539
+ y: Math.sin(2 * t)
540
+ };
541
+ }
542
+ function lissajous43(t, time, _params) {
543
+ const phi = time * 0.38;
544
+ return {
545
+ x: Math.sin(4 * t + phi),
546
+ y: Math.sin(3 * t)
547
+ };
548
+ }
549
+ function epicycloid3(t, _time, _params) {
550
+ return {
551
+ x: 4 * Math.cos(t) - Math.cos(4 * t),
552
+ y: 4 * Math.sin(t) - Math.sin(4 * t)
553
+ };
554
+ }
555
+ function lame(t, time, _params) {
556
+ const p = 1.75 + 1.25 * Math.sin(time * 0.48);
557
+ const c = Math.cos(t), s = Math.sin(t);
558
+ return {
559
+ x: Math.sign(c) * Math.pow(Math.abs(c), p),
560
+ y: Math.sign(s) * Math.pow(Math.abs(s), p)
561
+ };
562
+ }
303
563
  var curves = {
304
564
  artemis2: {
305
565
  name: "Artemis II",
@@ -336,6 +596,30 @@ var curves = {
336
596
  fn: rose3,
337
597
  period: TWO_PI2,
338
598
  speed: 1.15
599
+ },
600
+ lissajous32: {
601
+ name: "Lissajous 3:2",
602
+ fn: lissajous32,
603
+ period: TWO_PI2,
604
+ speed: 2
605
+ },
606
+ lissajous43: {
607
+ name: "Lissajous 4:3",
608
+ fn: lissajous43,
609
+ period: TWO_PI2,
610
+ speed: 1.8
611
+ },
612
+ epicycloid3: {
613
+ name: "Epicycloid (n=3)",
614
+ fn: epicycloid3,
615
+ period: TWO_PI2,
616
+ speed: 0.75
617
+ },
618
+ lame: {
619
+ name: "Lam\xE9 Curve",
620
+ fn: lame,
621
+ period: TWO_PI2,
622
+ speed: 1
339
623
  }
340
624
  };
341
625
 
@@ -348,7 +632,9 @@ function createSarmal(canvas, curveDef, options) {
348
632
 
349
633
  exports.createEngine = createEngine;
350
634
  exports.createRenderer = createRenderer;
635
+ exports.createSVGRenderer = createSVGRenderer;
351
636
  exports.createSarmal = createSarmal;
637
+ exports.createSarmalSVG = createSarmalSVG;
352
638
  exports.curves = curves;
353
639
  //# sourceMappingURL=index.cjs.map
354
640
  //# sourceMappingURL=index.cjs.map