emberwick 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -3,6 +3,83 @@
3
3
  All notable changes to Emberwick are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.5.0] — 2026-09-19
7
+
8
+ Correctness release. Thirteen defects, most of them able to put wrong data on
9
+ screen without raising anything. Minor rather than patch because two fixes
10
+ change observable behaviour: markers outside the loaded range are now hidden,
11
+ and `priceScale.marginBottom` finally does what it has always documented.
12
+
13
+ ### Fixed
14
+
15
+ - **An out-of-order tick no longer destroys the newest bar.** `append()` routed
16
+ any bar at or before the last one into an in-place overwrite, so a late tick
17
+ deleted the newest candle and left a duplicate timestamp — breaking the
18
+ ascending-by-time invariant that marker resolution's binary search relies on.
19
+ Equal timestamps still replace (that is an idempotent re-send of the forming
20
+ candle); older ones are dropped.
21
+ - **Timeframe is inferred from the median gap, not the first pair.** Any
22
+ exchange with a trading session puts a large gap at each day boundary; on NSE
23
+ minute data `bars[1] - bars[0]` across an overnight break reads as 17.75
24
+ hours. That number drove axis label density and Replay's future-marker
25
+ cut-off, so a bad inference could leak a future trade into playback.
26
+ - **A null OHLC value no longer collapses the price scale.** `isFinite(null)`
27
+ is `true`, so a null low passed the guard and dragged the minimum to zero,
28
+ flattening every candle into the top of the plot.
29
+ - **Log mode no longer collapses on a non-positive price.** A single zero tick
30
+ clamped to `1e-9` and turned the axis into a ~20-decade range.
31
+ - **A throwing frame no longer freezes the chart permanently.** `Loop` swapped
32
+ the dirty set out before calling the frame, so one exception lost the pending
33
+ layers and left nothing to reschedule. The set is now restored and retried;
34
+ after ten consecutive failures the loop stops with a clear message rather
35
+ than spinning at 60fps.
36
+ - **State-event dedupe is per listener.** `visibleRange` and `replay` shared a
37
+ single key, so a late subscriber recorded the current state as "already
38
+ sent" and every existing listener silently missed that update.
39
+ - **A transient history error no longer disables paging for good.** One failed
40
+ page used to latch `_exhausted` permanently; it now retries and gives up
41
+ only after three consecutive failures.
42
+ - **`Replay.seek()` ignores non-finite input.** `clamp()` compares with `<` and
43
+ `>`, both false against NaN, so `seek(NaN)` passed through and
44
+ `slice(0, NaN)` blanked the chart.
45
+ - **A replaced replay controller can no longer drive the chart.** Calling
46
+ `startReplay()` twice left the first controller live in the caller's hands,
47
+ still able to swap bars into a chart that had moved on. Controllers are now
48
+ detached by `startReplay()`, `stopReplay()`, `setData()` and `destroy()`.
49
+ - **Markers outside the loaded range are hidden rather than clamped.**
50
+ `nearestIndex()` clamps, so a trade from long before the window pinned itself
51
+ to bar 0 and read as an event at the left edge. Tolerance is one timeframe.
52
+ - **`setData()` invalidates the marker index cache**, like every other bar-array
53
+ mutator already did.
54
+ - **Generated marker ids no longer collide.** They were keyed off the array
55
+ index, so a remove-then-add could hand the newcomer an id a survivor owned,
56
+ and `removeMarker(id)` would take the wrong one.
57
+ - **`priceTicks()` cannot hang.** A non-finite bound made the step fall back to
58
+ 1 and the cursor start at `-Infinity`, where `v += step` never advances — an
59
+ infinite loop inside a frame.
60
+ - **`priceScale.marginBottom` is read.** It was documented, typed and stored,
61
+ but both bounds were padded from `marginTop`. With the default settings
62
+ (0.12 / 0.12) nothing changes; only charts that set them differently move.
63
+
64
+ ### Added
65
+
66
+ - `test/chart-correctness.test.mjs` — 18 cases; 17 fail against 0.4.2.
67
+ - `inferTimeframe(bars, fallback)` exported from `core/Chart.js` for testing.
68
+ - `Replay#detach()`.
69
+
70
+ ## [0.4.2] — 2026-09-19
71
+
72
+ Metadata only. The code is identical to 0.4.1; nothing needs re-testing.
73
+
74
+ ### Added
75
+
76
+ - `repository`, `homepage`, `bugs` and `author` in the published manifest, so
77
+ the npm package page links back to the source, the demo and the issue
78
+ tracker, and carries a publisher byline. These
79
+ belong in `package.lib.json` — the root `package.json` is private and never
80
+ reaches the registry — and 0.4.1 shipped without them. npm versions are
81
+ immutable, hence a new patch rather than a corrected republish.
82
+
6
83
  ## [0.4.1] — 2026-09-19
