emberwick 0.2.0 → 0.4.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/README.md +122 -3
- package/index.d.ts +120 -2
- package/index.js +375 -13
- 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,13 @@ class Chart {
|
|
|
1075
1291
|
this._unsub = null;
|
|
1076
1292
|
this._loadingHistory = false;
|
|
1077
1293
|
this._exhausted = false;
|
|
1294
|
+
this._replay = null;
|
|
1078
1295
|
this._listeners = {
|
|
1079
1296
|
crosshair: /* @__PURE__ */ new Set(),
|
|
1080
1297
|
visibleRange: /* @__PURE__ */ new Set(),
|
|
1081
1298
|
markerClick: /* @__PURE__ */ new Set(),
|
|
1082
|
-
markerHover: /* @__PURE__ */ new Set()
|
|
1299
|
+
markerHover: /* @__PURE__ */ new Set(),
|
|
1300
|
+
replay: /* @__PURE__ */ new Set()
|
|
1083
1301
|
};
|
|
1084
1302
|
this._markers = normalizeMarkers(options.markers);
|
|
1085
1303
|
this.priceLines = Array.isArray(options.priceLines) ? options.priceLines.slice() : [];
|
|
@@ -1115,6 +1333,7 @@ class Chart {
|
|
|
1115
1333
|
}
|
|
1116
1334
|
// ------------------------------------------------------------------ data --
|
|
1117
1335
|
setData(bars) {
|
|
1336
|
+
if (this._replay) this._replay = null;
|
|
1118
1337
|
this.bars = Array.isArray(bars) ? bars.slice() : [];
|
|
1119
1338
|
this._exhausted = false;
|
|
1120
1339
|
if (this.bars.length > 1) {
|
|
@@ -1126,6 +1345,23 @@ class Chart {
|
|
|
1126
1345
|
this.ps._primed = false;
|
|
1127
1346
|
this.loop.invalidate("all");
|
|
1128
1347
|
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Replace the bar array WITHOUT re-anchoring the view — the deliberate
|
|
1350
|
+
* difference from setData(), which snaps to the right edge and re-primes
|
|
1351
|
+
* the price scale. Replay swaps its revealed prefix through here on every
|
|
1352
|
+
* scrub, so a snap would fight the user's zoom and the price scale would
|
|
1353
|
+
* pop instead of easing between windows.
|
|
1354
|
+
*/
|
|
1355
|
+
_swapBars(bars) {
|
|
1356
|
+
this.bars = Array.isArray(bars) ? bars : [];
|
|
1357
|
+
if (this.bars.length > 1) {
|
|
1358
|
+
this.ts.timeframeMs = this.bars[1].time - this.bars[0].time;
|
|
1359
|
+
}
|
|
1360
|
+
this.ts.setBarCount(this.bars.length);
|
|
1361
|
+
this.live.reset();
|
|
1362
|
+
this._resolveKey = "";
|
|
1363
|
+
this.loop.invalidate("all");
|
|
1364
|
+
}
|
|
1129
1365
|
/** Merge a tick into the forming candle (animated). */
|
|
1130
1366
|
update(bar) {
|
|
1131
1367
|
if (!bar) return;
|
|
@@ -1167,6 +1403,7 @@ class Chart {
|
|
|
1167
1403
|
if (typeof feed.prime === "function") feed.prime(bars[bars.length - 1]);
|
|
1168
1404
|
this._unsub = feed.subscribe((msg) => {
|
|
1169
1405
|
if (!msg || !msg.bar) return;
|
|
1406
|
+
if (this._replay) return;
|
|
1170
1407
|
if (msg.type === "append") this.append(msg.bar);
|
|
1171
1408
|
else this.update(msg.bar);
|
|
1172
1409
|
});
|
|
@@ -1177,6 +1414,7 @@ class Chart {
|
|
|
1177
1414
|
this.feed = null;
|
|
1178
1415
|
}
|
|
1179
1416
|
async _maybeLoadHistory() {
|
|
1417
|
+
if (this._replay) return;
|
|
1180
1418
|
if (this._loadingHistory || this._exhausted || !this.feed) return;
|
|
1181
1419
|
const { from } = this.ts.visibleRange();
|
|
1182
1420
|
if (from > 80 || !this.bars.length) return;
|
|
@@ -1394,13 +1632,128 @@ class Chart {
|
|
|
1394
1632
|
const set = this._listeners[event];
|
|
1395
1633
|
if (!set) throw new Error(`Chart: unknown event "${event}"`);
|
|
1396
1634
|
set.add(fn);
|
|
1635
|
+
if (event === "visibleRange") {
|
|
1636
|
+
const payload = this.visibleRange();
|
|
1637
|
+
this._rangeKey = this._rangeIdentity(payload);
|
|
1638
|
+
fn(payload);
|
|
1639
|
+
}
|
|
1640
|
+
if (event === "replay") {
|
|
1641
|
+
const payload = this.replayState();
|
|
1642
|
+
this._replayKey = this._replayIdentity(payload);
|
|
1643
|
+
fn(payload);
|
|
1644
|
+
}
|
|
1397
1645
|
return () => set.delete(fn);
|
|
1398
1646
|
}
|
|
1647
|
+
// ------------------------------------------------------------------ range --
|
|
1648
|
+
/**
|
|
1649
|
+
* The window currently on screen. Cheap enough to poll, though
|
|
1650
|
+
* subscribe('visibleRange', fn) is the better way to track it.
|
|
1651
|
+
*/
|
|
1652
|
+
visibleRange() {
|
|
1653
|
+
const { from, to } = this.ts.visibleRange();
|
|
1654
|
+
return this._rangePayload(from, to);
|
|
1655
|
+
}
|
|
1656
|
+
_rangePayload(from, to) {
|
|
1657
|
+
const n = this.bars.length;
|
|
1658
|
+
return {
|
|
1659
|
+
from,
|
|
1660
|
+
to,
|
|
1661
|
+
fromTime: n ? this.bars[from].time : null,
|
|
1662
|
+
toTime: n ? this.bars[to].time : null,
|
|
1663
|
+
barCount: n,
|
|
1664
|
+
spacing: this.ts.spacing,
|
|
1665
|
+
settled: this.ts.settled
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
_rangeIdentity(p) {
|
|
1669
|
+
return `${p.from}:${p.to}:${p.fromTime}:${p.toTime}:${p.settled ? 1 : 0}`;
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Fires only when the window actually changed, so a consumer can hang a
|
|
1673
|
+
* fetch off it without debouncing. Two deliberate choices:
|
|
1674
|
+
*
|
|
1675
|
+
* - `spacing` is NOT part of the identity. It is a float that moves on every
|
|
1676
|
+
* frame of an eased zoom, so keying on it would make this a 60/sec
|
|
1677
|
+
* firehose. It is still reported, for level-of-detail decisions.
|
|
1678
|
+
* - `settled` IS part of the identity, so the final event of a gesture
|
|
1679
|
+
* always arrives with settled:true. Without it, code that defers expensive
|
|
1680
|
+
* work until the view stops moving would wait forever.
|
|
1681
|
+
*/
|
|
1682
|
+
_emitVisibleRange(from, to) {
|
|
1683
|
+
const set = this._listeners.visibleRange;
|
|
1684
|
+
if (!set.size) return;
|
|
1685
|
+
const payload = this._rangePayload(from, to);
|
|
1686
|
+
const key = this._rangeIdentity(payload);
|
|
1687
|
+
if (key === this._rangeKey) return;
|
|
1688
|
+
this._rangeKey = key;
|
|
1689
|
+
for (const fn of set) fn(payload);
|
|
1690
|
+
}
|
|
1691
|
+
// ----------------------------------------------------------------- replay --
|
|
1692
|
+
/**
|
|
1693
|
+
* Start bar-by-bar playback over a fixed dataset.
|
|
1694
|
+
*
|
|
1695
|
+
* With no `bars`, the chart's CURRENT data becomes the dataset — the usual
|
|
1696
|
+
* case: load history, then replay it. The chart is not switched into a
|
|
1697
|
+
* special mode; it is simply handed the revealed prefix, so scales,
|
|
1698
|
+
* annotations, the crosshair and visibleRange all keep behaving normally.
|
|
1699
|
+
*
|
|
1700
|
+
* chart.startReplay({ from: 200, speed: 4 })
|
|
1701
|
+
* chart.replay.play()
|
|
1702
|
+
*
|
|
1703
|
+
* @param {object} [options]
|
|
1704
|
+
* @param {Bar[]} [options.bars] dataset (defaults to current bars)
|
|
1705
|
+
* @param {number} [options.from] starting index (default: midpoint)
|
|
1706
|
+
* @param {number} [options.speed] rate multiplier, 0.25–500
|
|
1707
|
+
* @param {number} [options.baseInterval] real ms per bar at 1× (default 1000)
|
|
1708
|
+
* @param {boolean} [options.loop] restart at the end
|
|
1709
|
+
* @param {boolean} [options.follow] re-anchor the right edge on scrub
|
|
1710
|
+
* @returns {Replay|null} null if there are fewer than two bars to replay
|
|
1711
|
+
*/
|
|
1712
|
+
startReplay(options = {}) {
|
|
1713
|
+
const source = Array.isArray(options.bars) && options.bars.length ? options.bars : this._replay ? this._replay.source : this.bars;
|
|
1714
|
+
if (!source || source.length < 2) return null;
|
|
1715
|
+
const dataset = source.slice();
|
|
1716
|
+
this._replay = new Replay(this, { ...options, bars: dataset });
|
|
1717
|
+
return this._replay;
|
|
1718
|
+
}
|
|
1719
|
+
/** Leave replay and reveal the whole dataset again. */
|
|
1720
|
+
stopReplay() {
|
|
1721
|
+
if (!this._replay) return;
|
|
1722
|
+
const full = this._replay.source;
|
|
1723
|
+
this._replay = null;
|
|
1724
|
+
this.setData(full);
|
|
1725
|
+
}
|
|
1726
|
+
/** The active controller, or null. */
|
|
1727
|
+
get replay() {
|
|
1728
|
+
return this._replay;
|
|
1729
|
+
}
|
|
1730
|
+
/** Current playback state; `{ active: false, ... }` when not replaying. */
|
|
1731
|
+
replayState() {
|
|
1732
|
+
return this._replay ? this._replay.state() : inactiveReplay();
|
|
1733
|
+
}
|
|
1734
|
+
_replayIdentity(p) {
|
|
1735
|
+
return `${p.active ? 1 : 0}:${p.playing ? 1 : 0}:${p.index}:${p.length}:${p.speed}`;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Emitted from the frame, like visibleRange, so a handler can safely touch
|
|
1739
|
+
* the chart. Keyed on cursor + transport, not on the payload object, so a
|
|
1740
|
+
* paused replay emits nothing at all.
|
|
1741
|
+
*/
|
|
1742
|
+
_emitReplay() {
|
|
1743
|
+
const set = this._listeners.replay;
|
|
1744
|
+
if (!set.size) return;
|
|
1745
|
+
const payload = this.replayState();
|
|
1746
|
+
const key = this._replayIdentity(payload);
|
|
1747
|
+
if (key === this._replayKey) return;
|
|
1748
|
+
this._replayKey = key;
|
|
1749
|
+
for (const fn of set) fn(payload);
|
|
1750
|
+
}
|
|
1399
1751
|
// ----------------------------------------------------------- annotations --
|
|
1400
1752
|
/** Replace every marker. Each `time` is resolved to its nearest bar. */
|
|
1401
1753
|
setMarkers(markers) {
|
|
1402
1754
|
this._markers = normalizeMarkers(markers);
|
|
1403
1755
|
this._resolveKey = "";
|
|
1756
|
+
if (this._replay) this._replay.invalidateMarkers();
|
|
1404
1757
|
this.loop.invalidate("main");
|
|
1405
1758
|
}
|
|
1406
1759
|
/** The current markers, normalised, each with its resolved bar index. */
|
|
@@ -1430,6 +1783,7 @@ class Chart {
|
|
|
1430
1783
|
// ----------------------------------------------------------------- frame --
|
|
1431
1784
|
_frame(dirty, dt) {
|
|
1432
1785
|
let animating = false;
|
|
1786
|
+
if (this._replay && this._replay.tick(dt)) animating = true;
|
|
1433
1787
|
if (this.ts.tick(dt)) animating = true;
|
|
1434
1788
|
const dx = this.inertia.tick(dt);
|
|
1435
1789
|
if (dx) {
|
|
@@ -1460,10 +1814,12 @@ class Chart {
|
|
|
1460
1814
|
priceLines: this.priceLines,
|
|
1461
1815
|
zones: this.zones
|
|
1462
1816
|
};
|
|
1463
|
-
|
|
1464
|
-
|
|
1817
|
+
let markers = this._markers;
|
|
1818
|
+
if (this._replay && markers.length) markers = this._replay.markerFilter(markers);
|
|
1819
|
+
if (markers.length) {
|
|
1820
|
+
const key = markers.length + ":" + this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
|
|
1465
1821
|
if (key !== this._resolveKey) {
|
|
1466
|
-
resolveMarkers(
|
|
1822
|
+
resolveMarkers(markers, this.bars);
|
|
1467
1823
|
this._resolveKey = key;
|
|
1468
1824
|
}
|
|
1469
1825
|
}
|
|
@@ -1475,13 +1831,15 @@ class Chart {
|
|
|
1475
1831
|
this._markerHits = drawMarkers(
|
|
1476
1832
|
this.layers.ctx.main,
|
|
1477
1833
|
state,
|
|
1478
|
-
layoutMarkers(
|
|
1834
|
+
layoutMarkers(markers, state),
|
|
1479
1835
|
this._hoverMarkerId
|
|
1480
1836
|
);
|
|
1481
1837
|
}
|
|
1482
1838
|
if (redrawAll || dirty.has("overlay")) {
|
|
1483
1839
|
drawCrosshair(this.layers.ctx.overlay, state);
|
|
1484
1840
|
}
|
|
1841
|
+
this._emitVisibleRange(from, to);
|
|
1842
|
+
this._emitReplay();
|
|
1485
1843
|
return animating;
|
|
1486
1844
|
}
|
|
1487
1845
|
// ------------------------------------------------------------------- api --
|
|
@@ -1528,6 +1886,7 @@ class Chart {
|
|
|
1528
1886
|
this.loop.stop();
|
|
1529
1887
|
this.layers.destroy();
|
|
1530
1888
|
for (const set of Object.values(this._listeners)) set.clear();
|
|
1889
|
+
this._replay = null;
|
|
1531
1890
|
this._markers = [];
|
|
1532
1891
|
this._markerHits = [];
|
|
1533
1892
|
this.priceLines = [];
|
|
@@ -1710,14 +2069,17 @@ class RandomFeed extends DataFeed {
|
|
|
1710
2069
|
function createChart(container, options) {
|
|
1711
2070
|
return new Chart(container, options);
|
|
1712
2071
|
}
|
|
1713
|
-
const version = "0.
|
|
2072
|
+
const version = "0.4.0";
|
|
1714
2073
|
export {
|
|
1715
2074
|
Chart,
|
|
1716
2075
|
DataFeed,
|
|
1717
2076
|
Inertia,
|
|
1718
2077
|
LiveCandle,
|
|
2078
|
+
MAX_SPEED,
|
|
2079
|
+
MIN_SPEED,
|
|
1719
2080
|
PriceScale,
|
|
1720
2081
|
RandomFeed,
|
|
2082
|
+
Replay,
|
|
1721
2083
|
Smoothed,
|
|
1722
2084
|
TimeScale,
|
|
1723
2085
|
Tween,
|