emberwick 0.3.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/index.js CHANGED
@@ -169,7 +169,7 @@ class Tween {
169
169
  return true;
170
170
  }
171
171
  }
172
- const clamp$1 = (v, a, b) => v < a ? a : v > b ? b : v;
172
+ const clamp$2 = (v, a, b) => v < a ? a : v > b ? b : v;
173
173
  class TimeScale {
174
174
  constructor({ spacing = 9, minSpacing = 0.8, maxSpacing = 160, rightOffset = 12 } = {}) {
175
175
  this.minSpacing = minSpacing;
@@ -216,14 +216,14 @@ class TimeScale {
216
216
  const first = Math.floor(this.index(0)) - 1;
217
217
  const last = Math.ceil(this.index(this.width)) + 1;
218
218
  return {
219
- from: clamp$1(first, 0, Math.max(0, this.barCount - 1)),
220
- to: clamp$1(last, 0, Math.max(0, this.barCount - 1))
219
+ from: clamp$2(first, 0, Math.max(0, this.barCount - 1)),
220
+ to: clamp$2(last, 0, Math.max(0, this.barCount - 1))
221
221
  };
222
222
  }
223
223
  _clampRight(v) {
224
224
  const max = this.barCount - 1 + this.rightOffset + this.width / this._spacing.target;
225
225
  const min = Math.min(4, this.barCount - 1 + this.rightOffset);
226
- return clamp$1(v, min, max);
226
+ return clamp$2(v, min, max);
227
227
  }
228
228
  /** Immediate pan, in pixels. Positive dx drags content right (back in time). */
229
229
  panBy(dxPx) {
@@ -236,7 +236,7 @@ class TimeScale {
236
236
  /** Zoom by `factor`, keeping the bar under `x` pinned. */
237
237
  zoomAt(x, factor) {
238
238
  const s0 = this._spacing.target;
239
- const s1 = clamp$1(s0 * factor, this.minSpacing, this.maxSpacing);
239
+ const s1 = clamp$2(s0 * factor, this.minSpacing, this.maxSpacing);
240
240
  if (Math.abs(s1 - s0) < 1e-9) return false;
241
241
  const anchorX = this.follow ? this.width : x;
242
242
  const r0 = this._right.target;
@@ -250,6 +250,15 @@ class TimeScale {
250
250
  this.follow = true;
251
251
  this._right.set(this.barCount - 1 + this.rightOffset);
252
252
  }
253
+ /**
254
+ * Same anchor, no easing. Scrubbing re-targets the right edge many times a
255
+ * second; easing each one reads as the chart lagging the scrubber, which is
256
+ * the same reason a drag uses jump().
257
+ */
258
+ jumpToRealtime() {
259
+ this.follow = true;
260
+ this._right.jump(this.barCount - 1 + this.rightOffset);
261
+ }
253
262
  reset() {
254
263
  this._spacing.set(this._initial);
255
264
  this.snapToRealtime();
@@ -264,7 +273,7 @@ class TimeScale {
264
273
  return this._spacing.settled && this._right.settled;
265
274
  }
266
275
  }
267
- const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
276
+ const clamp$1 = (v, a, b) => v < a ? a : v > b ? b : v;
268
277
  class PriceScale {
269
278
  constructor({ mode = "linear", tau = 120, marginTop = 0.12, marginBottom = 0.12 } = {}) {
270
279
  this.mode = mode;
@@ -349,7 +358,7 @@ class PriceScale {
349
358
  const a = this._lo.target;
350
359
  const b = this._hi.target;
351
360
  const mid = (a + b) / 2;
352
- const half = (b - a) / 2 * clamp(factor, 0.2, 5);
361
+ const half = (b - a) / 2 * clamp$1(factor, 0.2, 5);
353
362
  this._lo.set(mid - half);
354
363
  this._hi.set(mid + half);
355
364
  }
@@ -492,6 +501,202 @@ class Inertia {
492
501
  return dx;
493
502
  }
494
503
  }
504
+ const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
505
+ const MIN_SPEED = 0.25;
506
+ const MAX_SPEED = 500;
507
+ const MAX_STEPS_PER_FRAME = 240;
508
+ class Replay {
509
+ constructor(chart, options = {}) {
510
+ const src = Array.isArray(options.bars) ? options.bars : [];
511
+ this.chart = chart;
512
+ this.source = src.slice();
513
+ this.baseInterval = Math.max(16, +options.baseInterval || 1e3);
514
+ this.speed = clamp(+options.speed || 1, MIN_SPEED, MAX_SPEED);
515
+ this.looping = options.loop === true;
516
+ this.follow = options.follow !== false;
517
+ this.playing = false;
518
+ this._acc = 0;
519
+ this._markerKey = "";
520
+ this._markerView = null;
521
+ this.minIndex = Math.min(1, this.lastIndex);
522
+ const from = +options.from;
523
+ this.index = clamp(
524
+ Number.isFinite(from) ? Math.round(from) : Math.floor(this.lastIndex / 2),
525
+ this.minIndex,
526
+ this.lastIndex
527
+ );
528
+ this._apply("seek");
529
+ }
530
+ // ----------------------------------------------------------------- state --
531
+ get length() {
532
+ return this.source.length;
533
+ }
534
+ get lastIndex() {
535
+ return Math.max(0, this.source.length - 1);
536
+ }
537
+ get atEnd() {
538
+ return this.index >= this.lastIndex;
539
+ }
540
+ get bar() {
541
+ return this.source[this.index] || null;
542
+ }
543
+ get time() {
544
+ return this.bar ? this.bar.time : null;
545
+ }
546
+ /** 0 at the first playable bar, 1 at the last. */
547
+ get progress() {
548
+ const span = this.lastIndex - this.minIndex;
549
+ return span > 0 ? (this.index - this.minIndex) / span : 1;
550
+ }
551
+ /** Real ms between bars at the current speed. */
552
+ get interval() {
553
+ return this.baseInterval / this.speed;
554
+ }
555
+ /** Snapshot handed to `subscribe('replay', fn)`. */
556
+ state() {
557
+ return {
558
+ active: true,
559
+ playing: this.playing,
560
+ index: this.index,
561
+ length: this.length,
562
+ progress: this.progress,
563
+ speed: this.speed,
564
+ time: this.time,
565
+ bar: this.bar,
566
+ atEnd: this.atEnd
567
+ };
568
+ }
569
+ // ------------------------------------------------------------- transport --
570
+ play() {
571
+ if (this.playing || this.length < 2) return this;
572
+ if (this.atEnd) {
573
+ this.index = this.minIndex;
574
+ this._apply("seek");
575
+ }
576
+ this.playing = true;
577
+ this._acc = 0;
578
+ this._changed();
579
+ return this;
580
+ }
581
+ pause() {
582
+ if (!this.playing) return this;
583
+ this.playing = false;
584
+ this._acc = 0;
585
+ this._changed();
586
+ return this;
587
+ }
588
+ toggle() {
589
+ return this.playing ? this.pause() : this.play();
590
+ }
591
+ /** Multiplier on `baseInterval`. Clamped to 0.25×–500×. */
592
+ setSpeed(speed) {
593
+ const s = clamp(+speed || 1, MIN_SPEED, MAX_SPEED);
594
+ if (s === this.speed) return this;
595
+ this.speed = s;
596
+ this._acc = 0;
597
+ this._changed();
598
+ return this;
599
+ }
600
+ setLoop(on) {
601
+ this.looping = !!on;
602
+ this._changed();
603
+ return this;
604
+ }
605
+ /** Move the cursor. Out-of-range values clamp; playback keeps running. */
606
+ seek(index) {
607
+ const next = clamp(Math.round(+index), this.minIndex, this.lastIndex);
608
+ if (next === this.index) return this;
609
+ this.index = next;
610
+ this._acc = 0;
611
+ this._apply("seek");
612
+ this._changed();
613
+ return this;
614
+ }
615
+ step(n = 1) {
616
+ return this.seek(this.index + (n || 0));
617
+ }
618
+ toStart() {
619
+ return this.seek(this.minIndex);
620
+ }
621
+ toEnd() {
622
+ return this.seek(this.lastIndex);
623
+ }
624
+ /**
625
+ * Advance with wall-clock time. Called once per frame by the chart; returns
626
+ * true while playback is in flight, which is what keeps the loop awake.
627
+ */
628
+ tick(dt) {
629
+ if (!this.playing || this.length < 2) return false;
630
+ this._acc += dt * this.speed;
631
+ const steps = Math.floor(this._acc / this.baseInterval);
632
+ if (steps <= 0) return true;
633
+ this._acc -= steps * this.baseInterval;
634
+ const last = this.lastIndex;
635
+ let next = this.index + Math.min(steps, MAX_STEPS_PER_FRAME);
636
+ if (next > last) {
637
+ if (this.looping) {
638
+ this.index = this.minIndex;
639
+ this._apply("seek");
640
+ this._changed();
641
+ return true;
642
+ }
643
+ next = last;
644
+ }
645
+ const single = next === this.index + 1;
646
+ this.index = next;
647
+ this._apply(single ? "step" : "seek");
648
+ if (this.atEnd && !this.looping) {
649
+ this.playing = false;
650
+ this._acc = 0;
651
+ }
652
+ this._changed();
653
+ return this.playing;
654
+ }
655
+ // --------------------------------------------------------------- markers --
656
+ /**
657
+ * Markers after the cursor are future information: hidden, not clamped.
658
+ * Without this they would all pin to the newest revealed bar, because
659
+ * time→index resolution snaps to the NEAREST bar.
660
+ *
661
+ * Cached on the cursor, so a paused chart allocates nothing per frame.
662
+ */
663
+ markerFilter(markers) {
664
+ const t = this.time;
665
+ if (t == null) return markers;
666
+ const key = this.index + ":" + markers.length;
667
+ if (key !== this._markerKey || !this._markerView) {
668
+ const cut = t + (this.chart.ts.timeframeMs || 0) / 2;
669
+ this._markerView = markers.filter((m) => m.time <= cut);
670
+ this._markerKey = key;
671
+ }
672
+ return this._markerView;
673
+ }
674
+ /** Drop the cached slice — the marker set itself changed. */
675
+ invalidateMarkers() {
676
+ this._markerKey = "";
677
+ this._markerView = null;
678
+ }
679
+ // ---------------------------------------------------------------- private --
680
+ /**
681
+ * Push the revealed prefix into the chart.
682
+ *
683
+ * 'step' (exactly one bar forward) takes the live path so the new candle
684
+ * animates; anything else swaps the prefix and re-anchors without easing.
685
+ */
686
+ _apply(mode) {
687
+ const chart = this.chart;
688
+ if (mode === "step" && chart.bars.length === this.index) {
689
+ chart.append(this.source[this.index]);
690
+ return;
691
+ }
692
+ chart._swapBars(this.source.slice(0, this.index + 1));
693
+ if (this.follow) chart.ts.jumpToRealtime();
694
+ }
695
+ /** Any state change needs a frame: that frame is what emits 'replay'. */
696
+ _changed() {
697
+ this.chart.loop.invalidate("main");
698
+ }
699
+ }
495
700
  function niceStep(span, count) {
496
701
  const raw = span / Math.max(1, count);
497
702
  if (!(raw > 0) || !isFinite(raw)) return 1;
@@ -1059,6 +1264,17 @@ function drawMarkers(ctx, s, placed, hoverId) {
1059
1264
  ctx.restore();
1060
1265
  return hits;
1061
1266
  }
1267
+ const inactiveReplay = () => ({
1268
+ active: false,
1269
+ playing: false,
1270
+ index: -1,
1271
+ length: 0,
1272
+ progress: 0,
1273
+ speed: 1,
1274
+ time: null,
1275
+ bar: null,
1276
+ atEnd: false
1277
+ });
1062
1278
  class Chart {
1063
1279
  constructor(container, options = {}) {
1064
1280
  if (!container) throw new Error("Chart: container element is required");
@@ -1075,11 +1291,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;
@@ -1399,6 +1637,11 @@ class Chart {
1399
1637
  this._rangeKey = this._rangeIdentity(payload);
1400
1638
  fn(payload);
1401
1639
  }
1640
+ if (event === "replay") {
1641
+ const payload = this.replayState();
1642
+ this._replayKey = this._replayIdentity(payload);
1643
+ fn(payload);
1644
+ }
1402
1645
  return () => set.delete(fn);
1403
1646
  }
1404
1647
  // ------------------------------------------------------------------ range --
@@ -1445,11 +1688,72 @@ class Chart {
1445
1688
  this._rangeKey = key;
1446
1689
  for (const fn of set) fn(payload);
1447
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
+ }
1448
1751
  // ----------------------------------------------------------- annotations --
1449
1752
  /** Replace every marker. Each `time` is resolved to its nearest bar. */
1450
1753
  setMarkers(markers) {
1451
1754
  this._markers = normalizeMarkers(markers);
1452
1755
  this._resolveKey = "";
1756
+ if (this._replay) this._replay.invalidateMarkers();
1453
1757
  this.loop.invalidate("main");
1454
1758
  }
1455
1759
  /** The current markers, normalised, each with its resolved bar index. */
@@ -1479,6 +1783,7 @@ class Chart {
1479
1783
  // ----------------------------------------------------------------- frame --
1480
1784
  _frame(dirty, dt) {
1481
1785
  let animating = false;
1786
+ if (this._replay && this._replay.tick(dt)) animating = true;
1482
1787
  if (this.ts.tick(dt)) animating = true;
1483
1788
  const dx = this.inertia.tick(dt);
1484
1789
  if (dx) {
@@ -1509,10 +1814,12 @@ class Chart {
1509
1814
  priceLines: this.priceLines,
1510
1815
  zones: this.zones
1511
1816
  };
1512
- if (this._markers.length) {
1513
- const key = this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
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);
1514
1821
  if (key !== this._resolveKey) {
1515
- resolveMarkers(this._markers, this.bars);
1822
+ resolveMarkers(markers, this.bars);
1516
1823
  this._resolveKey = key;
1517
1824
  }
1518
1825
  }
@@ -1524,7 +1831,7 @@ class Chart {
1524
1831
  this._markerHits = drawMarkers(
1525
1832
  this.layers.ctx.main,
1526
1833
  state,
1527
- layoutMarkers(this._markers, state),
1834
+ layoutMarkers(markers, state),
1528
1835
  this._hoverMarkerId
1529
1836
  );
1530
1837
  }
@@ -1532,6 +1839,7 @@ class Chart {
1532
1839
  drawCrosshair(this.layers.ctx.overlay, state);
1533
1840
  }
1534
1841
  this._emitVisibleRange(from, to);
1842
+ this._emitReplay();
1535
1843
  return animating;
1536
1844
  }
1537
1845
  // ------------------------------------------------------------------- api --
@@ -1578,6 +1886,7 @@ class Chart {
1578
1886
  this.loop.stop();
1579
1887
  this.layers.destroy();
1580
1888
  for (const set of Object.values(this._listeners)) set.clear();
1889
+ this._replay = null;
1581
1890
  this._markers = [];
1582
1891
  this._markerHits = [];
1583
1892
  this.priceLines = [];
@@ -1760,14 +2069,17 @@ class RandomFeed extends DataFeed {
1760
2069
  function createChart(container, options) {
1761
2070
  return new Chart(container, options);
1762
2071
  }
1763
- const version = "0.2.0";
2072
+ const version = "0.4.0";
1764
2073
  export {
1765
2074
  Chart,
1766
2075
  DataFeed,
1767
2076
  Inertia,
1768
2077
  LiveCandle,
2078
+ MAX_SPEED,
2079
+ MIN_SPEED,
1769
2080
  PriceScale,
1770
2081
  RandomFeed,
2082
+ Replay,
1771
2083
  Smoothed,
1772
2084
  TimeScale,
1773
2085
  Tween,