emberwick 0.3.0 → 0.4.1

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/index.js CHANGED
@@ -169,7 +169,7 @@ class Tween {
169
169
  return true;
170
170
  }
171
171
  }
172
- const clamp$1 = (v, a, b) => v < a ? a : v > b ? b : v;
172
+ const clamp$2 = (v, a, b) => v < a ? a : v > b ? b : v;
173
173
  class TimeScale {
174
174
  constructor({ spacing = 9, minSpacing = 0.8, maxSpacing = 160, rightOffset = 12 } = {}) {
175
175
  this.minSpacing = minSpacing;
@@ -216,14 +216,14 @@ class TimeScale {
216
216
  const first = Math.floor(this.index(0)) - 1;
217
217
  const last = Math.ceil(this.index(this.width)) + 1;
218
218
  return {
219
- from: clamp$1(first, 0, Math.max(0, this.barCount - 1)),
220
- to: clamp$1(last, 0, Math.max(0, this.barCount - 1))
219
+ from: clamp$2(first, 0, Math.max(0, this.barCount - 1)),
220
+ to: clamp$2(last, 0, Math.max(0, this.barCount - 1))
221
221
  };
222
222
  }
223
223
  _clampRight(v) {
224
224
  const max = this.barCount - 1 + this.rightOffset + this.width / this._spacing.target;
225
225
  const min = Math.min(4, this.barCount - 1 + this.rightOffset);
226
- return clamp$1(v, min, max);
226
+ return clamp$2(v, min, max);
227
227
  }
228
228
  /** Immediate pan, in pixels. Positive dx drags content right (back in time). */
229
229
  panBy(dxPx) {
@@ -236,7 +236,7 @@ class TimeScale {
236
236
  /** Zoom by `factor`, keeping the bar under `x` pinned. */
237
237
  zoomAt(x, factor) {
238
238
  const s0 = this._spacing.target;
239
- const s1 = clamp$1(s0 * factor, this.minSpacing, this.maxSpacing);
239
+ const s1 = clamp$2(s0 * factor, this.minSpacing, this.maxSpacing);
240
240
  if (Math.abs(s1 - s0) < 1e-9) return false;
241
241
  const anchorX = this.follow ? this.width : x;
242
242
  const r0 = this._right.target;
@@ -250,6 +250,15 @@ class TimeScale {
250
250
  this.follow = true;
251
251
  this._right.set(this.barCount - 1 + this.rightOffset);
252
252
  }
253
+ /**
254
+ * Same anchor, no easing. Scrubbing re-targets the right edge many times a
255
+ * second; easing each one reads as the chart lagging the scrubber, which is
256
+ * the same reason a drag uses jump().
257
+ */
258
+ jumpToRealtime() {
259
+ this.follow = true;
260
+ this._right.jump(this.barCount - 1 + this.rightOffset);
261
+ }
253
262
  reset() {
254
263
  this._spacing.set(this._initial);
255
264
  this.snapToRealtime();
@@ -264,7 +273,7 @@ class TimeScale {
264
273
  return this._spacing.settled && this._right.settled;
265
274
  }
266
275
  }
267
- const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
276
+ const clamp$1 = (v, a, b) => v < a ? a : v > b ? b : v;
268
277
  class PriceScale {
269
278
  constructor({ mode = "linear", tau = 120, marginTop = 0.12, marginBottom = 0.12 } = {}) {
270
279
  this.mode = mode;
@@ -349,7 +358,7 @@ class PriceScale {
349
358
  const a = this._lo.target;
350
359
  const b = this._hi.target;
351
360
  const mid = (a + b) / 2;
352
- const half = (b - a) / 2 * clamp(factor, 0.2, 5);
361
+ const half = (b - a) / 2 * clamp$1(factor, 0.2, 5);
353
362
  this._lo.set(mid - half);
354
363
  this._hi.set(mid + half);
355
364
  }
@@ -492,6 +501,202 @@ class Inertia {
492
501
  return dx;
493
502
  }
494
503
  }
504
+ const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
505
+ const MIN_SPEED = 0.25;
506
+ const MAX_SPEED = 500;
507
+ const MAX_STEPS_PER_FRAME = 240;
508
+ class Replay {
509
+ constructor(chart, options = {}) {
510
+ const src = Array.isArray(options.bars) ? options.bars : [];
511
+ this.chart = chart;
512
+ this.source = src.slice();
513
+ this.baseInterval = Math.max(16, +options.baseInterval || 1e3);
514
+ this.speed = clamp(+options.speed || 1, MIN_SPEED, MAX_SPEED);
515
+ this.looping = options.loop === true;
516
+ this.follow = options.follow !== false;
517
+ this.playing = false;
518
+ this._acc = 0;
519
+ this._markerKey = "";
520
+ this._markerView = null;
521
+ this.minIndex = Math.min(1, this.lastIndex);
522
+ const from = +options.from;
523
+ this.index = clamp(
524
+ Number.isFinite(from) ? Math.round(from) : Math.floor(this.lastIndex / 2),
525
+ this.minIndex,
526
+ this.lastIndex
527
+ );
528
+ this._apply("seek");
529
+ }
530
+ // ----------------------------------------------------------------- state --
531
+ get length() {
532
+ return this.source.length;
533
+ }
534
+ get lastIndex() {
535
+ return Math.max(0, this.source.length - 1);
536
+ }
537
+ get atEnd() {
538
+ return this.index >= this.lastIndex;
539
+ }
540
+ get bar() {
541
+ return this.source[this.index] || null;
542
+ }
543
+ get time() {
544
+ return this.bar ? this.bar.time : null;
545
+ }
546
+ /** 0 at the first playable bar, 1 at the last. */
547
+ get progress() {
548
+ const span = this.lastIndex - this.minIndex;
549
+ return span > 0 ? (this.index - this.minIndex) / span : 1;
550
+ }
551
+ /** Real ms between bars at the current speed. */
552
+ get interval() {
553
+ return this.baseInterval / this.speed;
554
+ }
555
+ /** Snapshot handed to `subscribe('replay', fn)`. */
556
+ state() {
557
+ return {
558
+ active: true,
559
+ playing: this.playing,
560
+ index: this.index,
561
+ length: this.length,
562
+ progress: this.progress,
563
+ speed: this.speed,
564
+ time: this.time,
565
+ bar: this.bar,
566
+ atEnd: this.atEnd
567
+ };
568
+ }
569
+ // ------------------------------------------------------------- transport --
570
+ play() {
571
+ if (this.playing || this.length < 2) return this;
572
+ if (this.atEnd) {
573
+ this.index = this.minIndex;
574
+ this._apply("seek");
575
+ }
576
+ this.playing = true;
577
+ this._acc = 0;
578
+ this._changed();
579
+ return this;
580
+ }
581
+ pause() {
582
+ if (!this.playing) return this;
583
+ this.playing = false;
584
+ this._acc = 0;
585
+ this._changed();
586
+ return this;
587
+ }
588
+ toggle() {
589
+ return this.playing ? this.pause() : this.play();
590
+ }
591
+ /** Multiplier on `baseInterval`. Clamped to 0.25×–500×. */
592
+ setSpeed(speed) {
593
+ const s = clamp(+speed || 1, MIN_SPEED, MAX_SPEED);
594
+ if (s === this.speed) return this;
595
+ this.speed = s;
596
+ this._acc = 0;
597
+ this._changed();
598
+ return this;
599
+ }
600
+ setLoop(on) {
601
+ this.looping = !!on;
602
+ this._changed();
603
+ return this;
604
+ }
605
+ /** Move the cursor. Out-of-range values clamp; playback keeps running. */
606
+ seek(index) {
607
+ const next = clamp(Math.round(+index), this.minIndex, this.lastIndex);
608
+ if (next === this.index) return this;
609
+ this.index = next;
610
+ this._acc = 0;
611
+ this._apply("seek");
612
+ this._changed();
613
+ return this;
614
+ }
615
+ step(n = 1) {
616
+ return this.seek(this.index + (n || 0));
617
+ }
618
+ toStart() {
619
+ return this.seek(this.minIndex);
620
+ }
621
+ toEnd() {
622
+ return this.seek(this.lastIndex);
623
+ }
624
+ /**
625
+ * Advance with wall-clock time. Called once per frame by the chart; returns
626
+ * true while playback is in flight, which is what keeps the loop awake.
627
+ */
628
+ tick(dt) {
629
+ if (!this.playing || this.length < 2) return false;
630
+ this._acc += dt * this.speed;
631
+ const steps = Math.floor(this._acc / this.baseInterval);
632
+ if (steps <= 0) return true;
633
+ this._acc -= steps * this.baseInterval;
634
+ const last = this.lastIndex;
635
+ let next = this.index + Math.min(steps, MAX_STEPS_PER_FRAME);
636
+ if (next > last) {
637
+ if (this.looping) {
638
+ this.index = this.minIndex;
639
+ this._apply("seek");
640
+ this._changed();
641
+ return true;
642
+ }
643
+ next = last;
644
+ }
645
+ const single = next === this.index + 1;
646
+ this.index = next;
647
+ this._apply(single ? "step" : "seek");
648
+ if (this.atEnd && !this.looping) {
649
+ this.playing = false;
650
+ this._acc = 0;
651
+ }
652
+ this._changed();
653
+ return this.playing;
654
+ }
655
+ // --------------------------------------------------------------- markers --
656
+ /**
657
+ * Markers after the cursor are future information: hidden, not clamped.
658
+ * Without this they would all pin to the newest revealed bar, because
659
+ * time→index resolution snaps to the NEAREST bar.
660
+ *
661
+ * Cached on the cursor, so a paused chart allocates nothing per frame.
662
+ */
663
+ markerFilter(markers) {
664
+ const t = this.time;
665
+ if (t == null) return markers;
666
+ const key = this.index + ":" + markers.length;
667
+ if (key !== this._markerKey || !this._markerView) {
668
+ const cut = t + (this.chart.ts.timeframeMs || 0) / 2;
669
+ this._markerView = markers.filter((m) => m.time <= cut);
670
+ this._markerKey = key;
671
+ }
672
+ return this._markerView;
673
+ }
674
+ /** Drop the cached slice — the marker set itself changed. */
675
+ invalidateMarkers() {
676
+ this._markerKey = "";
677
+ this._markerView = null;
678
+ }
679
+ // ---------------------------------------------------------------- private --
680
+ /**
681
+ * Push the revealed prefix into the chart.
682
+ *
683
+ * 'step' (exactly one bar forward) takes the live path so the new candle
684
+ * animates; anything else swaps the prefix and re-anchors without easing.
685
+ */
686
+ _apply(mode) {
687
+ const chart = this.chart;
688
+ if (mode === "step" && chart.bars.length === this.index) {
689
+ chart.append(this.source[this.index]);
690
+ return;
691
+ }
692
+ chart._swapBars(this.source.slice(0, this.index + 1));
693
+ if (this.follow) chart.ts.jumpToRealtime();
694
+ }
695
+ /** Any state change needs a frame: that frame is what emits 'replay'. */
696
+ _changed() {
697
+ this.chart.loop.invalidate("main");
698
+ }
699
+ }
495
700
  function niceStep(span, count) {
496
701
  const raw = span / Math.max(1, count);
497
702
  if (!(raw > 0) || !isFinite(raw)) return 1;
@@ -1059,6 +1264,17 @@ function drawMarkers(ctx, s, placed, hoverId) {
1059
1264
  ctx.restore();
1060
1265
  return hits;
1061
1266
  }
1267
+ const inactiveReplay = () => ({
1268
+ active: false,
1269
+ playing: false,
1270
+ index: -1,
1271
+ length: 0,
1272
+ progress: 0,
1273
+ speed: 1,
1274
+ time: null,
1275
+ bar: null,
1276
+ atEnd: false
1277
+ });
1062
1278
  class Chart {
1063
1279
  constructor(container, options = {}) {
1064
1280
  if (!container) throw new Error("Chart: container element is required");
@@ -1075,11 +1291,16 @@ class Chart {
1075
1291
  this._unsub = null;
1076
1292
  this._loadingHistory = false;
1077
1293
  this._exhausted = false;
1294
+ this._replay = null;
1295
+ this._feedGen = 0;
1296
+ this._destroyed = false;
1078
1297
  this._listeners = {
1079
1298
  crosshair: /* @__PURE__ */ new Set(),
1080
1299
  visibleRange: /* @__PURE__ */ new Set(),
1081
1300
  markerClick: /* @__PURE__ */ new Set(),
1082
- markerHover: /* @__PURE__ */ new Set()
1301
+ markerHover: /* @__PURE__ */ new Set(),
1302
+ replay: /* @__PURE__ */ new Set(),
1303
+ error: /* @__PURE__ */ new Set()
1083
1304
  };
1084
1305
  this._markers = normalizeMarkers(options.markers);
1085
1306
  this.priceLines = Array.isArray(options.priceLines) ? options.priceLines.slice() : [];
@@ -1115,6 +1336,7 @@ class Chart {
1115
1336
  }
1116
1337
  // ------------------------------------------------------------------ data --
1117
1338
  setData(bars) {
1339
+ if (this._replay) this._replay = null;
1118
1340
  this.bars = Array.isArray(bars) ? bars.slice() : [];
1119
1341
  this._exhausted = false;
1120
1342
  if (this.bars.length > 1) {
@@ -1126,6 +1348,23 @@ class Chart {
1126
1348
  this.ps._primed = false;
1127
1349
  this.loop.invalidate("all");
1128
1350
  }
1351
+ /**
1352
+ * Replace the bar array WITHOUT re-anchoring the view — the deliberate
1353
+ * difference from setData(), which snaps to the right edge and re-primes
1354
+ * the price scale. Replay swaps its revealed prefix through here on every
1355
+ * scrub, so a snap would fight the user's zoom and the price scale would
1356
+ * pop instead of easing between windows.
1357
+ */
1358
+ _swapBars(bars) {
1359
+ this.bars = Array.isArray(bars) ? bars : [];
1360
+ if (this.bars.length > 1) {
1361
+ this.ts.timeframeMs = this.bars[1].time - this.bars[0].time;
1362
+ }
1363
+ this.ts.setBarCount(this.bars.length);
1364
+ this.live.reset();
1365
+ this._resolveKey = "";
1366
+ this.loop.invalidate("all");
1367
+ }
1129
1368
  /** Merge a tick into the forming candle (animated). */
1130
1369
  update(bar) {
1131
1370
  if (!bar) return;
@@ -1156,62 +1395,90 @@ class Chart {
1156
1395
  this.detachFeed();
1157
1396
  this.feed = feed;
1158
1397
  if (!feed) return;
1398
+ const gen = ++this._feedGen;
1159
1399
  this.ts.timeframeMs = feed.timeframe || this.ts.timeframeMs;
1160
- const bars = await feed.getBars({
1161
- symbol: feed.symbol,
1162
- timeframe: feed.timeframe,
1163
- to: null,
1164
- limit: this.options.initialBars || 1500
1165
- });
1400
+ let bars;
1401
+ try {
1402
+ bars = await feed.getBars({
1403
+ symbol: feed.symbol,
1404
+ timeframe: feed.timeframe,
1405
+ to: null,
1406
+ limit: this.options.initialBars || 1500
1407
+ });
1408
+ } catch (err) {
1409
+ if (gen !== this._feedGen || this._destroyed) return;
1410
+ this._emitError(err, "setFeed");
1411
+ return;
1412
+ }
1413
+ if (gen !== this._feedGen || this._destroyed) return;
1166
1414
  this.setData(bars);
1167
- if (typeof feed.prime === "function") feed.prime(bars[bars.length - 1]);
1415
+ if (typeof feed.prime === "function") feed.prime(this.bars[this.bars.length - 1]);
1168
1416
  this._unsub = feed.subscribe((msg) => {
1169
1417
  if (!msg || !msg.bar) return;
1418
+ if (gen !== this._feedGen || this._destroyed) return;
1419
+ if (this._replay) return;
1170
1420
  if (msg.type === "append") this.append(msg.bar);
1171
1421
  else this.update(msg.bar);
1172
1422
  });
1173
1423
  }
1174
1424
  detachFeed() {
1425
+ this._feedGen++;
1426
+ this._loadingHistory = false;
1175
1427
  if (this._unsub) this._unsub();
1176
1428
  this._unsub = null;
1177
1429
  this.feed = null;
1178
1430
  }
1179
1431
  async _maybeLoadHistory() {
1432
+ if (this._replay) return;
1180
1433
  if (this._loadingHistory || this._exhausted || !this.feed) return;
1181
1434
  const { from } = this.ts.visibleRange();
1182
1435
  if (from > 80 || !this.bars.length) return;
1183
1436
  this._loadingHistory = true;
1437
+ const gen = this._feedGen;
1438
+ const feed = this.feed;
1184
1439
  try {
1185
- const oldest = this.bars[0].time;
1186
- const older = await this.feed.getBars({
1187
- symbol: this.feed.symbol,
1188
- timeframe: this.feed.timeframe,
1189
- to: oldest,
1440
+ const older = await feed.getBars({
1441
+ symbol: feed.symbol,
1442
+ timeframe: feed.timeframe,
1443
+ to: this.bars[0].time,
1190
1444
  limit: 1e3
1191
1445
  });
1446
+ if (gen !== this._feedGen || this._destroyed) return;
1447
+ if (!this.bars.length) return;
1192
1448
  if (!older || !older.length) {
1193
1449
  this._exhausted = true;
1194
- } else {
1195
- const added = older.filter((b) => b.time < oldest);
1196
- if (!added.length) {
1197
- this._exhausted = true;
1198
- } else {
1199
- this.bars = added.concat(this.bars);
1200
- const wasFollowing = this.ts.follow;
1201
- this.ts.barCount = this.bars.length;
1202
- this.ts._right.jump(this.ts._right.value + added.length);
1203
- this.ts._right.set(this.ts._right.target + added.length);
1204
- this.ts.follow = wasFollowing;
1205
- this.loop.invalidate("all");
1206
- }
1450
+ return;
1207
1451
  }
1208
- } catch (e) {
1209
- console.error("[Emberwick] history load failed", e);
1452
+ const boundary = this.bars[0].time;
1453
+ const added = older.filter((b) => b.time < boundary);
1454
+ if (!added.length) {
1455
+ this._exhausted = true;
1456
+ return;
1457
+ }
1458
+ this.bars = added.concat(this.bars);
1459
+ this.ts.barCount = this.bars.length;
1460
+ this.ts._right.jump(this.ts._right.value + added.length);
1461
+ this.loop.invalidate("all");
1462
+ } catch (err) {
1463
+ if (gen !== this._feedGen || this._destroyed) return;
1210
1464
  this._exhausted = true;
1465
+ this._emitError(err, "loadHistory");
1211
1466
  } finally {
1212
- this._loadingHistory = false;
1467
+ if (gen === this._feedGen) this._loadingHistory = false;
1213
1468
  }
1214
1469
  }
1470
+ /**
1471
+ * Feed failures are delivered as an 'error' event so an adapter can react.
1472
+ * With no subscriber they still reach the console rather than vanishing.
1473
+ */
1474
+ _emitError(err, phase) {
1475
+ const set = this._listeners.error;
1476
+ if (!set.size) {
1477
+ console.error(`[Emberwick] ${phase} failed`, err);
1478
+ return;
1479
+ }
1480
+ for (const fn of set) fn(err);
1481
+ }
1215
1482
  // ---------------------------------------------------------------- events --
1216
1483
  _bindEvents() {
1217
1484
  const el = this.container;
@@ -1399,6 +1666,11 @@ class Chart {
1399
1666
  this._rangeKey = this._rangeIdentity(payload);
1400
1667
  fn(payload);
1401
1668
  }
1669
+ if (event === "replay") {
1670
+ const payload = this.replayState();
1671
+ this._replayKey = this._replayIdentity(payload);
1672
+ fn(payload);
1673
+ }
1402
1674
  return () => set.delete(fn);
1403
1675
  }
1404
1676
  // ------------------------------------------------------------------ range --
@@ -1445,11 +1717,72 @@ class Chart {
1445
1717
  this._rangeKey = key;
1446
1718
  for (const fn of set) fn(payload);
1447
1719
  }
1720
+ // ----------------------------------------------------------------- replay --
1721
+ /**
1722
+ * Start bar-by-bar playback over a fixed dataset.
1723
+ *
1724
+ * With no `bars`, the chart's CURRENT data becomes the dataset — the usual
1725
+ * case: load history, then replay it. The chart is not switched into a
1726
+ * special mode; it is simply handed the revealed prefix, so scales,
1727
+ * annotations, the crosshair and visibleRange all keep behaving normally.
1728
+ *
1729
+ * chart.startReplay({ from: 200, speed: 4 })
1730
+ * chart.replay.play()
1731
+ *
1732
+ * @param {object} [options]
1733
+ * @param {Bar[]} [options.bars] dataset (defaults to current bars)
1734
+ * @param {number} [options.from] starting index (default: midpoint)
1735
+ * @param {number} [options.speed] rate multiplier, 0.25–500
1736
+ * @param {number} [options.baseInterval] real ms per bar at 1× (default 1000)
1737
+ * @param {boolean} [options.loop] restart at the end
1738
+ * @param {boolean} [options.follow] re-anchor the right edge on scrub
1739
+ * @returns {Replay|null} null if there are fewer than two bars to replay
1740
+ */
1741
+ startReplay(options = {}) {
1742
+ const source = Array.isArray(options.bars) && options.bars.length ? options.bars : this._replay ? this._replay.source : this.bars;
1743
+ if (!source || source.length < 2) return null;
1744
+ const dataset = source.slice();
1745
+ this._replay = new Replay(this, { ...options, bars: dataset });
1746
+ return this._replay;
1747
+ }
1748
+ /** Leave replay and reveal the whole dataset again. */
1749
+ stopReplay() {
1750
+ if (!this._replay) return;
1751
+ const full = this._replay.source;
1752
+ this._replay = null;
1753
+ this.setData(full);
1754
+ }
1755
+ /** The active controller, or null. */
1756
+ get replay() {
1757
+ return this._replay;
1758
+ }
1759
+ /** Current playback state; `{ active: false, ... }` when not replaying. */
1760
+ replayState() {
1761
+ return this._replay ? this._replay.state() : inactiveReplay();
1762
+ }
1763
+ _replayIdentity(p) {
1764
+ return `${p.active ? 1 : 0}:${p.playing ? 1 : 0}:${p.index}:${p.length}:${p.speed}`;
1765
+ }
1766
+ /**
1767
+ * Emitted from the frame, like visibleRange, so a handler can safely touch
1768
+ * the chart. Keyed on cursor + transport, not on the payload object, so a
1769
+ * paused replay emits nothing at all.
1770
+ */
1771
+ _emitReplay() {
1772
+ const set = this._listeners.replay;
1773
+ if (!set.size) return;
1774
+ const payload = this.replayState();
1775
+ const key = this._replayIdentity(payload);
1776
+ if (key === this._replayKey) return;
1777
+ this._replayKey = key;
1778
+ for (const fn of set) fn(payload);
1779
+ }
1448
1780
  // ----------------------------------------------------------- annotations --
1449
1781
  /** Replace every marker. Each `time` is resolved to its nearest bar. */
1450
1782
  setMarkers(markers) {
1451
1783
  this._markers = normalizeMarkers(markers);
1452
1784
  this._resolveKey = "";
1785
+ if (this._replay) this._replay.invalidateMarkers();
1453
1786
  this.loop.invalidate("main");
1454
1787
  }
1455
1788
  /** The current markers, normalised, each with its resolved bar index. */
@@ -1479,6 +1812,7 @@ class Chart {
1479
1812
  // ----------------------------------------------------------------- frame --
1480
1813
  _frame(dirty, dt) {
1481
1814
  let animating = false;
1815
+ if (this._replay && this._replay.tick(dt)) animating = true;
1482
1816
  if (this.ts.tick(dt)) animating = true;
1483
1817
  const dx = this.inertia.tick(dt);
1484
1818
  if (dx) {
@@ -1509,10 +1843,12 @@ class Chart {
1509
1843
  priceLines: this.priceLines,
1510
1844
  zones: this.zones
1511
1845
  };
1512
- if (this._markers.length) {
1513
- const key = this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
1846
+ let markers = this._markers;
1847
+ if (this._replay && markers.length) markers = this._replay.markerFilter(markers);
1848
+ if (markers.length) {
1849
+ const key = markers.length + ":" + this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
1514
1850
  if (key !== this._resolveKey) {
1515
- resolveMarkers(this._markers, this.bars);
1851
+ resolveMarkers(markers, this.bars);
1516
1852
  this._resolveKey = key;
1517
1853
  }
1518
1854
  }
@@ -1524,7 +1860,7 @@ class Chart {
1524
1860
  this._markerHits = drawMarkers(
1525
1861
  this.layers.ctx.main,
1526
1862
  state,
1527
- layoutMarkers(this._markers, state),
1863
+ layoutMarkers(markers, state),
1528
1864
  this._hoverMarkerId
1529
1865
  );
1530
1866
  }
@@ -1532,6 +1868,7 @@ class Chart {
1532
1868
  drawCrosshair(this.layers.ctx.overlay, state);
1533
1869
  }
1534
1870
  this._emitVisibleRange(from, to);
1871
+ this._emitReplay();
1535
1872
  return animating;
1536
1873
  }
1537
1874
  // ------------------------------------------------------------------- api --
@@ -1564,6 +1901,8 @@ class Chart {
1564
1901
  return this.layers.composite().toDataURL("image/png");
1565
1902
  }
1566
1903
  destroy() {
1904
+ if (this._destroyed) return;
1905
+ this._destroyed = true;
1567
1906
  const el = this.container;
1568
1907
  el.removeEventListener("pointerdown", this._onDown);
1569
1908
  el.removeEventListener("pointermove", this._onMove);
@@ -1578,6 +1917,7 @@ class Chart {
1578
1917
  this.loop.stop();
1579
1918
  this.layers.destroy();
1580
1919
  for (const set of Object.values(this._listeners)) set.clear();
1920
+ this._replay = null;
1581
1921
  this._markers = [];
1582
1922
  this._markerHits = [];
1583
1923
  this.priceLines = [];
@@ -1760,14 +2100,17 @@ class RandomFeed extends DataFeed {
1760
2100
  function createChart(container, options) {
1761
2101
  return new Chart(container, options);
1762
2102
  }
1763
- const version = "0.2.0";
2103
+ const version = "0.4.1";
1764
2104
  export {
1765
2105
  Chart,
1766
2106
  DataFeed,
1767
2107
  Inertia,
1768
2108
  LiveCandle,
2109
+ MAX_SPEED,
2110
+ MIN_SPEED,
1769
2111
  PriceScale,
1770
2112
  RandomFeed,
2113
+ Replay,
1771
2114
  Smoothed,
1772
2115
  TimeScale,
1773
2116
  Tween,