7
84
 
8
85
  Bug-fix release. Three feed-lifecycle races could put wrong prices on screen,
package/README.md CHANGED
@@ -114,8 +114,15 @@ One contract, used everywhere:
114
114
  }
115
115
  ```
116
116
 
117
- Bars must be **ascending by time** and **de-duplicated**. The chart infers the
118
- timeframe from the gap between the first two bars (or from `feed.timeframe`).
117
+ Bars must be **ascending by time** and **de-duplicated**. A bar older than the
118
+ newest one is dropped rather than applied an out-of-order tick would
119
+ otherwise overwrite the newest candle and leave a duplicate timestamp behind.
120
+
121
+ The chart infers the timeframe from the **median** gap between consecutive
122
+ bars, sampled across the dataset (or takes it from `feed.timeframe`). The
123
+ median rather than the first pair, because any exchange with a trading session
124
+ puts a large gap at each day boundary — on NSE minute data, `bars[1] - bars[0]`
125
+ across an overnight break reads as 17.75 hours.
119
126
 
120
127
  ---
121
128
 
@@ -423,6 +430,11 @@ A marker is pinned to a **timestamp**, not a bar index, and resolves to the
423
430
  nearest bar. Load an older page of history and every marker re-resolves, so
424
431
  nothing drifts off its candle.
425
432
 
433
+ A marker more than one timeframe outside the loaded range is **hidden**, not
434
+ clamped to the end bar. A trade from six months before the loaded window is
435
+ not an event that happened at the left edge of the chart, and drawing it there
436
+ is worse than not drawing it at all. Page that history in and it appears.
437
+
426
438
  | Field | Default | Notes |
427
439
  |---|---|---|
428
440
  | `time` | *required* | ms since epoch, snapped to the closest bar |
package/index.d.ts CHANGED
@@ -351,6 +351,12 @@ export declare class Replay {
351
351
  /** Clamped to 0.25–500. */
352
352
  setSpeed(speed: number): this
353
353
  setLoop(on: boolean): this
354
+ /**
355
+ * Stop this controller from ever touching the chart again. Called for you
356
+ * by startReplay(), stopReplay(), setData() and destroy(); a controller you
357
+ * are still holding after any of those is inert. Idempotent.
358
+ */
359
+ detach(): this
354
360
  /** Move the cursor. Out-of-range values clamp. */
355
361
  seek(index: number): this
356
362
  step(n?: number): this
package/index.js CHANGED
@@ -63,9 +63,11 @@ class Layers {
63
63
  this.ctx = {};
64
64
  }
65
65
  }
66
+ const MAX_FRAME_ERRORS = 10;
66
67
  class Loop {
67
68
  constructor(onFrame) {
68
69
  this.onFrame = onFrame;
70
+ this._frameErrors = 0;
69
71
  this.fps = 0;
70
72
  this._raf = 0;
71
73
  this._dirty = /* @__PURE__ */ new Set();
@@ -112,7 +114,18 @@ class Loop {
112
114
  let wantMore = false;
113
115
  try {
114
116
  wantMore = this.onFrame(dirty, dt, now) === true;
117
+ this._frameErrors = 0;
115
118
  } catch (e) {
119
+ for (const l of dirty) this._dirty.add(l);
120
+ if (++this._frameErrors >= MAX_FRAME_ERRORS) {
121
+ console.error(
122
+ `[Emberwick] frame error — stopping after ${MAX_FRAME_ERRORS} consecutive failures`,
123
+ e
124
+ );
125
+ this._dirty.clear();
126
+ this.stop();
127
+ return;
128
+ }
116
129
  console.error("[Emberwick] frame error", e);
117
130
  }
118
131
  if (wantMore || this._dirty.size) this._schedule();
@@ -322,28 +335,43 @@ class PriceScale {
322
335
  const t = 1 - (y - this.top) / this.height;
323
336
  return this._inv(a + t * (b - a));
324
337
  }
338
+ /**
339
+ * Is this a price this scale can actually plot?
340
+ *
341
+ * `isFinite(null)` is TRUE — null numifies to 0 — so a bar carrying a null
342
+ * low used to sail through the old isFinite() guard and drag the minimum to
343
+ * zero, flattening every candle into the top of the plot. Log mode has the
344
+ * same problem from the other end: log(0) is -Infinity, and the clamp to
345
+ * 1e-9 turns one zero tick into a ~20-decade range.
346
+ */
347
+ _plottable(v) {
348
+ return typeof v === "number" && isFinite(v) && (this.mode !== "log" || v > 0);
349
+ }
325
350
  /** Fit visible bars. `extra` lets the forming candle influence the range. */
326
351
  fit(bars, from, to, extra) {
327
352
  if (!this.auto || !bars.length) return;
328
353
  let min = Infinity;
329
354
  let max = -Infinity;
355
+ const consider = (lo, hi) => {
356
+ if (this._plottable(lo) && lo < min) min = lo;
357
+ if (this._plottable(hi) && hi > max) max = hi;
358
+ };
330
359
  for (let i = from; i <= to; i++) {
331
360
  const b2 = bars[i];
332
361
  if (!b2) continue;
333
- if (b2.low < min) min = b2.low;
334
- if (b2.high > max) max = b2.high;
335
- }
336
- if (extra) {
337
- if (extra.low < min) min = extra.low;
338
- if (extra.high > max) max = extra.high;
362
+ consider(b2.low, b2.high);
339
363
  }
364
+ if (extra) consider(extra.low, extra.high);
340
365
  if (!isFinite(min) || !isFinite(max)) return;
341
366
  let a = this._fwd(min);
342
367
  let b = this._fwd(max);
343
- let pad = (b - a) * this.marginTop;
344
- if (!(pad > 0)) pad = Math.abs(b) * 0.01 || 1;
345
- a -= pad;
346
- b += (b - a) * 0 + pad;
368
+ const span = b - a;
369
+ let padTop = span * this.marginTop;
370
+ let padBottom = span * this.marginBottom;
371
+ if (!(padTop > 0)) padTop = Math.abs(b) * 0.01 || 1;
372
+ if (!(padBottom > 0)) padBottom = Math.abs(a) * 0.01 || 1;
373
+ a -= padBottom;
374
+ b += padTop;
347
375
  this._lo.set(a);
348
376
  this._hi.set(b);
349
377
  if (!this._primed) {
@@ -518,6 +546,7 @@ class Replay {
518
546
  this._acc = 0;
519
547
  this._markerKey = "";
520
548
  this._markerView = null;
549
+ this._detached = false;
521
550
  this.minIndex = Math.min(1, this.lastIndex);
522
551
  const from = +options.from;
523
552
  this.index = clamp(
@@ -567,8 +596,14 @@ class Replay {
567
596
  };
568
597
  }
569
598
  // ------------------------------------------------------------- transport --
599
+ /** Stop this controller from ever touching the chart again. Idempotent. */
600
+ detach() {
601
+ this._detached = true;
602
+ this.playing = false;
603
+ return this;
604
+ }
570
605
  play() {
571
- if (this.playing || this.length < 2) return this;
606
+ if (this._detached || this.playing || this.length < 2) return this;
572
607
  if (this.atEnd) {
573
608
  this.index = this.minIndex;
574
609
  this._apply("seek");
@@ -604,8 +639,10 @@ class Replay {
604
639
  }
605
640
  /** Move the cursor. Out-of-range values clamp; playback keeps running. */
606
641
  seek(index) {
607
- const next = clamp(Math.round(+index), this.minIndex, this.lastIndex);
608
- if (next === this.index) return this;
642
+ const n = Math.round(+index);
643
+ if (!Number.isFinite(n)) return this;
644
+ const next = clamp(n, this.minIndex, this.lastIndex);
645
+ if (this._detached || next === this.index) return this;
609
646
  this.index = next;
610
647
  this._acc = 0;
611
648
  this._apply("seek");
@@ -626,7 +663,7 @@ class Replay {
626
663
  * true while playback is in flight, which is what keeps the loop awake.
627
664
  */
628
665
  tick(dt) {
629
- if (!this.playing || this.length < 2) return false;
666
+ if (this._detached || !this.playing || this.length < 2) return false;
630
667
  this._acc += dt * this.speed;
631
668
  const steps = Math.floor(this._acc / this.baseInterval);
632
669
  if (steps <= 0) return true;
@@ -684,6 +721,7 @@ class Replay {
684
721
  * animates; anything else swaps the prefix and re-anchors without easing.
685
722
  */
686
723
  _apply(mode) {
724
+ if (this._detached) return;
687
725
  const chart = this.chart;
688
726
  if (mode === "step" && chart.bars.length === this.index) {
689
727
  chart.append(this.source[this.index]);
@@ -694,6 +732,7 @@ class Replay {
694
732
  }
695
733
  /** Any state change needs a frame: that frame is what emits 'replay'. */
696
734
  _changed() {
735
+ if (this._detached) return;
697
736
  this.chart.loop.invalidate("main");
698
737
  }
699
738
  }
@@ -705,11 +744,17 @@ function niceStep(span, count) {
705
744
  const s = n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10;
706
745
  return s * mag;
707
746
  }
747
+ const MAX_TICKS = 1e3;
708
748
  function priceTicks(lo, hi, count) {
709
749
  const step = niceStep(hi - lo, count);
710
- const ticks = [];
750
+ if (!isFinite(lo) || !isFinite(hi) || hi < lo) return { ticks: [], step };
711
751
  const start = Math.ceil(lo / step) * step;
712
- for (let v = start; v <= hi + step * 1e-9; v += step) ticks.push(v);
752
+ const ticks = [];
753
+ for (let i = 0; i < MAX_TICKS; i++) {
754
+ const v = start + i * step;
755
+ if (v > hi + step * 1e-9) break;
756
+ ticks.push(v);
757
+ }
713
758
  return { ticks, step };
714
759
  }
715
760
  function decimalsFor(step) {
@@ -950,6 +995,7 @@ const MARKER_SHAPES = [
950
995
  "label"
951
996
  ];
952
997
  const SHAPE_SET = new Set(MARKER_SHAPES);
998
+ let autoId = 0;
953
999
  const DEFAULT_POSITION = {
954
1000
  arrowUp: "belowBar",
955
1001
  triangleUp: "belowBar",
@@ -962,7 +1008,7 @@ function normalizeMarker(raw, i) {
962
1008
  const shape = SHAPE_SET.has(raw.shape) ? raw.shape : "circle";
963
1009
  const position = POSITIONS.has(raw.position) ? raw.position : DEFAULT_POSITION[shape] || "aboveBar";
964
1010
  return {
965
- id: raw.id != null ? String(raw.id) : `mk${i}`,
1011
+ id: raw.id != null ? String(raw.id) : `mk${++autoId}`,
966
1012
  time: +raw.time,
967
1013
  price: isFinite(raw.price) ? +raw.price : null,
968
1014
  shape,
@@ -980,7 +1026,7 @@ function normalizeMarkers(list) {
980
1026
  if (!Array.isArray(list)) return [];
981
1027
  const out = [];
982
1028
  for (let i = 0; i < list.length; i++) {
983
- const m = normalizeMarker(list[i], i);
1029
+ const m = normalizeMarker(list[i]);
984
1030
  if (m) out.push(m);
985
1031
  }
986
1032
  out.sort((a, b) => a.time - b.time);
@@ -1004,9 +1050,14 @@ function nearestIndex(bars, time) {
1004
1050
  const b = Math.min(n - 1, lo);
1005
1051
  return Math.abs(bars[a].time - time) <= Math.abs(bars[b].time - time) ? a : b;
1006
1052
  }
1007
- function resolveMarkers(markers, bars) {
1053
+ function resolveMarkers(markers, bars, toleranceMs) {
1054
+ const n = bars.length;
1055
+ const tol = isFinite(toleranceMs) && toleranceMs > 0 ? toleranceMs : Infinity;
1056
+ const first = n ? bars[0].time - tol : 0;
1057
+ const last = n ? bars[n - 1].time + tol : 0;
1008
1058
  for (let i = 0; i < markers.length; i++) {
1009
- markers[i].index = nearestIndex(bars, markers[i].time);
1059
+ const t = markers[i].time;
1060
+ markers[i].index = !n || t < first || t > last ? -1 : nearestIndex(bars, t);
1010
1061
  }
1011
1062
  return markers;
1012
1063
  }
@@ -1275,6 +1326,21 @@ const inactiveReplay = () => ({
1275
1326
  bar: null,
1276
1327
  atEnd: false
1277
1328
  });
1329
+ const TF_SAMPLES = 200;
1330
+ const MAX_HISTORY_ERRORS = 3;
1331
+ function inferTimeframe(bars, fallback = 6e4) {
1332
+ const n = bars.length;
1333
+ if (n < 2) return fallback;
1334
+ const stride = Math.max(1, Math.floor(n / TF_SAMPLES));
1335
+ const gaps = [];
1336
+ for (let i = 1; i < n; i += stride) {
1337
+ const d = bars[i].time - bars[i - 1].time;
1338
+ if (d > 0) gaps.push(d);
1339
+ }
1340
+ if (!gaps.length) return fallback;
1341
+ gaps.sort((a, b) => a - b);
1342
+ return gaps[gaps.length >> 1];
1343
+ }
1278
1344
  class Chart {
1279
1345
  constructor(container, options = {}) {
1280
1346
  if (!container) throw new Error("Chart: container element is required");
@@ -1291,6 +1357,7 @@ class Chart {
1291
1357
  this._unsub = null;
1292
1358
  this._loadingHistory = false;
1293
1359
  this._exhausted = false;
1360
+ this._historyErrors = 0;
1294
1361
  this._replay = null;
1295
1362
  this._feedGen = 0;
1296
1363
  this._destroyed = false;
@@ -1308,6 +1375,7 @@ class Chart {
1308
1375
  this._markerHits = [];
1309
1376
  this._hoverMarkerId = null;
1310
1377
  this._resolveKey = "";
1378
+ this._stateKeys = { visibleRange: /* @__PURE__ */ new Map(), replay: /* @__PURE__ */ new Map() };
1311
1379
  this.layers = new Layers(container, ["base", "main", "overlay"]);
1312
1380
  this.ts = new TimeScale(options.timeScale);
1313
1381
  this.ps = new PriceScale(options.priceScale);
@@ -1336,11 +1404,16 @@ class Chart {
1336
1404
  }
1337
1405
  // ------------------------------------------------------------------ data --
1338
1406
  setData(bars) {
1339
- if (this._replay) this._replay = null;
1407
+ if (this._replay) {
1408
+ this._replay.detach();
1409
+ this._replay = null;
1410
+ }
1340
1411
  this.bars = Array.isArray(bars) ? bars.slice() : [];
1341
1412
  this._exhausted = false;
1413
+ this._historyErrors = 0;
1414
+ this._resolveKey = "";
1342
1415
  if (this.bars.length > 1) {
1343
- this.ts.timeframeMs = this.bars[1].time - this.bars[0].time;
1416
+ this.ts.timeframeMs = inferTimeframe(this.bars, this.ts.timeframeMs);
1344
1417
  }
1345
1418
  this.ts.setBarCount(this.bars.length);
1346
1419
  this.ts.snapToRealtime();
@@ -1358,7 +1431,7 @@ class Chart {
1358
1431
  _swapBars(bars) {
1359
1432
  this.bars = Array.isArray(bars) ? bars : [];
1360
1433
  if (this.bars.length > 1) {
1361
- this.ts.timeframeMs = this.bars[1].time - this.bars[0].time;
1434
+ this.ts.timeframeMs = inferTimeframe(this.bars, this.ts.timeframeMs);
1362
1435
  }
1363
1436
  this.ts.setBarCount(this.bars.length);
1364
1437
  this.live.reset();
@@ -1378,11 +1451,21 @@ class Chart {
1378
1451
  this.live.setTarget(bar);
1379
1452
  this.loop.invalidate("main");
1380
1453
  }
1381
- /** Open a new candle; the previous one is now closed. */
1454
+ /**
1455
+ * Open a new candle; the previous one is now closed.
1456
+ *
1457
+ * A bar OLDER than the newest one is dropped rather than applied. It used
1458
+ * to overwrite the last element — so a late tick silently deleted the
1459
+ * newest candle and left a duplicate timestamp behind, which breaks the
1460
+ * ascending-by-time invariant that marker resolution's binary search
1461
+ * depends on. Feeds are documented as ascending and de-duplicated
1462
+ * (data/DataFeed.js); this is the guard for the ones that are not.
1463
+ */
1382
1464
  append(bar) {
1383
1465
  if (!bar) return;
1384
1466
  const n = this.bars.length;
1385
- if (n && bar.time <= this.bars[n - 1].time) {
1467
+ if (n && bar.time < this.bars[n - 1].time) return;
1468
+ if (n && bar.time === this.bars[n - 1].time) {
1386
1469
  this.bars[n - 1] = bar;
1387
1470
  } else {
1388
1471
  this.bars.push(bar);
@@ -1424,6 +1507,7 @@ class Chart {
1424
1507
  detachFeed() {
1425
1508
  this._feedGen++;
1426
1509
  this._loadingHistory = false;
1510
+ this._historyErrors = 0;
1427
1511
  if (this._unsub) this._unsub();
1428
1512
  this._unsub = null;
1429
1513
  this.feed = null;
@@ -1455,13 +1539,14 @@ class Chart {
1455
1539
  this._exhausted = true;
1456
1540
  return;
1457
1541
  }
1542
+ this._historyErrors = 0;
1458
1543
  this.bars = added.concat(this.bars);
1459
1544
  this.ts.barCount = this.bars.length;
1460
1545
  this.ts._right.jump(this.ts._right.value + added.length);
1461
1546
  this.loop.invalidate("all");
1462
1547
  } catch (err) {
1463
1548
  if (gen !== this._feedGen || this._destroyed) return;
1464
- this._exhausted = true;
1549
+ if (++this._historyErrors >= MAX_HISTORY_ERRORS) this._exhausted = true;
1465
1550
  this._emitError(err, "loadHistory");
1466
1551
  } finally {
1467
1552
  if (gen === this._feedGen) this._loadingHistory = false;
@@ -1663,15 +1748,19 @@ class Chart {
1663
1748
  set.add(fn);
1664
1749
  if (event === "visibleRange") {
1665
1750
  const payload = this.visibleRange();
1666
- this._rangeKey = this._rangeIdentity(payload);
1751
+ this._stateKeys.visibleRange.set(fn, this._rangeIdentity(payload));
1667
1752
  fn(payload);
1668
1753
  }
1669
1754
  if (event === "replay") {
1670
1755
  const payload = this.replayState();
1671
- this._replayKey = this._replayIdentity(payload);
1756
+ this._stateKeys.replay.set(fn, this._replayIdentity(payload));
1672
1757
  fn(payload);
1673
1758
  }
1674
- return () => set.delete(fn);
1759
+ return () => {
1760
+ set.delete(fn);
1761
+ const keys = this._stateKeys[event];
1762
+ if (keys) keys.delete(fn);
1763
+ };
1675
1764
  }
1676
1765
  // ------------------------------------------------------------------ range --
1677
1766
  /**
@@ -1709,13 +1798,23 @@ class Chart {
1709
1798
  * work until the view stops moving would wait forever.
1710
1799
  */
1711
1800
  _emitVisibleRange(from, to) {
1712
- const set = this._listeners.visibleRange;
1801
+ this._emitState("visibleRange", this._rangePayload(from, to), this._rangeIdentity);
1802
+ }
1803
+ /**
1804
+ * Deliver a state event to every listener that has not already seen this
1805
+ * exact state. Keys are per-listener, so one subscriber can never suppress
1806
+ * another's update, and a brand-new listener (no key yet) always gets one.
1807
+ */
1808
+ _emitState(event, payload, identity) {
1809
+ const set = this._listeners[event];
1713
1810
  if (!set.size) return;
1714
- const payload = this._rangePayload(from, to);
1715
- const key = this._rangeIdentity(payload);
1716
- if (key === this._rangeKey) return;
1717
- this._rangeKey = key;
1718
- for (const fn of set) fn(payload);
1811
+ const key = identity.call(this, payload);
1812
+ const keys = this._stateKeys[event];
1813
+ for (const fn of set) {
1814
+ if (keys.get(fn) === key) continue;
1815
+ keys.set(fn, key);
1816
+ fn(payload);
1817
+ }
1719
1818
  }
1720
1819
  // ----------------------------------------------------------------- replay --
1721
1820
  /**
@@ -1742,6 +1841,7 @@ class Chart {
1742
1841
  const source = Array.isArray(options.bars) && options.bars.length ? options.bars : this._replay ? this._replay.source : this.bars;
1743
1842
  if (!source || source.length < 2) return null;
1744
1843
  const dataset = source.slice();
1844
+ if (this._replay) this._replay.detach();
1745
1845
  this._replay = new Replay(this, { ...options, bars: dataset });
1746
1846
  return this._replay;
1747
1847
  }
@@ -1749,6 +1849,7 @@ class Chart {
1749
1849
  stopReplay() {
1750
1850
  if (!this._replay) return;
1751
1851
  const full = this._replay.source;
1852
+ this._replay.detach();
1752
1853
  this._replay = null;
1753
1854
  this.setData(full);
1754
1855
  }
@@ -1769,13 +1870,7 @@ class Chart {
1769
1870
  * paused replay emits nothing at all.
1770
1871
  */
1771
1872
  _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);
1873
+ this._emitState("replay", this.replayState(), this._replayIdentity);
1779
1874
  }
1780
1875
  // ----------------------------------------------------------- annotations --
1781
1876
  /** Replace every marker. Each `time` is resolved to its nearest bar. */
@@ -1848,7 +1943,7 @@ class Chart {
1848
1943
  if (markers.length) {
1849
1944
  const key = markers.length + ":" + this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
1850
1945
  if (key !== this._resolveKey) {
1851
- resolveMarkers(markers, this.bars);
1946
+ resolveMarkers(markers, this.bars, this.ts.timeframeMs);
1852
1947
  this._resolveKey = key;
1853
1948
  }
1854
1949
  }
@@ -1917,6 +2012,8 @@ class Chart {
1917
2012
  this.loop.stop();
1918
2013
  this.layers.destroy();
1919
2014
  for (const set of Object.values(this._listeners)) set.clear();
2015
+ for (const keys of Object.values(this._stateKeys)) keys.clear();
2016
+ if (this._replay) this._replay.detach();
1920
2017
  this._replay = null;
1921
2018
  this._markers = [];
1922
2019
  this._markerHits = [];
@@ -2100,7 +2197,7 @@ class RandomFeed extends DataFeed {
2100
2197
  function createChart(container, options) {
2101
2198
  return new Chart(container, options);
2102
2199
  }
2103
- const version = "0.4.1";
2200
+ const version = "0.5.0";
2104
2201
  export {
2105
2202
  Chart,
2106
2203
  DataFeed,