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