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 +77 -0
- package/README.md +14 -2
- package/index.d.ts +6 -0
- package/index.js +141 -44
- package/index.js.map +1 -1
- package/package.json +10 -1
- package/umd/emberwick.umd.js +1 -1
- package/umd/emberwick.umd.js.map +1 -1
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**.
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
608
|
-
if (
|
|
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
|
-
|
|
750
|
+
if (!isFinite(lo) || !isFinite(hi) || hi < lo) return { ticks: [], step };
|
|
711
751
|
const start = Math.ceil(lo / step) * step;
|
|
712
|
-
|
|
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${
|
|
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]
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
1756
|
+
this._stateKeys.replay.set(fn, this._replayIdentity(payload));
|
|
1672
1757
|
fn(payload);
|
|
1673
1758
|
}
|
|
1674
|
-
return () =>
|
|
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
|
-
|
|
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
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
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.
|
|
2200
|
+
const version = "0.5.0";
|
|
2104
2201
|
export {
|
|
2105
2202
|
Chart,
|
|
2106
2203
|
DataFeed,
|