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