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/CHANGELOG.md +66 -0
- package/README.md +106 -2
- package/index.d.ts +95 -0
- package/index.js +384 -41
- package/index.js.map +1 -1
- package/package.json +1 -1
- package/umd/emberwick.umd.js +1 -1
- package/umd/emberwick.umd.js.map +1 -1
package/index.js
CHANGED
|
@@ -169,7 +169,7 @@ class Tween {
|
|
|
169
169
|
return true;
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
|
-
const clamp$
|
|
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$
|
|
220
|
-
to: clamp$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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,
|