@sarmal/core 0.7.1 → 0.9.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
@@ -188,21 +188,90 @@ function createEngine(curveDef, trailLength = 120) {
188
188
  // src/renderer.ts
189
189
  var DEFAULT_MORPH_DURATION_MS = 300;
190
190
  var DEFAULT_HEAD_RADIUS = 4;
191
- var DEFAULT_GLOW_SIZE = 20;
192
191
  var DEFAULT_SKELETON_COLOR = "#ffffff";
193
192
  var DEFAULT_SKELETON_OPACITY = 0.15;
194
193
  var FIT_PADDING = 0.1;
195
- var TRAIL_BATCH_SIZE = 20;
196
194
  var TRAIL_FADE_CURVE = 1.5;
197
195
  var TRAIL_MAX_OPACITY = 0.88;
198
196
  var TRAIL_MIN_WIDTH = 0.5;
199
197
  var TRAIL_MAX_WIDTH = 2.5;
200
- var GLOW_INNER_EDGE = 0.4;
201
- var GLOW_FALLOFF_OPACITY = 0.53;
198
+ var GRADIENT = {
199
+ bard: ["#a855f7", "#3b82f6", "#14b8a6", "#ec4899"],
200
+ sunset: ["#f97316", "#dc2626", "#9333ea", "#f472b6"],
201
+ ocean: ["#1e3a8a", "#06b6d4", "#22d3ee", "#e0f2fe"],
202
+ ice: ["#1e3a8a", "#67e8f9"],
203
+ fire: ["#7f1d1d", "#fbbf24"],
204
+ forest: ["#14532d", "#86efac"]
205
+ };
206
+ var PRESETS = {
207
+ bard: GRADIENT.bard,
208
+ sunset: GRADIENT.sunset,
209
+ ocean: GRADIENT.ocean,
210
+ ice: GRADIENT.ice,
211
+ fire: GRADIENT.fire,
212
+ forest: GRADIENT.forest
213
+ };
214
+ function hexToRgb(hex) {
215
+ const n = parseInt(hex.slice(1), 16);
216
+ return { r: n >> 16, g: n >> 8 & 255, b: n & 255 };
217
+ }
218
+ var lerpRgb = (a, b, t) => ({
219
+ r: Math.round(a.r + (b.r - a.r) * t),
220
+ g: Math.round(a.g + (b.g - a.g) * t),
221
+ b: Math.round(a.b + (b.b - a.b) * t)
222
+ });
223
+ function getPaletteColor(palette, position, timeOffset = 0) {
224
+ if (palette.length === 0) return { r: 255, g: 255, b: 255 };
225
+ if (palette.length === 1) return hexToRgb(palette[0]);
226
+ const cyclePos = (position + timeOffset) % 1;
227
+ const scaled = cyclePos * palette.length;
228
+ const idx = Math.floor(scaled);
229
+ const t = scaled - idx;
230
+ const c1 = hexToRgb(palette[idx % palette.length]);
231
+ const c2 = hexToRgb(palette[(idx + 1) % palette.length]);
232
+ return lerpRgb(c1, c2, t);
233
+ }
234
+ function resolvePalette(palette, trailStyle) {
235
+ if (Array.isArray(palette)) return palette;
236
+ if (palette && palette in PRESETS) return PRESETS[palette];
237
+ return trailStyle === "gradient-animated" ? GRADIENT.bard : GRADIENT.ice;
238
+ }
202
239
  function hexToRgbComponents(hex) {
203
240
  const n = parseInt(hex.slice(1), 16);
204
241
  return `${n >> 16},${n >> 8 & 255},${n & 255}`;
205
242
  }
243
+ function computeTangent(trail, i) {
244
+ const count = trail.length;
245
+ if (count < 2) {
246
+ return { x: 1, y: 0 };
247
+ }
248
+ if (i === 0) {
249
+ const dx2 = trail[1].x - trail[0].x;
250
+ const dy2 = trail[1].y - trail[0].y;
251
+ const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1;
252
+ return { x: dx2 / len2, y: dy2 / len2 };
253
+ }
254
+ if (i === count - 1) {
255
+ const dx2 = trail[count - 1].x - trail[count - 2].x;
256
+ const dy2 = trail[count - 1].y - trail[count - 2].y;
257
+ const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1;
258
+ return { x: dx2 / len2, y: dy2 / len2 };
259
+ }
260
+ const dx = trail[i + 1].x - trail[i - 1].x;
261
+ const dy = trail[i + 1].y - trail[i - 1].y;
262
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
263
+ return { x: dx / len, y: dy / len };
264
+ }
265
+ function computeNormal(trail, i) {
266
+ const tangent = computeTangent(trail, i);
267
+ return { x: -tangent.y, y: tangent.x };
268
+ }
269
+ function applyDprSizing(target, logicalWidth, logicalHeight, dpr) {
270
+ target.style.width = `${logicalWidth}px`;
271
+ target.style.height = `${logicalHeight}px`;
272
+ target.width = logicalWidth * dpr;
273
+ target.height = logicalHeight * dpr;
274
+ }
206
275
  function createRenderer(options) {
207
276
  const canvas = options.canvas;
208
277
  if (!canvas.getContext("2d")) {
@@ -214,11 +283,22 @@ function createRenderer(options) {
214
283
  skeletonColor: options.skeletonColor ?? DEFAULT_SKELETON_COLOR,
215
284
  trailColor: options.trailColor ?? "#ffffff",
216
285
  headColor: options.headColor ?? "#ffffff",
217
- headRadius: options.headRadius ?? DEFAULT_HEAD_RADIUS,
218
- glowSize: options.glowSize ?? DEFAULT_GLOW_SIZE
286
+ headRadius: options.headRadius ?? DEFAULT_HEAD_RADIUS
219
287
  };
288
+ const trailStyle = options.trailStyle ?? "default";
289
+ const palette = resolvePalette(options.palette, trailStyle);
220
290
  const trailRgb = hexToRgbComponents(opts.trailColor);
221
- const headRgbFalloff = `rgba(${hexToRgbComponents(opts.headColor)},${GLOW_FALLOFF_OPACITY})`;
291
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
292
+ function setupCanvas() {
293
+ const rect = canvas.getBoundingClientRect();
294
+ const lw = rect.width || 200;
295
+ const lh = rect.height || 200;
296
+ applyDprSizing(canvas, lw, lh, dpr);
297
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
298
+ }
299
+ setupCanvas();
300
+ let logicalWidth = canvas.width / dpr;
301
+ let logicalHeight = canvas.height / dpr;
222
302
  let skeleton = [];
223
303
  let skeletonCanvas = null;
224
304
  let trail = [];
@@ -232,6 +312,7 @@ function createRenderer(options) {
232
312
  let morphResolve = null;
233
313
  let morphDurationMs = DEFAULT_MORPH_DURATION_MS;
234
314
  let morphAlpha = 0;
315
+ let gradientAnimTime = 0;
235
316
  function computeBoundaries(pts) {
236
317
  if (pts.length === 0) return null;
237
318
  const first = pts[0];
@@ -244,17 +325,15 @@ function createRenderer(options) {
244
325
  }
245
326
  const width = maxX - minX;
246
327
  const height = maxY - minY;
247
- const canvasWidth = canvas.width;
248
- const canvasHeight = canvas.height;
249
- const scaleX = canvasWidth / (width * (1 + FIT_PADDING * 2));
250
- const scaleY = canvasHeight / (height * (1 + FIT_PADDING * 2));
328
+ const scaleX = logicalWidth / (width * (1 + FIT_PADDING * 2));
329
+ const scaleY = logicalHeight / (height * (1 + FIT_PADDING * 2));
251
330
  const s = Math.min(scaleX, scaleY);
252
331
  const boundsWidth = width * s;
253
332
  const boundsHeight = height * s;
254
333
  return {
255
334
  scale: s,
256
- offsetX: (canvasWidth - boundsWidth) / 2 - minX * s,
257
- offsetY: (canvasHeight - boundsHeight) / 2 - minY * s
335
+ offsetX: (logicalWidth - boundsWidth) / 2 - minX * s,
336
+ offsetY: (logicalHeight - boundsHeight) / 2 - minY * s
258
337
  };
259
338
  }
260
339
  function calculateBoundaries() {
@@ -269,6 +348,7 @@ function createRenderer(options) {
269
348
  if (skeleton.length < 2) return;
270
349
  skeletonCanvas = new OffscreenCanvas(canvas.width, canvas.height);
271
350
  const skeletonCtx = skeletonCanvas.getContext("2d");
351
+ skeletonCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
272
352
  skeletonCtx.strokeStyle = `rgba(${hexToRgbComponents(opts.skeletonColor)},${DEFAULT_SKELETON_OPACITY})`;
273
353
  skeletonCtx.lineWidth = 1.5;
274
354
  skeletonCtx.beginPath();
@@ -314,32 +394,47 @@ function createRenderer(options) {
314
394
  }
315
395
  ctx.stroke();
316
396
  } else if (skeletonCanvas) {
317
- ctx.drawImage(skeletonCanvas, 0, 0);
397
+ ctx.drawImage(skeletonCanvas, 0, 0, logicalWidth, logicalHeight);
318
398
  }
319
399
  }
320
400
  function drawTrail() {
321
401
  if (trailCount < 2) {
322
402
  return;
323
403
  }
324
- ctx.lineJoin = "round";
325
- ctx.lineCap = "round";
326
- for (let batchIndex = 0; batchIndex < trailCount - 1; batchIndex += TRAIL_BATCH_SIZE) {
327
- const bEnd = Math.min(batchIndex + TRAIL_BATCH_SIZE, trailCount - 1);
328
- const progress = (batchIndex + bEnd) / 2 / (trailCount - 1);
404
+ for (let i = 0; i < trailCount - 1; i++) {
405
+ const progress = i / (trailCount - 1);
406
+ const nextProgress = (i + 1) / (trailCount - 1);
329
407
  const alpha = Math.pow(progress, TRAIL_FADE_CURVE) * TRAIL_MAX_OPACITY;
330
- const lineWidth = TRAIL_MIN_WIDTH + progress * (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH);
331
- ctx.beginPath();
332
- for (let i = batchIndex; i <= bEnd; i++) {
333
- const point = trail[i];
334
- if (i === batchIndex) {
335
- ctx.moveTo(point.x * scale + offsetX, point.y * scale + offsetY);
336
- } else {
337
- ctx.lineTo(point.x * scale + offsetX, point.y * scale + offsetY);
338
- }
408
+ const width = TRAIL_MIN_WIDTH + progress * (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH);
409
+ const nextWidth = TRAIL_MIN_WIDTH + nextProgress * (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH);
410
+ const curr = trail[i];
411
+ const next = trail[i + 1];
412
+ const n0 = computeNormal(trail, i);
413
+ const n1 = computeNormal(trail, i + 1);
414
+ const halfW0 = width / 2;
415
+ const halfW1 = nextWidth / 2;
416
+ const l0x = curr.x * scale + offsetX + n0.x * halfW0;
417
+ const l0y = curr.y * scale + offsetY + n0.y * halfW0;
418
+ const r0x = curr.x * scale + offsetX - n0.x * halfW0;
419
+ const r0y = curr.y * scale + offsetY - n0.y * halfW0;
420
+ const l1x = next.x * scale + offsetX + n1.x * halfW1;
421
+ const l1y = next.y * scale + offsetY + n1.y * halfW1;
422
+ const r1x = next.x * scale + offsetX - n1.x * halfW1;
423
+ const r1y = next.y * scale + offsetY - n1.y * halfW1;
424
+ if (trailStyle === "default") {
425
+ ctx.fillStyle = `rgba(${trailRgb},${alpha})`;
426
+ } else {
427
+ const timeOffset = trailStyle === "gradient-animated" ? gradientAnimTime * 5e-4 : 0;
428
+ const color = getPaletteColor(palette, progress, timeOffset);
429
+ ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${alpha})`;
339
430
  }
340
- ctx.strokeStyle = `rgba(${trailRgb},${alpha})`;
341
- ctx.lineWidth = lineWidth;
342
- ctx.stroke();
431
+ ctx.beginPath();
432
+ ctx.moveTo(l0x, l0y);
433
+ ctx.lineTo(l1x, l1y);
434
+ ctx.lineTo(r1x, r1y);
435
+ ctx.lineTo(r0x, r0y);
436
+ ctx.closePath();
437
+ ctx.fill();
343
438
  }
344
439
  }
345
440
  function drawHead() {
@@ -348,14 +443,6 @@ function createRenderer(options) {
348
443
  }
349
444
  const x = head.x * scale + offsetX;
350
445
  const y = head.y * scale + offsetY;
351
- const gradient = ctx.createRadialGradient(x, y, 0, x, y, opts.glowSize);
352
- gradient.addColorStop(0, opts.headColor);
353
- gradient.addColorStop(GLOW_INNER_EDGE, headRgbFalloff);
354
- gradient.addColorStop(1, "transparent");
355
- ctx.fillStyle = gradient;
356
- ctx.beginPath();
357
- ctx.arc(x, y, opts.glowSize, 0, Math.PI * 2);
358
- ctx.fill();
359
446
  ctx.fillStyle = opts.headColor;
360
447
  ctx.beginPath();
361
448
  ctx.arc(x, y, opts.headRadius, 0, Math.PI * 2);
@@ -365,6 +452,9 @@ function createRenderer(options) {
365
452
  const now = performance.now();
366
453
  const deltaTime = Math.min((now - lastTime) / 1e3, 1 / 30);
367
454
  lastTime = now;
455
+ if (trailStyle === "gradient-animated") {
456
+ gradientAnimTime += deltaTime * 1e3;
457
+ }
368
458
  if (engine.morphAlpha !== null) {
369
459
  morphAlpha = Math.min(1, morphAlpha + deltaTime / (morphDurationMs / 1e3));
370
460
  engine.setMorphAlpha(morphAlpha);
@@ -389,7 +479,7 @@ function createRenderer(options) {
389
479
  trail = engine.tick(deltaTime);
390
480
  trailCount = engine.trailCount;
391
481
  head = trailCount > 0 ? trail[trailCount - 1] : null;
392
- ctx.clearRect(0, 0, canvas.width, canvas.height);
482
+ ctx.clearRect(0, 0, logicalWidth, logicalHeight);
393
483
  if (engine.isLiveSkeleton && engine.morphAlpha === null) {
394
484
  skeleton = engine.getSarmalSkeleton();
395
485
  calculateBoundaries();
@@ -455,16 +545,13 @@ function createRenderer(options) {
455
545
 
456
546
  // src/renderer-svg.ts
457
547
  var DEFAULT_MORPH_DURATION_MS2 = 300;
458
- var TRAIL_BATCH_COUNT = 12;
548
+ var MAX_TRAIL_SEGMENTS = 200;
459
549
  var TRAIL_FADE_CURVE2 = 1.5;
460
550
  var TRAIL_MAX_OPACITY2 = 0.88;
461
551
  var TRAIL_MIN_WIDTH2 = 0.5;
462
552
  var TRAIL_MAX_WIDTH2 = 2.5;
463
553
  var DEFAULT_SKELETON_OPACITY2 = 0.15;
464
- var DEFAULT_GLOW_INNER_STOP = 0.4;
465
- var DEFAULT_GLOW_FALLOFF_OPACITY = 0.53;
466
554
  var FIT_PADDING2 = 0.1;
467
- var instanceCount = 0;
468
555
  function el(tag) {
469
556
  return document.createElementNS("http://www.w3.org/2000/svg", tag);
470
557
  }
@@ -475,11 +562,8 @@ function createSVGRenderer(options) {
475
562
  trailColor: options.trailColor ?? "#ffffff",
476
563
  headColor: options.headColor ?? "#ffffff",
477
564
  headRadius: options.headRadius ?? 4,
478
- glowSize: options.glowSize ?? 20,
479
565
  ariaLabel: options.ariaLabel ?? "Loading"
480
566
  };
481
- const uid = ++instanceCount;
482
- const gradientId = `sarmal-glow-${uid}`;
483
567
  const rect = container.getBoundingClientRect();
484
568
  const width = rect.width || 200;
485
569
  const height = rect.height || 200;
@@ -492,27 +576,6 @@ function createSVGRenderer(options) {
492
576
  const titleEl = el("title");
493
577
  titleEl.textContent = opts.ariaLabel;
494
578
  svg.appendChild(titleEl);
495
- const defs = el("defs");
496
- const gradient = el("radialGradient");
497
- gradient.id = gradientId;
498
- gradient.setAttribute("cx", "50%");
499
- gradient.setAttribute("cy", "50%");
500
- gradient.setAttribute("r", "50%");
501
- const stop0 = el("stop");
502
- stop0.setAttribute("offset", "0%");
503
- stop0.setAttribute("stop-color", opts.headColor);
504
- stop0.setAttribute("stop-opacity", "1");
505
- const stopMid = el("stop");
506
- stopMid.setAttribute("offset", `${DEFAULT_GLOW_INNER_STOP * 100}%`);
507
- stopMid.setAttribute("stop-color", opts.headColor);
508
- stopMid.setAttribute("stop-opacity", String(DEFAULT_GLOW_FALLOFF_OPACITY));
509
- const stop1 = el("stop");
510
- stop1.setAttribute("offset", "100%");
511
- stop1.setAttribute("stop-color", opts.headColor);
512
- stop1.setAttribute("stop-opacity", "0");
513
- gradient.append(stop0, stopMid, stop1);
514
- defs.appendChild(gradient);
515
- svg.appendChild(defs);
516
579
  const skeletonPath = el("path");
517
580
  skeletonPath.setAttribute("fill", "none");
518
581
  skeletonPath.setAttribute("stroke", opts.skeletonColor);
@@ -534,19 +597,12 @@ function createSVGRenderer(options) {
534
597
  let morphPathABuilt = "";
535
598
  let morphPathBBuilt = "";
536
599
  const trailPaths = [];
537
- for (let i = 0; i < TRAIL_BATCH_COUNT; i++) {
600
+ for (let i = 0; i < MAX_TRAIL_SEGMENTS; i++) {
538
601
  const path = el("path");
539
- path.setAttribute("fill", "none");
540
- path.setAttribute("stroke", opts.trailColor);
541
- path.setAttribute("stroke-linecap", "round");
542
- path.setAttribute("stroke-linejoin", "round");
602
+ path.setAttribute("fill", opts.trailColor);
543
603
  svg.appendChild(path);
544
604
  trailPaths.push(path);
545
605
  }
546
- const glowCircle = el("circle");
547
- glowCircle.setAttribute("fill", `url(#${gradientId})`);
548
- glowCircle.setAttribute("r", String(opts.glowSize));
549
- svg.appendChild(glowCircle);
550
606
  const headCircle = el("circle");
551
607
  headCircle.setAttribute("fill", opts.headColor);
552
608
  headCircle.setAttribute("r", String(opts.headRadius));
@@ -584,19 +640,25 @@ function createSVGRenderer(options) {
584
640
  offsetY = (height - h * scale) / 2 - minY * scale;
585
641
  }
586
642
  function px(p) {
587
- return (p.x * scale + offsetX).toFixed(2);
643
+ return p.x * scale + offsetX;
588
644
  }
589
645
  function py(p) {
590
- return (p.y * scale + offsetY).toFixed(2);
646
+ return p.y * scale + offsetY;
647
+ }
648
+ function pxStr(p) {
649
+ return px(p).toFixed(2);
650
+ }
651
+ function pyStr(p) {
652
+ return py(p).toFixed(2);
591
653
  }
592
654
  function updateSkeleton(skeleton2) {
593
655
  if (skeleton2.length < 2) {
594
656
  skeletonPath.setAttribute("d", "");
595
657
  return;
596
658
  }
597
- let d = `M${px(skeleton2[0])} ${py(skeleton2[0])}`;
659
+ let d = `M${pxStr(skeleton2[0])} ${pyStr(skeleton2[0])}`;
598
660
  for (let i = 1; i < skeleton2.length; i++) {
599
- d += ` L${px(skeleton2[i])} ${py(skeleton2[i])}`;
661
+ d += ` L${pxStr(skeleton2[i])} ${pyStr(skeleton2[i])}`;
600
662
  }
601
663
  d += " Z";
602
664
  skeletonPath.setAttribute("d", d);
@@ -613,24 +675,32 @@ function createSVGRenderer(options) {
613
675
  }
614
676
  return;
615
677
  }
616
- const batchSize = Math.ceil(trailCount / TRAIL_BATCH_COUNT);
617
- for (let b = 0; b < TRAIL_BATCH_COUNT; b++) {
618
- const start = b * batchSize;
619
- const end = Math.min(start + batchSize, trailCount - 1);
620
- if (start >= trailCount - 1) {
621
- trailPaths[b].setAttribute("d", "");
622
- continue;
623
- }
624
- const progress = (start + end) / 2 / (trailCount - 1);
678
+ for (let i = 0; i < trailCount - 1; i++) {
679
+ const progress = i / (trailCount - 1);
680
+ const nextProgress = (i + 1) / (trailCount - 1);
625
681
  const opacity = Math.pow(progress, TRAIL_FADE_CURVE2) * TRAIL_MAX_OPACITY2;
626
- const strokeWidth = TRAIL_MIN_WIDTH2 + progress * (TRAIL_MAX_WIDTH2 - TRAIL_MIN_WIDTH2);
627
- let d = `M${px(trail[start])} ${py(trail[start])}`;
628
- for (let i = start + 1; i <= end; i++) {
629
- d += ` L${px(trail[i])} ${py(trail[i])}`;
630
- }
631
- trailPaths[b].setAttribute("d", d);
632
- trailPaths[b].setAttribute("stroke-opacity", opacity.toFixed(3));
633
- trailPaths[b].setAttribute("stroke-width", strokeWidth.toFixed(2));
682
+ const width2 = TRAIL_MIN_WIDTH2 + progress * (TRAIL_MAX_WIDTH2 - TRAIL_MIN_WIDTH2);
683
+ const nextWidth = TRAIL_MIN_WIDTH2 + nextProgress * (TRAIL_MAX_WIDTH2 - TRAIL_MIN_WIDTH2);
684
+ const curr = trail[i];
685
+ const next = trail[i + 1];
686
+ const n0 = computeNormal(trail, i);
687
+ const n1 = computeNormal(trail, i + 1);
688
+ const halfW0 = width2 / 2;
689
+ const halfW1 = nextWidth / 2;
690
+ const l0x = px(curr) + n0.x * halfW0;
691
+ const l0y = py(curr) + n0.y * halfW0;
692
+ const r0x = px(curr) - n0.x * halfW0;
693
+ const r0y = py(curr) - n0.y * halfW0;
694
+ const l1x = px(next) + n1.x * halfW1;
695
+ const l1y = py(next) + n1.y * halfW1;
696
+ const r1x = px(next) - n1.x * halfW1;
697
+ const r1y = py(next) - n1.y * halfW1;
698
+ const d = `M${l0x.toFixed(2)} ${l0y.toFixed(2)} L${l1x.toFixed(2)} ${l1y.toFixed(2)} L${r1x.toFixed(2)} ${r1y.toFixed(2)} L${r0x.toFixed(2)} ${r0y.toFixed(2)} Z`;
699
+ trailPaths[i].setAttribute("d", d);
700
+ trailPaths[i].setAttribute("fill-opacity", opacity.toFixed(3));
701
+ }
702
+ for (let i = trailCount - 1; i < trailPaths.length; i++) {
703
+ trailPaths[i].setAttribute("d", "");
634
704
  }
635
705
  }
636
706
  function updateHead(trail, trailCount) {
@@ -640,10 +710,8 @@ function createSVGRenderer(options) {
640
710
  const head = trail[trailCount - 1];
641
711
  const x = px(head);
642
712
  const y = py(head);
643
- glowCircle.setAttribute("cx", x);
644
- glowCircle.setAttribute("cy", y);
645
- headCircle.setAttribute("cx", x);
646
- headCircle.setAttribute("cy", y);
713
+ headCircle.setAttribute("cx", String(x));
714
+ headCircle.setAttribute("cy", String(y));
647
715
  }
648
716
  let animationId = null;
649
717
  let lastTime = 0;