@luxalgo/vela 0.7.8 → 0.8.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.
Files changed (46) hide show
  1. package/NOTICE +28 -28
  2. package/dist/{DataProvider-CGa4KHRa.d.cts → DataProvider-BrlxntPN.d.cts} +1 -1
  3. package/dist/{DataProvider-CmWUtDVL.d.ts → DataProvider-DwFIoV4r.d.ts} +1 -1
  4. package/dist/{chunk-WTWGEB7K.js → chunk-2IAETM2I.js} +260 -2
  5. package/dist/{chunk-CAFCLMPF.js → chunk-BZQM2XO7.js} +1 -0
  6. package/dist/{chunk-M6GP7DAX.js → chunk-E3GU6AUI.js} +459 -37
  7. package/dist/{chunk-BFA32GOU.js → chunk-EZ5FWVLA.js} +5 -2
  8. package/dist/{chunk-PVGWNF7V.js → chunk-KA77UTRF.js} +2 -2
  9. package/dist/{chunk-N4ZZYLMP.js → chunk-QBPUT5SU.js} +686 -251
  10. package/dist/{contributions-DF_Ea07m.d.cts → contributions-CtlptxR8.d.cts} +187 -5
  11. package/dist/{contributions-nKOSeLy8.d.ts → contributions-tjNExo1o.d.ts} +187 -5
  12. package/dist/index.cjs +966 -247
  13. package/dist/index.d.cts +24 -8
  14. package/dist/index.d.ts +24 -8
  15. package/dist/index.js +28 -4
  16. package/dist/{options-D1AJKsn_.d.ts → options-BnEPUuVV.d.cts} +1 -1
  17. package/dist/{options-D1AJKsn_.d.cts → options-BnEPUuVV.d.ts} +1 -1
  18. package/dist/{plugin-6F04gjQe.d.cts → plugin-BWxJRKzS.d.cts} +10 -4
  19. package/dist/{plugin-BLsNipkc.d.ts → plugin-e2WUEtiI.d.ts} +10 -4
  20. package/dist/plugin.cjs +1 -0
  21. package/dist/plugin.d.cts +4 -4
  22. package/dist/plugin.d.ts +4 -4
  23. package/dist/plugin.js +2 -2
  24. package/dist/providers/binance.d.cts +2 -2
  25. package/dist/providers/binance.d.ts +2 -2
  26. package/dist/providers/coinbase.d.cts +2 -2
  27. package/dist/providers/coinbase.d.ts +2 -2
  28. package/dist/providers/hyperliquid.d.cts +2 -2
  29. package/dist/providers/hyperliquid.d.ts +2 -2
  30. package/dist/{statusline-model-DAFyqVmR.d.ts → statusline-model-DFY7tuHj.d.cts} +5 -3
  31. package/dist/{statusline-model-BoWJ3cWQ.d.cts → statusline-model-zKXtGIjj.d.ts} +5 -3
  32. package/dist/ui.cjs +263 -0
  33. package/dist/ui.d.cts +75 -2
  34. package/dist/ui.d.ts +75 -2
  35. package/dist/ui.js +3 -3
  36. package/dist/vela.global.js +968 -249
  37. package/dist/vela.global.min.js +73 -71
  38. package/dist/widget.cjs +1411 -292
  39. package/dist/widget.d.cts +26 -7
  40. package/dist/widget.d.ts +26 -7
  41. package/dist/widget.js +7 -7
  42. package/dist/workspace.cjs +1414 -292
  43. package/dist/workspace.d.cts +124 -6
  44. package/dist/workspace.d.ts +124 -6
  45. package/dist/workspace.js +6 -6
  46. package/package.json +1 -1
package/dist/widget.cjs CHANGED
@@ -1230,6 +1230,7 @@ registerIcon("chevron-down", S('<path d="M3.5 6 8 10.5 12.5 6"/>'));
1230
1230
  registerIcon("chevron-up", S('<path d="M3.5 10 8 5.5 12.5 10"/>'));
1231
1231
  registerIcon("chevrons-right", S('<path d="m4 3.5 4.5 4.5L4 12.5"/><path d="m8.5 3.5 4.5 4.5-4.5 4.5"/>'));
1232
1232
  registerIcon("chevrons-left", S('<path d="M12 3.5 7.5 8l4.5 4.5"/><path d="M7.5 3.5 3 8l4.5 4.5"/>'));
1233
+ registerIcon("replay", S('<path d="M14 4.2v7.6L8.6 8z"/><path d="M7.4 4.2v7.6L2 8z"/>'));
1233
1234
  registerIcon("check", S('<path d="m2.8 8.4 3.4 3.4 7-7.6"/>'));
1234
1235
  registerIcon("close", S('<path d="m3.8 3.8 8.4 8.4M12.2 3.8l-8.4 8.4"/>'));
1235
1236
  registerIcon("pin", S('<path d="M5.6 2.5h4.8M6.4 2.5v3.6L4.4 8.4v.9h7.2v-.9L9.6 6.1V2.5M8 9.3v4.2"/>'));
@@ -4550,6 +4551,264 @@ var CalloutBubble = class {
4550
4551
  }
4551
4552
  };
4552
4553
 
4554
+ // src/ui/components/date-picker/controller.ts
4555
+ var MONTH_LABELS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
4556
+ var MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4557
+ var WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
4558
+ function isoDate(d) {
4559
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
4560
+ }
4561
+ function normalizeDateInput(raw) {
4562
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw.trim());
4563
+ if (!m) return null;
4564
+ const y = Number(m[1]);
4565
+ const mo = Number(m[2]);
4566
+ const d = Number(m[3]);
4567
+ const dt = new Date(y, mo - 1, d);
4568
+ if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
4569
+ return isoDate(dt);
4570
+ }
4571
+ function parseIsoDate(raw) {
4572
+ const iso = raw ? normalizeDateInput(raw) : null;
4573
+ if (!iso) return null;
4574
+ const [y, mo, d] = iso.split("-").map(Number);
4575
+ return new Date(y, mo - 1, d);
4576
+ }
4577
+ function datePickerController(opts = {}) {
4578
+ const today = opts.today ?? /* @__PURE__ */ new Date();
4579
+ let selected = parseIsoDate(opts.value);
4580
+ let year = selected?.getFullYear() ?? today.getFullYear();
4581
+ let month = selected?.getMonth() ?? today.getMonth();
4582
+ let panel = "date";
4583
+ const decade = () => Math.floor(year / 10) * 10;
4584
+ return {
4585
+ get panel() {
4586
+ return panel;
4587
+ },
4588
+ get year() {
4589
+ return year;
4590
+ },
4591
+ get month() {
4592
+ return month;
4593
+ },
4594
+ get value() {
4595
+ return selected ? isoDate(selected) : null;
4596
+ },
4597
+ header() {
4598
+ return {
4599
+ month: MONTH_LABELS[month] ?? "",
4600
+ monthVisible: panel === "date",
4601
+ year: panel === "year" ? `${decade()}-${decade() + 9}` : String(year),
4602
+ yearDisabled: panel === "year"
4603
+ // in the decade panel the year reads as its title
4604
+ };
4605
+ },
4606
+ navLabels() {
4607
+ if (panel === "date") return { prev: "Previous month", next: "Next month" };
4608
+ if (panel === "month") return { prev: "Previous year", next: "Next year" };
4609
+ return { prev: "Previous decade", next: "Next decade" };
4610
+ },
4611
+ cells() {
4612
+ const out = [];
4613
+ if (panel === "date") {
4614
+ const startPad = new Date(year, month, 1).getDay();
4615
+ const days = new Date(year, month + 1, 0).getDate();
4616
+ const todayIso = isoDate(today);
4617
+ const selectedIso = selected ? isoDate(selected) : "";
4618
+ for (let i = 0; i < startPad; i++) out.push({ label: "", value: "", checked: false, today: false, blank: true });
4619
+ for (let day = 1; day <= days; day++) {
4620
+ const iso = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
4621
+ out.push({ label: String(day), value: iso, checked: iso === selectedIso, today: iso === todayIso });
4622
+ }
4623
+ return out;
4624
+ }
4625
+ if (panel === "month") {
4626
+ for (let m = 0; m < 12; m++) {
4627
+ out.push({
4628
+ label: MONTH_SHORT[m] ?? "",
4629
+ value: m,
4630
+ checked: selected?.getFullYear() === year && selected.getMonth() === m,
4631
+ today: today.getFullYear() === year && today.getMonth() === m
4632
+ });
4633
+ }
4634
+ return out;
4635
+ }
4636
+ const d = decade();
4637
+ for (let y = d - 1; y <= d + 10; y++) {
4638
+ out.push({ label: String(y), value: y, checked: selected?.getFullYear() === y, today: today.getFullYear() === y, outside: y < d || y > d + 9 });
4639
+ }
4640
+ return out;
4641
+ },
4642
+ step(dir) {
4643
+ if (panel === "date") {
4644
+ month += dir;
4645
+ if (month < 0) {
4646
+ month = 11;
4647
+ year -= 1;
4648
+ }
4649
+ if (month > 11) {
4650
+ month = 0;
4651
+ year += 1;
4652
+ }
4653
+ } else {
4654
+ year += panel === "month" ? dir : dir * 10;
4655
+ }
4656
+ },
4657
+ showMonths() {
4658
+ panel = "month";
4659
+ },
4660
+ showYears() {
4661
+ panel = "year";
4662
+ },
4663
+ choose(cell) {
4664
+ if (cell.blank) return null;
4665
+ if (panel === "date") {
4666
+ selected = parseIsoDate(String(cell.value));
4667
+ return selected ? isoDate(selected) : null;
4668
+ }
4669
+ if (panel === "month") {
4670
+ month = Number(cell.value);
4671
+ panel = "date";
4672
+ return null;
4673
+ }
4674
+ year = Number(cell.value);
4675
+ panel = "month";
4676
+ return null;
4677
+ },
4678
+ setValue(value) {
4679
+ selected = parseIsoDate(value);
4680
+ if (selected) {
4681
+ year = selected.getFullYear();
4682
+ month = selected.getMonth();
4683
+ }
4684
+ panel = "date";
4685
+ }
4686
+ };
4687
+ }
4688
+
4689
+ // src/ui/components/date-picker/styles.ts
4690
+ var DATE_PICKER_STYLE_ID = "vela-date-picker-styles";
4691
+ var DATE_PICKER_CSS = `
4692
+ .vela-date-picker{color:var(--vela-fg);font:14px var(--vela-font);user-select:none;}
4693
+ .vela-date-picker [hidden]{display:none !important;}
4694
+ .vela-date-picker-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
4695
+ .vela-date-picker-title{flex:1;display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;}
4696
+ .vela-date-picker-switch{border:none;background:transparent;color:var(--vela-fg-bright);font:inherit;font-weight:600;font-size:14px;padding:2px 6px;border-radius:4px;cursor:pointer;}
4697
+ .vela-date-picker-switch:not(:disabled):hover{background:var(--vela-hover);}
4698
+ .vela-date-picker-switch:disabled{cursor:default;}
4699
+ .vela-date-picker-nav{width:24px;height:24px;border:none;background:transparent;color:var(--vela-fg-muted);border-radius:4px;padding:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:14px;}
4700
+ .vela-date-picker-nav:hover{background:var(--vela-hover);color:var(--vela-fg-bright);}
4701
+ .vela-date-picker-week,.vela-date-picker-grid{display:grid;grid-template-columns:repeat(7,28px);gap:2px;}
4702
+ .vela-date-picker-week{margin-bottom:4px;color:var(--vela-fg-muted);font-size:11px;text-align:center;}
4703
+ .vela-date-picker-week span{line-height:20px;}
4704
+ .vela-date-picker-blank{width:28px;height:28px;}
4705
+ .vela-date-picker-day{width:28px;height:28px;border:none;background:transparent;color:inherit;border-radius:4px;padding:0;cursor:pointer;font:inherit;font-size:14px;}
4706
+ .vela-date-picker-day:hover{background:var(--vela-hover);}
4707
+ .vela-date-picker-day[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
4708
+ .vela-date-picker-day[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
4709
+ .vela-date-picker-cells{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;width:calc(7 * 28px + 6 * 2px);}
4710
+ .vela-date-picker-cell{height:36px;border:none;background:transparent;color:inherit;border-radius:4px;padding:0 4px;cursor:pointer;font:inherit;font-size:14px;}
4711
+ .vela-date-picker-cell:hover{background:var(--vela-hover);}
4712
+ .vela-date-picker-cell[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
4713
+ .vela-date-picker-cell[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
4714
+ .vela-date-picker-cell[data-outside]{opacity:0.45;}
4715
+ `;
4716
+
4717
+ // src/ui/components/date-picker/view.ts
4718
+ var DatePicker = class {
4719
+ constructor(opts = {}) {
4720
+ this.opts = opts;
4721
+ const doc = document;
4722
+ injectStyles(DATE_PICKER_STYLE_ID, DATE_PICKER_CSS, doc);
4723
+ this.ctrl = datePickerController(opts);
4724
+ const button = (className) => {
4725
+ const b = doc.createElement("button");
4726
+ b.type = "button";
4727
+ b.className = className;
4728
+ return b;
4729
+ };
4730
+ this.prev = button("vela-date-picker-nav");
4731
+ this.prev.append(iconEl("chevron-left", doc));
4732
+ this.next = button("vela-date-picker-nav");
4733
+ this.next.append(iconEl("chevron-right", doc));
4734
+ this.monthBtn = button("vela-date-picker-switch");
4735
+ this.monthBtn.setAttribute("aria-label", "Choose month");
4736
+ this.yearBtn = button("vela-date-picker-switch");
4737
+ this.yearBtn.setAttribute("aria-label", "Choose year");
4738
+ const title = doc.createElement("div");
4739
+ title.className = "vela-date-picker-title";
4740
+ title.append(this.monthBtn, this.yearBtn);
4741
+ const head = doc.createElement("div");
4742
+ head.className = "vela-date-picker-head";
4743
+ head.append(this.prev, title, this.next);
4744
+ this.week = doc.createElement("div");
4745
+ this.week.className = "vela-date-picker-week";
4746
+ for (const d of WEEKDAY_LABELS) {
4747
+ const cell = doc.createElement("span");
4748
+ cell.textContent = d;
4749
+ this.week.appendChild(cell);
4750
+ }
4751
+ this.grid = doc.createElement("div");
4752
+ const on = (el, run) => el.addEventListener("click", (e) => {
4753
+ e.stopPropagation();
4754
+ run();
4755
+ this.paint();
4756
+ });
4757
+ on(this.prev, () => this.ctrl.step(-1));
4758
+ on(this.next, () => this.ctrl.step(1));
4759
+ on(this.monthBtn, () => this.ctrl.showMonths());
4760
+ on(this.yearBtn, () => this.ctrl.showYears());
4761
+ this.el = doc.createElement("div");
4762
+ this.el.className = "vela-date-picker";
4763
+ this.el.append(head, this.week, this.grid);
4764
+ this.paint();
4765
+ }
4766
+ get value() {
4767
+ return this.ctrl.value;
4768
+ }
4769
+ /** Rewrite the selection without emitting (the calendar opens on its month). */
4770
+ setValue(value) {
4771
+ this.ctrl.setValue(value);
4772
+ this.paint();
4773
+ }
4774
+ paint() {
4775
+ const doc = this.el.ownerDocument;
4776
+ const h = this.ctrl.header();
4777
+ this.monthBtn.hidden = !h.monthVisible;
4778
+ this.monthBtn.textContent = h.month;
4779
+ this.yearBtn.textContent = h.year;
4780
+ this.yearBtn.disabled = h.yearDisabled;
4781
+ const nav = this.ctrl.navLabels();
4782
+ this.prev.setAttribute("aria-label", nav.prev);
4783
+ this.next.setAttribute("aria-label", nav.next);
4784
+ const dates = this.ctrl.panel === "date";
4785
+ this.week.hidden = !dates;
4786
+ this.grid.className = dates ? "vela-date-picker-grid" : "vela-date-picker-cells";
4787
+ this.grid.replaceChildren(...this.ctrl.cells().map((cell) => this.cellEl(doc, cell, dates)));
4788
+ }
4789
+ cellEl(doc, cell, dates) {
4790
+ if (cell.blank) {
4791
+ const blank = doc.createElement("span");
4792
+ blank.className = "vela-date-picker-blank";
4793
+ return blank;
4794
+ }
4795
+ const b = doc.createElement("button");
4796
+ b.type = "button";
4797
+ b.className = dates ? "vela-date-picker-day" : "vela-date-picker-cell";
4798
+ b.textContent = cell.label;
4799
+ if (cell.checked) b.dataset.checked = "1";
4800
+ if (cell.today) b.dataset.today = "1";
4801
+ if (cell.outside) b.dataset.outside = "1";
4802
+ b.addEventListener("click", (e) => {
4803
+ e.stopPropagation();
4804
+ const picked = this.ctrl.choose(cell);
4805
+ if (picked) this.opts.onPick?.(picked);
4806
+ else this.paint();
4807
+ });
4808
+ return b;
4809
+ }
4810
+ };
4811
+
4553
4812
  // src/widget/layout-picker.ts
4554
4813
  var STYLE_ID = "vela-widget-layout-picker-v14";
4555
4814
  var CSS2 = `
@@ -4967,6 +5226,9 @@ function pinnedTopbarActionIds(comp) {
4967
5226
  }
4968
5227
 
4969
5228
  // src/widget/contributions.ts
5229
+ function mobilePlacement(action) {
5230
+ return action.mobile ?? (action.align === "left" ? "bar" : "menu");
5231
+ }
4970
5232
  var registry3 = /* @__PURE__ */ new Map();
4971
5233
  var attachments = /* @__PURE__ */ new Map();
4972
5234
  var panels = /* @__PURE__ */ new Map();
@@ -5204,6 +5466,22 @@ function priceStyleIds() {
5204
5466
  for (const t of chartTypes()) if (!out.includes(t.id)) out.push(t.id);
5205
5467
  return out;
5206
5468
  }
5469
+ function sanitizeCrosshairOverride(value) {
5470
+ if (!value || typeof value !== "object") return null;
5471
+ const v = value;
5472
+ const out = {};
5473
+ if (isBool(v.vertical)) out.vertical = v.vertical;
5474
+ if (isBool(v.horizontal)) out.horizontal = v.horizontal;
5475
+ if (isColor(v.color)) out.color = v.color;
5476
+ if (isNum(v.width)) out.width = Math.max(0.5, Math.min(8, v.width));
5477
+ if (isLineStyle(v.style)) out.style = v.style;
5478
+ if (isNum(v.opacity)) out.opacity = clampOpacity(v.opacity);
5479
+ const shade = v.shadeRight;
5480
+ if (shade && typeof shade === "object" && isColor(shade.color)) {
5481
+ out.shadeRight = isNum(shade.opacity) ? { color: shade.color, opacity: clampOpacity(shade.opacity) } : { color: shade.color };
5482
+ }
5483
+ return Object.keys(out).length > 0 ? out : null;
5484
+ }
5207
5485
  function isColor(v) {
5208
5486
  return typeof v === "string" && v.trim().length > 0;
5209
5487
  }
@@ -6091,8 +6369,8 @@ var Topbar = class {
6091
6369
  // src/core/util/wall-clock.ts
6092
6370
  var BOUNDARY_SLACK_MS = 5;
6093
6371
  var SecondClock = class _SecondClock {
6094
- constructor(now = () => Date.now()) {
6095
- this.now = now;
6372
+ constructor(now2 = () => Date.now()) {
6373
+ this.now = now2;
6096
6374
  this.subs = /* @__PURE__ */ new Set();
6097
6375
  this.timer = null;
6098
6376
  }
@@ -6108,15 +6386,15 @@ var SecondClock = class _SecondClock {
6108
6386
  };
6109
6387
  }
6110
6388
  /** Milliseconds from `now` to just past the next second boundary. */
6111
- static delayToNextSecond(now) {
6112
- const intoSecond = (now % 1e3 + 1e3) % 1e3;
6389
+ static delayToNextSecond(now2) {
6390
+ const intoSecond = (now2 % 1e3 + 1e3) % 1e3;
6113
6391
  return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
6114
6392
  }
6115
6393
  arm() {
6116
6394
  this.timer = setTimeout(() => {
6117
6395
  this.timer = null;
6118
- const now = this.now();
6119
- for (const cb of this.subs) cb(now);
6396
+ const now2 = this.now();
6397
+ for (const cb of this.subs) cb(now2);
6120
6398
  if (this.subs.size > 0) this.arm();
6121
6399
  }, _SecondClock.delayToNextSecond(this.now()));
6122
6400
  }
@@ -15370,16 +15648,16 @@ var MobileBar = class {
15370
15648
  host.appendChild(this.el);
15371
15649
  this.renderActions();
15372
15650
  }
15373
- /** Re-project the left-aligned contributed actions as icon-only stops in the
15374
- * indicators slot (call after registrations change). Right-aligned actions stay
15375
- * in the three-dots drawer — a primary stop is what `align: 'left'` opts into. */
15651
+ /** Re-project the contributed actions placed on the bar (left-aligned by default —
15652
+ * see `mobilePlacement`) as icon-only stops in the indicators slot (call after
15653
+ * registrations change). The rest are three-dots drawer rows. */
15376
15654
  renderActions() {
15377
15655
  const ctx = this.opts.getContext?.();
15378
15656
  if (!ctx) return;
15379
15657
  const doc = this.el.ownerDocument;
15380
15658
  this.actionsHost.replaceChildren();
15381
15659
  const builtin = new Set(TOPBAR_BUILTIN_IDS);
15382
- for (const action of widgetActions("topbar", ctx).filter((a) => a.align === "left" && !builtin.has(a.id))) {
15660
+ for (const action of widgetActions("topbar", ctx).filter((a) => mobilePlacement(a) === "bar" && !builtin.has(a.id))) {
15383
15661
  const b = doc.createElement("button");
15384
15662
  b.className = "vela-mb-item";
15385
15663
  b.setAttribute("aria-label", action.label);
@@ -15935,6 +16213,7 @@ var MoreDrawer = class {
15935
16213
  const value = shape ? `${shape.cols} \xD7 ${shape.rows}` : this.opts.layout.presets().find((p) => p.checked)?.label;
15936
16214
  list.appendChild(this.row(doc, "Layout", { icon: "layout", value, chevron: true, onClick: () => this.show("layout") }));
15937
16215
  }
16216
+ for (const act of this.opts.primaryActions?.() ?? []) list.appendChild(this.actionRow(doc, act));
15938
16217
  for (const panel of this.opts.panels()) {
15939
16218
  list.appendChild(
15940
16219
  this.row(doc, panel.title, {
@@ -15950,19 +16229,19 @@ var MoreDrawer = class {
15950
16229
  const alertCount = this.opts.alerts().length;
15951
16230
  list.appendChild(this.row(doc, "Alerts", { icon: "bell", value: alertCount > 0 ? String(alertCount) : void 0, chevron: true, onClick: () => this.show("alerts") }));
15952
16231
  }
15953
- for (const act of this.opts.actions()) {
15954
- list.appendChild(
15955
- this.row(doc, act.label, {
15956
- icon: act.icon,
15957
- onClick: () => {
15958
- act.run();
15959
- this.drawer.hide();
15960
- }
15961
- })
15962
- );
15963
- }
16232
+ for (const act of this.opts.actions()) list.appendChild(this.actionRow(doc, act));
15964
16233
  this.drawer.body.appendChild(list);
15965
16234
  }
16235
+ /** A contributed action's row: runs it and closes the drawer. */
16236
+ actionRow(doc, act) {
16237
+ return this.row(doc, act.label, {
16238
+ icon: act.icon,
16239
+ onClick: () => {
16240
+ act.run();
16241
+ this.drawer.hide();
16242
+ }
16243
+ });
16244
+ }
15966
16245
  renderStyle(doc) {
15967
16246
  const list = doc.createElement("div");
15968
16247
  list.className = "vela-md-list";
@@ -18336,6 +18615,7 @@ function sanitizeCell(raw) {
18336
18615
  if (typeof c.bars === "number" && Number.isFinite(c.bars) && c.bars > 0) out.bars = c.bars;
18337
18616
  if (c.session === "regular" || c.session === "extended") out.session = c.session;
18338
18617
  if (typeof c.watermark === "boolean") out.watermark = c.watermark;
18618
+ if (typeof c.replayWatermark === "boolean") out.replayWatermark = c.replayWatermark;
18339
18619
  if (typeof c.indicatorTitles === "boolean") out.indicatorTitles = c.indicatorTitles;
18340
18620
  if (typeof c.indicatorValues === "boolean") out.indicatorValues = c.indicatorValues;
18341
18621
  if (c.rendererConfig != null && typeof c.rendererConfig === "object") out.rendererConfig = c.rendererConfig;
@@ -19074,6 +19354,7 @@ var CHUNK_BARS = 1e4;
19074
19354
  var FIRST_PAINT_BARS = 20;
19075
19355
  var GAP_FACTOR = 1.5;
19076
19356
  var HEAL_COOLDOWN_MS = 5e3;
19357
+ var MIN_TICK_UPDATE_MS = 16;
19077
19358
  var EngineOrchestrator = class _EngineOrchestrator {
19078
19359
  constructor(container, renderer, feed, engines, config, dataControl) {
19079
19360
  this.renderer = renderer;
@@ -19126,6 +19407,32 @@ var EngineOrchestrator = class _EngineOrchestrator {
19126
19407
  this.lastHealAt = 0;
19127
19408
  /** The `visibilitychange` catch-up listener (removed on destroy). */
19128
19409
  this.onVisible = null;
19410
+ // ── bar replay: `rawBars` holds the history up to the cursor, the rest waits here ──
19411
+ /** Bars still hidden right of the replay cursor, oldest first; null ≡ replay off. */
19412
+ this.replayQueue = null;
19413
+ this.replayTimer = null;
19414
+ this.replayPlaying = false;
19415
+ this.replayIntervalMs = 1e3;
19416
+ /** The depth (`market.bars`) before a replay deepened the history — restored when it ends. */
19417
+ this.replayDepth = null;
19418
+ /** A start is loading older history before it can cut; `seek` = a replay was running
19419
+ * (and left silently to deepen), so abandoning the load must announce its end. */
19420
+ this.replayLoading = null;
19421
+ /** A replay carried over a timeframe/session switch, between the old series' silent end
19422
+ * and the cut of the new one: still ACTIVE for the outside (`replayState`), resumed
19423
+ * at `revealedEnd` once the new bars land, unless stopped (`cancelled`) meanwhile. */
19424
+ this.replayCarrying = null;
19425
+ /** Intrabar prices for the bars replay reveals — null ≡ whole bars. */
19426
+ this.replayTickSource = null;
19427
+ /** The bar being revealed tick by tick: its stored form (what it settles on), its ticks,
19428
+ * the next tick to apply, the candle built so far, the volume each tick adds when the
19429
+ * ticks carry none, and its pacing (ticks per update, ms between updates — one play
19430
+ * interval per tick — and the clock time tick 0 is due at, so late timers catch up). */
19431
+ this.replayForming = null;
19432
+ /** Tick requests by bar time, shared by prefetch and playback; `ticks` once they landed. */
19433
+ this.replayTickFetches = /* @__PURE__ */ new Map();
19434
+ /** Aborts the tick requests in flight — renewed whenever the replay moves on. */
19435
+ this.replayTickAbort = null;
19129
19436
  /** What the last applied bar did: refine the open candle, or open a new one (which
19130
19437
  * settles the previous). Read by the `script:run` cause attribution. */
19131
19438
  this.barCause = "tick";
@@ -19349,6 +19656,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
19349
19656
  const paint = (bars, final) => {
19350
19657
  if (this.generation !== gen || !final && bars.length === 0) return;
19351
19658
  if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
19659
+ if (painted && this.replayQueue) {
19660
+ this.mergeHistoryIntoReplay(bars);
19661
+ return;
19662
+ }
19352
19663
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
19353
19664
  if (!painted && bars.length > 0) {
19354
19665
  painted = true;
@@ -19491,6 +19802,36 @@ var EngineOrchestrator = class _EngineOrchestrator {
19491
19802
  // exactly like a timeframe change; the cache keys the sessions apart.
19492
19803
  next.session !== void 0 && next.session !== m.session || next.data !== void 0;
19493
19804
  const depthChanged = next.bars !== void 0 && next.bars !== m.bars;
19805
+ const sameClock = identityChanged && next.data === void 0 && (next.symbol === void 0 || next.symbol === m.symbol) && (next.timeframe !== void 0 || next.session !== void 0);
19806
+ const pending = identityChanged ? this.replayCarrying : null;
19807
+ if (pending) {
19808
+ this.replayCarrying = null;
19809
+ if (!sameClock) {
19810
+ pending.cancelled = true;
19811
+ this.events.emit("replay:end", { reason: "market" });
19812
+ }
19813
+ }
19814
+ const replayCarry = this.replayQueue && sameClock && this.rawBars.length > 0 ? { revealedEnd: this.rawBars[this.rawBars.length - 1].time + this.barIntervalMs(), playing: this.replayPlaying, cancelled: false } : pending && sameClock ? { revealedEnd: pending.revealedEnd, playing: pending.playing, cancelled: false } : null;
19815
+ if (pending) pending.cancelled = true;
19816
+ if (replayCarry) this.replayCarrying = replayCarry;
19817
+ if (replayCarry && this.replayQueue) {
19818
+ this.endReplay(null, { restore: false, resume: false });
19819
+ const depth = this.replayDepth;
19820
+ this.replayDepth = null;
19821
+ if (depth && next.bars === void 0) {
19822
+ if (depth.bars === void 0) delete m.bars;
19823
+ else m.bars = depth.bars;
19824
+ }
19825
+ }
19826
+ if (this.replayQueue && (identityChanged || depthChanged)) this.endReplay("market", { restore: !identityChanged, resume: false });
19827
+ if (this.replayLoading && (identityChanged || depthChanged)) {
19828
+ const depth = this.replayDepth;
19829
+ this.abandonReplayLoad("market");
19830
+ if (depth && next.bars === void 0) {
19831
+ if (depth.bars === void 0) delete m.bars;
19832
+ else m.bars = depth.bars;
19833
+ }
19834
+ }
19494
19835
  const depthOnly = depthChanged && !identityChanged && this.rawBars.length > 0 && !m.data?.length && ((next.bars ?? 0) <= this.rawBars.length || typeof this.feed.loadRange === "function");
19495
19836
  if (!identityChanged && !depthChanged) {
19496
19837
  if (typeof next.visibleRange === "string") this.setVisibleRangePreset(next.visibleRange);
@@ -19548,15 +19889,20 @@ var EngineOrchestrator = class _EngineOrchestrator {
19548
19889
  const from = bars[Math.max(0, idx - (carriedCount - 1))].time;
19549
19890
  this.renderer.setVisibleRange({ from, to: Math.max(carried.right, bars[idx].time + 1) });
19550
19891
  }
19551
- if (!depthOnly) {
19892
+ const carry = replayCarry && !replayCarry.cancelled ? replayCarry : null;
19893
+ const carryFrom = carry ? carry.revealedEnd - this.barIntervalMs() : 0;
19894
+ const carryNow = carry != null && this.rawBars.length > 0 && carryFrom >= this.rawBars[0].time;
19895
+ const replayBack = carryNow && this.resumeCarriedReplay(this.replayStart({ from: carryFrom }), carry);
19896
+ if (!depthOnly && !replayBack) {
19552
19897
  this.restartNativeIndicators();
19553
19898
  this.restartChartTypeEngine();
19554
19899
  this.reexecuteIndicators();
19555
19900
  }
19556
- this.startLive();
19901
+ if (!replayBack) this.startLive();
19557
19902
  if (identityChanged) {
19558
19903
  this.events.emit("market:changed", { symbol: m.symbol ?? "TEST", timeframe: m.timeframe ?? "60", prev: prev2 });
19559
19904
  }
19905
+ if (carry && !carryNow) this.resumeCarriedReplay(this.replayStart({ from: carryFrom }), carry);
19560
19906
  }
19561
19907
  /**
19562
19908
  * Restart every native indicator over the new market: a `NativeIndicatorContext`
@@ -19680,6 +20026,397 @@ var EngineOrchestrator = class _EngineOrchestrator {
19680
20026
  historyComplete() {
19681
20027
  return this.historyCompletePromise;
19682
20028
  }
20029
+ // ── bar replay ──────────────────────────────────────────────
20030
+ /**
20031
+ * Enter replay — or seek, when already replaying — keeping every bar opened at or before
20032
+ * `opts.from` and queueing the rest. A `from` inside the loaded bars cuts at once, even
20033
+ * while older history is still streaming in: what lands later joins the tape's head
20034
+ * (see {@link mergeHistoryIntoReplay}) and leaves the cut alone. An older `from` waits
20035
+ * for that load, then deepens if it still has to. Consumers restart over the cut exactly
20036
+ * as on a market switch — their models describe bars that are now hidden — and the
20037
+ * active chart-type engine is rebuilt without live data.
20038
+ */
20039
+ async replayStart(opts) {
20040
+ if (this.rawBars.length === 0) {
20041
+ console.warn("[vela] chart.replay.start() ignored \u2014 the chart has no bars yet");
20042
+ return;
20043
+ }
20044
+ let gen = this.generation;
20045
+ if (opts.from < this.rawBars[0].time) {
20046
+ await this.historyCompletePromise;
20047
+ if (this.generation !== gen || this.rawBars.length === 0) return;
20048
+ }
20049
+ const oldest = this.rawBars[0].time;
20050
+ if (opts.from < oldest && this.canHeal() && typeof this.feed.loadRange === "function") {
20051
+ const held = this.rawBars.length + (this.replayQueue?.length ?? 0);
20052
+ const bars = held + Math.ceil((oldest - opts.from) / this.barIntervalMs()) + 1;
20053
+ const seek = this.replayQueue !== null;
20054
+ if (seek) this.endReplay(null, { restore: true, resume: false });
20055
+ this.replayDepth ?? (this.replayDepth = { bars: this.config.market.bars });
20056
+ const deepened = this.setMarket({ bars });
20057
+ this.replayLoading = { seek };
20058
+ await deepened;
20059
+ gen = this.generation;
20060
+ await this.historyCompletePromise;
20061
+ if (!this.replayLoading || this.generation !== gen || this.rawBars.length === 0) return;
20062
+ this.replayLoading = null;
20063
+ }
20064
+ this.settleFormingBar();
20065
+ this.dropTickFetches();
20066
+ const tape = this.replayQueue ? [...this.rawBars, ...this.replayQueue] : this.rawBars;
20067
+ let cut = 0;
20068
+ while (cut < tape.length && tape[cut].time <= opts.from) cut += 1;
20069
+ cut = Math.max(1, cut);
20070
+ if (cut >= tape.length) {
20071
+ console.warn("[vela] chart.replay.start() ignored \u2014 no bar opens after the requested time");
20072
+ return;
20073
+ }
20074
+ const wasPlaying = this.replayPlaying;
20075
+ this.clearReplayTimer();
20076
+ if (!this.replayQueue) this.stopLive();
20077
+ this.replayQueue = tape.slice(cut);
20078
+ this.setBarSeries(tape.slice(0, cut));
20079
+ this.restartBarConsumers({ blank: true });
20080
+ if (wasPlaying) this.events.emit("replay:pause", void 0);
20081
+ this.events.emit("replay:start", { cursorTime: tape[cut - 1].time, remaining: this.replayQueue.length });
20082
+ this.prefetchTicks();
20083
+ }
20084
+ /** Reveal the next queued bar through the live-bar path — or complete the bar forming
20085
+ * tick by tick; the last one ends the replay. */
20086
+ replayStep() {
20087
+ if (this.replayForming) {
20088
+ this.finishFormingBar();
20089
+ return true;
20090
+ }
20091
+ const queue = this.replayQueue;
20092
+ const bar = queue?.shift();
20093
+ if (!queue || !bar) return false;
20094
+ this.replayTickFetches.delete(bar.time);
20095
+ this.applyBar(bar);
20096
+ this.completeReveal(bar);
20097
+ this.prefetchTicks();
20098
+ return true;
20099
+ }
20100
+ /**
20101
+ * Follow up a replay carried onto a new series of the same symbol (another timeframe or
20102
+ * session). The cut asked for keeps the bars that CLOSED by the end of what was revealed —
20103
+ * every finer bar of the last revealed one, never a coarser bar still open at that point
20104
+ * (no look-ahead). Once it settles: playback resumes if it was on; if the replay did not
20105
+ * come back (nothing left after that point, or a later switch took over), it ends as a
20106
+ * market switch would. Returns whether the replay is back already — a start with no
20107
+ * history to wait for cuts synchronously (an async function runs up to its first await).
20108
+ */
20109
+ resumeCarriedReplay(start, carry) {
20110
+ if (this.replayQueue && this.replayCarrying === carry) this.replayCarrying = null;
20111
+ void start.then(() => {
20112
+ if (this.replayCarrying === carry) this.replayCarrying = null;
20113
+ if (carry.cancelled) return;
20114
+ if (!this.replayQueue) this.events.emit("replay:end", { reason: "market" });
20115
+ else if (carry.playing) this.replayPlay();
20116
+ });
20117
+ return this.replayQueue !== null;
20118
+ }
20119
+ replayStepUpdate() {
20120
+ if (!this.replayQueue) return false;
20121
+ if (this.replayForming) {
20122
+ this.applyReplayTicks(1);
20123
+ return true;
20124
+ }
20125
+ const bar = this.replayQueue[0];
20126
+ if (!bar) return false;
20127
+ const entry = this.replayTickSource ? this.requestTicks(bar) : null;
20128
+ if (entry && !entry.ticks) {
20129
+ const epoch = this.replayTickAbort;
20130
+ void entry.done.then(() => {
20131
+ if (!this.replayPlaying && !this.replayForming && this.replayTickAbort === epoch && this.replayQueue?.[0] === bar) this.replayStepUpdate();
20132
+ });
20133
+ return true;
20134
+ }
20135
+ if (!entry?.ticks?.length) return this.replayStep();
20136
+ this.openFormingBar(entry.ticks, 1);
20137
+ return true;
20138
+ }
20139
+ replaySetTicks(source) {
20140
+ this.finishFormingBar();
20141
+ this.dropTickFetches();
20142
+ this.replayTickSource = source;
20143
+ this.prefetchTicks();
20144
+ }
20145
+ replayPlay(intervalMs2) {
20146
+ if (!this.replayQueue) return;
20147
+ if (intervalMs2 !== void 0) {
20148
+ if (!(Number.isFinite(intervalMs2) && intervalMs2 > 0)) {
20149
+ console.warn(`[vela] chart.replay.play(${intervalMs2}) ignored \u2014 the interval must be a positive number of ms`);
20150
+ return;
20151
+ }
20152
+ this.replayIntervalMs = intervalMs2;
20153
+ }
20154
+ this.clearReplayTimer();
20155
+ this.replayPlaying = true;
20156
+ if (this.replayForming) this.paceFormingBar(this.replayForming);
20157
+ this.scheduleReplayStep(this.replayForming?.delayMs);
20158
+ this.events.emit("replay:play", { intervalMs: this.replayIntervalMs });
20159
+ }
20160
+ replayPause() {
20161
+ if (!this.replayPlaying) return;
20162
+ this.clearReplayTimer();
20163
+ this.events.emit("replay:pause", void 0);
20164
+ }
20165
+ replayStop() {
20166
+ const carrying = this.replayCarrying;
20167
+ if (carrying) {
20168
+ carrying.cancelled = true;
20169
+ this.replayCarrying = null;
20170
+ this.events.emit("replay:end", { reason: "stopped" });
20171
+ }
20172
+ if (this.replayLoading) {
20173
+ const depth = this.replayDepth;
20174
+ this.abandonReplayLoad("stopped");
20175
+ void this.setMarket({ bars: depth?.bars ?? 500 });
20176
+ return;
20177
+ }
20178
+ this.endReplay("stopped", { restore: true, resume: true });
20179
+ }
20180
+ abandonReplayLoad(reason) {
20181
+ const loading = this.replayLoading;
20182
+ if (!loading) return;
20183
+ this.replayLoading = null;
20184
+ this.replayDepth = null;
20185
+ if (loading.seek) this.events.emit("replay:end", { reason });
20186
+ }
20187
+ replayState() {
20188
+ const active = this.replayQueue !== null;
20189
+ return {
20190
+ active: active || this.replayCarrying !== null,
20191
+ // a replay carried over a switch stays on while the new bars load
20192
+ playing: this.replayCarrying?.playing ?? this.replayPlaying,
20193
+ cursorTime: active ? this.rawBars[this.rawBars.length - 1]?.time ?? null : null,
20194
+ remaining: this.replayQueue?.length ?? 0,
20195
+ nextTime: this.replayForming?.bar.time ?? this.replayQueue?.[0]?.time ?? null,
20196
+ intervalMs: this.replayIntervalMs
20197
+ };
20198
+ }
20199
+ replayBounds() {
20200
+ const first = this.rawBars[0];
20201
+ const last = this.replayQueue?.[this.replayQueue.length - 1] ?? this.rawBars[this.rawBars.length - 1];
20202
+ return first && last ? { first: first.time, last: last.time } : null;
20203
+ }
20204
+ scheduleReplayStep(delayMs = this.replayIntervalMs) {
20205
+ this.replayTimer = setTimeout(() => {
20206
+ this.replayTimer = null;
20207
+ this.replayAdvance();
20208
+ }, delayMs);
20209
+ }
20210
+ /** One beat of timed playback: the forming bar's next ticks, else the next bar — whole,
20211
+ * or opened from its ticks (waiting for them when they have not landed yet). */
20212
+ replayAdvance() {
20213
+ if (!this.replayPlaying || !this.replayQueue) return;
20214
+ const forming = this.replayForming;
20215
+ if (forming) {
20216
+ const due = Math.floor((now() - forming.origin) / this.replayIntervalMs) + 1;
20217
+ this.applyReplayTicks(Math.max(forming.batch, due - forming.next));
20218
+ if (this.replayPlaying) this.scheduleReplayStep(forming.delayMs);
20219
+ return;
20220
+ }
20221
+ const bar = this.replayQueue[0];
20222
+ if (!bar) return;
20223
+ const entry = this.replayTickSource ? this.requestTicks(bar) : null;
20224
+ if (entry && !entry.ticks) {
20225
+ const epoch = this.replayTickAbort;
20226
+ void entry.done.then(() => {
20227
+ if (this.replayPlaying && this.replayTimer == null && this.replayTickAbort === epoch && this.replayQueue?.[0] === bar) this.replayAdvance();
20228
+ });
20229
+ return;
20230
+ }
20231
+ if (!entry?.ticks?.length) {
20232
+ if (this.replayStep() && this.replayPlaying) this.scheduleReplayStep();
20233
+ return;
20234
+ }
20235
+ this.openFormingBar(entry.ticks);
20236
+ if (this.replayPlaying) this.scheduleReplayStep(this.replayForming?.delayMs);
20237
+ }
20238
+ /** Take the next queued bar off the tape and open it at its first tick (the first
20239
+ * `count` ticks — default: one timed update's worth). */
20240
+ openFormingBar(ticks, count) {
20241
+ const bar = this.replayQueue.shift();
20242
+ const volumeEach = ticks.every((t) => t.volume === void 0) ? (bar.volume ?? 0) / ticks.length : null;
20243
+ const forming = { bar, ticks, next: 0, candle: null, volumeEach, batch: 1, delayMs: this.replayIntervalMs, origin: 0 };
20244
+ this.paceFormingBar(forming);
20245
+ this.replayForming = forming;
20246
+ this.applyReplayTicks(count ?? forming.batch);
20247
+ this.prefetchTicks();
20248
+ }
20249
+ /** One tick per interval, batched when the interval is shorter than a frame; the clock
20250
+ * is anchored on the ticks already applied (a resume or a pace change carries on from
20251
+ * there, never jumps). */
20252
+ paceFormingBar(f) {
20253
+ f.batch = Math.max(1, Math.ceil(MIN_TICK_UPDATE_MS / this.replayIntervalMs));
20254
+ f.delayMs = this.replayIntervalMs * f.batch;
20255
+ f.origin = now() - Math.max(0, f.next - 1) * this.replayIntervalMs;
20256
+ }
20257
+ /** Fold the next `count` ticks into the forming candle; the last one settles the bar. */
20258
+ applyReplayTicks(count) {
20259
+ const f = this.replayForming;
20260
+ if (!f) return;
20261
+ let c = f.candle;
20262
+ const stop = Math.min(f.ticks.length, f.next + count);
20263
+ for (; f.next < stop; f.next += 1) {
20264
+ const t = f.ticks[f.next];
20265
+ const v = f.volumeEach ?? t.volume ?? 0;
20266
+ const hi = Math.max(t.high ?? t.price, t.price);
20267
+ const lo = Math.min(t.low ?? t.price, t.price);
20268
+ if (c) {
20269
+ c = { ...c, high: Math.max(c.high, hi), low: Math.min(c.low, lo), close: t.price, volume: (c.volume ?? 0) + v };
20270
+ } else {
20271
+ const open2 = t.open ?? t.price;
20272
+ c = { time: f.bar.time, open: open2, high: Math.max(open2, hi), low: Math.min(open2, lo), close: t.price, volume: v };
20273
+ }
20274
+ }
20275
+ f.candle = c;
20276
+ if (f.next >= f.ticks.length) {
20277
+ this.finishFormingBar();
20278
+ return;
20279
+ }
20280
+ this.applyBar(c);
20281
+ this.events.emit("replay:tick", { cursorTime: f.bar.time, index: f.next - 1, count: f.ticks.length });
20282
+ }
20283
+ /** Settle the forming bar on its stored values and announce it revealed. */
20284
+ finishFormingBar() {
20285
+ const f = this.replayForming;
20286
+ if (!f) return;
20287
+ this.replayForming = null;
20288
+ this.replayTickFetches.delete(f.bar.time);
20289
+ this.applyBar(f.bar);
20290
+ this.events.emit("replay:tick", { cursorTime: f.bar.time, index: f.ticks.length - 1, count: f.ticks.length });
20291
+ this.completeReveal(f.bar);
20292
+ }
20293
+ /** Put the forming bar back in its stored form, silently — a seek or an end rebuilds
20294
+ * from the tape, which must hold the stored bar. */
20295
+ settleFormingBar() {
20296
+ const f = this.replayForming;
20297
+ if (!f) return;
20298
+ this.replayForming = null;
20299
+ this.applyBar(f.bar);
20300
+ }
20301
+ /**
20302
+ * A history answer landing mid-replay (a progressive snapshot — the whole answer so far):
20303
+ * the tape keeps its cut and its hidden queue; bars older than the tape join its head,
20304
+ * bars newer than it join the queue's end.
20305
+ */
20306
+ mergeHistoryIntoReplay(bars) {
20307
+ const queue = this.replayQueue;
20308
+ const head = this.rawBars[0];
20309
+ if (!queue || !head) return;
20310
+ const tail = queue[queue.length - 1] ?? this.rawBars[this.rawBars.length - 1];
20311
+ let older = 0;
20312
+ while (older < bars.length && bars[older].time < head.time) older += 1;
20313
+ let newer = bars.length;
20314
+ while (newer > older && bars[newer - 1].time > tail.time) newer -= 1;
20315
+ if (newer < bars.length) queue.push(...bars.slice(newer));
20316
+ if (older > 0) {
20317
+ this.setBarSeries([...bars.slice(0, older), ...this.rawBars], { preserveView: true });
20318
+ this.notifySessionsBars("backfill");
20319
+ }
20320
+ }
20321
+ /** A bar is fully on screen: announce it, and end the replay if it was the last. */
20322
+ completeReveal(bar) {
20323
+ const queue = this.replayQueue;
20324
+ if (!queue) return;
20325
+ this.events.emit("replay:step", { cursorTime: bar.time, remaining: queue.length });
20326
+ if (queue.length === 0) this.endReplay("finished", { restore: true, resume: true });
20327
+ }
20328
+ /** Ask for the ticks of the next hidden bar ahead of time. */
20329
+ prefetchTicks() {
20330
+ const next = this.replayQueue?.[0];
20331
+ if (next && this.replayTickSource) this.requestTicks(next);
20332
+ }
20333
+ /** The (shared) tick request of a queued bar — the source runs once per bar. */
20334
+ requestTicks(bar) {
20335
+ const known = this.replayTickFetches.get(bar.time);
20336
+ if (known) return known;
20337
+ const source = this.replayTickSource;
20338
+ this.replayTickAbort ?? (this.replayTickAbort = new AbortController());
20339
+ const signal = this.replayTickAbort.signal;
20340
+ const queue = this.replayQueue ?? [];
20341
+ const end = queue[queue.indexOf(bar) + 1]?.time ?? bar.time + this.barIntervalMs();
20342
+ const entry = { ticks: null, done: Promise.resolve() };
20343
+ const land = (ticks) => {
20344
+ entry.ticks = usableTicks(ticks);
20345
+ };
20346
+ const fail = (e) => {
20347
+ if (!signal.aborted) console.warn(`[vela] replay tick source failed \u2014 revealing the bar whole (${e instanceof Error ? e.message : String(e)})`);
20348
+ land([]);
20349
+ };
20350
+ try {
20351
+ const out = source(bar, { end, signal });
20352
+ if (Array.isArray(out)) land(out);
20353
+ else entry.done = Promise.resolve(out).then(land, fail);
20354
+ } catch (e) {
20355
+ fail(e);
20356
+ }
20357
+ this.replayTickFetches.set(bar.time, entry);
20358
+ return entry;
20359
+ }
20360
+ /** Abort the tick requests in flight and forget the landed ones. */
20361
+ dropTickFetches() {
20362
+ this.replayTickAbort?.abort();
20363
+ this.replayTickAbort = null;
20364
+ this.replayTickFetches.clear();
20365
+ }
20366
+ clearReplayTimer() {
20367
+ if (this.replayTimer != null) clearTimeout(this.replayTimer);
20368
+ this.replayTimer = null;
20369
+ this.replayPlaying = false;
20370
+ }
20371
+ /**
20372
+ * Leave replay. `restore` puts the queued bars back and restarts the consumers over the
20373
+ * full history (a market switch skips it: its load replaces everything anyway); `resume`
20374
+ * restarts live updates and heals the bars that closed while replaying. A null reason
20375
+ * leaves silently — a seek that re-enters right after.
20376
+ */
20377
+ endReplay(reason, opts) {
20378
+ const queue = this.replayQueue;
20379
+ if (!queue) return;
20380
+ this.clearReplayTimer();
20381
+ if (opts.restore) this.settleFormingBar();
20382
+ else this.replayForming = null;
20383
+ this.dropTickFetches();
20384
+ this.replayQueue = null;
20385
+ const depth = reason ? this.replayDepth : null;
20386
+ if (reason) this.replayDepth = null;
20387
+ if (depth) {
20388
+ if (depth.bars === void 0) delete this.config.market.bars;
20389
+ else this.config.market.bars = depth.bars;
20390
+ }
20391
+ if (opts.restore) {
20392
+ const keep = depth ? depth.bars ?? 500 : Infinity;
20393
+ if (queue.length > 0 || this.rawBars.length > keep) {
20394
+ const full = [...this.rawBars, ...queue];
20395
+ this.setBarSeries(full.length > keep ? full.slice(full.length - keep) : full);
20396
+ this.restartBarConsumers({ blank: false });
20397
+ } else if (this.config.live) {
20398
+ this.restartNativeIndicators();
20399
+ this.restartChartTypeEngine();
20400
+ }
20401
+ }
20402
+ if (opts.resume && this.config.live) {
20403
+ this.startLive();
20404
+ const last = this.rawBars[this.rawBars.length - 1];
20405
+ if (last && this.canHeal()) void this.healGap(last.time);
20406
+ }
20407
+ if (reason) this.events.emit("replay:end", { reason });
20408
+ }
20409
+ /**
20410
+ * The bars were replaced by a cut or a restore of the SAME market: every consumer re-runs.
20411
+ * A cut also blanks the mounted models first — they still paint the hidden bars' values,
20412
+ * which must not show until the re-runs land.
20413
+ */
20414
+ restartBarConsumers(opts) {
20415
+ if (opts.blank) this.blankIndicatorVisuals();
20416
+ this.restartNativeIndicators();
20417
+ this.restartChartTypeEngine();
20418
+ this.reexecuteIndicators();
20419
+ }
19683
20420
  /** The primary symbol as the data layer resolves it, or null if no symbol is set. */
19684
20421
  qualifiedSymbol() {
19685
20422
  const market = this.config.market;
@@ -19751,7 +20488,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19751
20488
  engine.start({
19752
20489
  symbol: this.config.market.symbol ?? "TEST",
19753
20490
  timeframe: this.config.market.timeframe ?? "60",
19754
- live: this.config.live ?? false,
20491
+ live: (this.config.live ?? false) && this.replayQueue === null,
19755
20492
  session: this.config.market.session,
19756
20493
  bars: () => this.bars,
19757
20494
  data: this.dataControl,
@@ -19877,6 +20614,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19877
20614
  record.session.notifyBars(reason);
19878
20615
  } else if (record.native) record.native.instance.onBars();
19879
20616
  }
20617
+ if (this.activeEngineStyle) this.typeEngines.get(this.activeEngineStyle)?.onBars?.();
19880
20618
  }
19881
20619
  /** Gap healing needs a ranged feed and a provider-backed series (offline `data` has no source). */
19882
20620
  canHeal() {
@@ -19899,10 +20637,11 @@ var EngineOrchestrator = class _EngineOrchestrator {
19899
20637
  try {
19900
20638
  const bars = await this.feed.loadRange(this.config.market, { from: fromMs });
19901
20639
  if (this.generation !== gen) return;
20640
+ if (this.replayQueue) return;
19902
20641
  for (const b of bars) this.applyBar(b, false);
19903
20642
  } catch {
19904
20643
  } finally {
19905
- if (this.generation === gen) {
20644
+ if (this.generation === gen && !this.replayQueue) {
19906
20645
  this.healing = false;
19907
20646
  const pending = this.healBuffer;
19908
20647
  this.healBuffer = [];
@@ -20207,6 +20946,13 @@ var EngineOrchestrator = class _EngineOrchestrator {
20207
20946
  destroy() {
20208
20947
  this.bumpGeneration();
20209
20948
  this.resolveHistoryComplete();
20949
+ this.clearReplayTimer();
20950
+ this.replayQueue = null;
20951
+ this.replayForming = null;
20952
+ this.replayCarrying = null;
20953
+ this.dropTickFetches();
20954
+ this.replayDepth = null;
20955
+ this.replayLoading = null;
20210
20956
  this.stopLive();
20211
20957
  if (this.viewportTimer != null) clearTimeout(this.viewportTimer);
20212
20958
  this.viewportUnsub?.();
@@ -20305,6 +21051,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20305
21051
  this.emitScriptRun(id, cause, first);
20306
21052
  },
20307
21053
  onAlert: (a) => {
21054
+ if (this.replayQueue || this.replayCarrying) return;
20308
21055
  const indicator = record.options?.title ?? record.prepared?.meta.title ?? record.title;
20309
21056
  this.events.emit("alert", { ...a, indicator });
20310
21057
  handle.emit("alert", { id: a.id, message: a.message, title: a.title, time: a.time });
@@ -20331,7 +21078,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20331
21078
  id,
20332
21079
  symbol: this.config.market.symbol ?? "TEST",
20333
21080
  timeframe: this.config.market.timeframe ?? "60",
20334
- live: this.config.live,
21081
+ live: this.config.live && this.replayQueue === null,
20335
21082
  session: this.config.market.session,
20336
21083
  bars: () => this.bars,
20337
21084
  data: this.dataControl,
@@ -20734,17 +21481,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
20734
21481
  }
20735
21482
  emitContextChanged(id) {
20736
21483
  if (!this.registry.get(id)?.session?.getContext) return;
20737
- const now = Date.now();
20738
- if (now - (this.contextEmitAt.get(id) ?? 0) < 1e3) return;
20739
- this.contextEmitAt.set(id, now);
21484
+ const now2 = Date.now();
21485
+ if (now2 - (this.contextEmitAt.get(id) ?? 0) < 1e3) return;
21486
+ this.contextEmitAt.set(id, now2);
20740
21487
  this.events.emit("context:changed", { id });
20741
21488
  }
20742
21489
  emitScriptRun(id, cause, first) {
20743
21490
  const record = this.registry.get(id);
20744
21491
  if (!record || record.native || !this.events.hasListeners("script:run")) return;
20745
- const now = Date.now();
20746
- if (cause === "tick" && now - (this.runEmitAt.get(id) ?? 0) < RUN_EMIT_THROTTLE_MS) return;
20747
- this.runEmitAt.set(id, now);
21492
+ const now2 = Date.now();
21493
+ if (cause === "tick" && now2 - (this.runEmitAt.get(id) ?? 0) < RUN_EMIT_THROTTLE_MS) return;
21494
+ this.runEmitAt.set(id, now2);
20748
21495
  void Promise.resolve().then(() => this.buildScriptRun(record, cause, first)).then((run) => {
20749
21496
  if (run && this.registry.get(id)) this.events.emit("script:run", run);
20750
21497
  });
@@ -20797,6 +21544,16 @@ var EngineOrchestrator = class _EngineOrchestrator {
20797
21544
  handle?.emit("error", { error });
20798
21545
  }
20799
21546
  };
21547
+ function now() {
21548
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
21549
+ }
21550
+ function usableTicks(value) {
21551
+ if (!Array.isArray(value)) return [];
21552
+ const optional = (n) => n === void 0 || Number.isFinite(n);
21553
+ return value.filter(
21554
+ (t) => t != null && Number.isFinite(t.price) && optional(t.volume) && optional(t.high) && optional(t.low) && optional(t.open)
21555
+ );
21556
+ }
20800
21557
  function yieldToPaint() {
20801
21558
  if (typeof requestAnimationFrame === "function") {
20802
21559
  return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
@@ -20997,6 +21754,12 @@ var RendererControl = class {
20997
21754
  onCrosshairMove(cb) {
20998
21755
  return this.renderer.onCrosshairMove(cb);
20999
21756
  }
21757
+ /** A click — or a touch tap — on the plot, never the end of a pan: the open time of
21758
+ * the bar under it (`null` off the bars). On touch, where a tap moves no crosshair,
21759
+ * this is how an interaction learns which bar was chosen. */
21760
+ onClick(cb) {
21761
+ return this.renderer.onClick(cb);
21762
+ }
21000
21763
  /** Touch long-press on a price or time axis strip — silent no-op without the seam. */
21001
21764
  onAxisLongPress(cb) {
21002
21765
  return this.renderer.onAxisLongPress?.(cb) ?? (() => void 0);
@@ -21495,6 +22258,71 @@ var MarksControl = class {
21495
22258
  }
21496
22259
  };
21497
22260
 
22261
+ // src/core/ReplayControl.ts
22262
+ var ReplayControl = class {
22263
+ constructor(ctrl) {
22264
+ this.ctrl = ctrl;
22265
+ }
22266
+ /**
22267
+ * Enter replay (or seek, when already replaying) at `opts.from`. Resolves once the
22268
+ * chart shows the rewound history — after any in-flight history load has finished.
22269
+ * Starts paused; call {@link play} or {@link step}.
22270
+ */
22271
+ start(opts) {
22272
+ return this.ctrl.replayStart(opts);
22273
+ }
22274
+ /** Reveal the next bar — or, mid-way through a bar played tick by tick, complete it.
22275
+ * Returns false when replay is off or nothing is left. */
22276
+ step() {
22277
+ return this.ctrl.replayStep();
22278
+ }
22279
+ /** Reveal the next UPDATE: with {@link setTicks}, the next tick of the forming bar — or
22280
+ * the next bar opened at its first tick (once its ticks are in, if still loading);
22281
+ * otherwise the next whole bar, like {@link step}. Returns false when nothing is left. */
22282
+ stepUpdate() {
22283
+ return this.ctrl.replayStepUpdate();
22284
+ }
22285
+ /** Reveal one bar (one tick, with {@link setTicks}) every `intervalMs` (default: the last
22286
+ * interval used, else 1000). Calling it again while playing changes the pace. */
22287
+ play(intervalMs2) {
22288
+ this.ctrl.replayPlay(intervalMs2);
22289
+ return this;
22290
+ }
22291
+ pause() {
22292
+ this.ctrl.replayPause();
22293
+ return this;
22294
+ }
22295
+ /** Leave replay: the full history comes back and live updates resume. Also cancels a
22296
+ * `start()` still loading older history (the chart returns to its previous depth). */
22297
+ stop() {
22298
+ this.ctrl.replayStop();
22299
+ return this;
22300
+ }
22301
+ get state() {
22302
+ return this.ctrl.replayState();
22303
+ }
22304
+ /** The loaded history a replay can walk — hidden bars included while replaying. Null
22305
+ * before any bar loaded. A start older than `first` deepens the history first. */
22306
+ get bounds() {
22307
+ return this.ctrl.replayBounds();
22308
+ }
22309
+ /**
22310
+ * Play each revealed bar as a series of intrabar updates instead of whole: while
22311
+ * playing, the forming candle opens at the first tick, then every tick moves its close
22312
+ * and stretches its high and low, exactly like a live tick — indicators, chart types
22313
+ * and the `bar` event follow — and the last one settles it on the stored bar. The play
22314
+ * interval is then the time between two TICKS: a bar lasts as many intervals as it
22315
+ * has ticks (below ~16 ms, ticks are batched per frame). The next bar's ticks are
22316
+ * requested while the current one plays; playback waits for them. `null` goes back to
22317
+ * whole bars. Takes effect from the next bar; a bar forming when the source changes
22318
+ * completes at once.
22319
+ */
22320
+ setTicks(source) {
22321
+ this.ctrl.replaySetTicks(source);
22322
+ return this;
22323
+ }
22324
+ };
22325
+
21498
22326
  // src/core/model/inputs.ts
21499
22327
  function inputVisible(when, values) {
21500
22328
  if (!when) return true;
@@ -22104,10 +22932,16 @@ var IndicatorInputsDialog = class {
22104
22932
  this.calendarPop = null;
22105
22933
  this.calendarAnchor = null;
22106
22934
  },
22107
- content: (el) => fillCalendar(el, current, (iso) => {
22108
- pop.hide();
22109
- onPick(iso);
22110
- })
22935
+ content: (el) => {
22936
+ const picker = new DatePicker({
22937
+ value: current,
22938
+ onPick: (iso) => {
22939
+ pop.hide();
22940
+ onPick(iso);
22941
+ }
22942
+ });
22943
+ el.append(picker.el);
22944
+ }
22111
22945
  });
22112
22946
  this.calendarPop = pop;
22113
22947
  this.calendarAnchor = anchor;
@@ -22232,28 +23066,6 @@ function ensureDialogStyles() {
22232
23066
  .vela-ind-combo-chevron{position:absolute;right:0;top:0;bottom:0;width:26px;border:none;background:transparent;color:inherit;opacity:0.55;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;}
22233
23067
  .vela-ind-combo-chevron:hover{opacity:0.9;}
22234
23068
  .vela-ind-cal{background:var(--vela-bg);color:var(--vela-fg);border:none;border-radius:6px;box-shadow:var(--vela-shadow);font:14px var(--vela-font);padding:10px 12px;user-select:none;}
22235
- .vela-ind-cal [hidden]{display:none !important;}
22236
- .vela-ind-cal-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
22237
- .vela-ind-cal-title{flex:1;display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;}
22238
- .vela-ind-cal-switch{border:none;background:transparent;color:var(--vela-fg-bright);font:inherit;font-weight:600;font-size:14px;padding:2px 6px;border-radius:4px;cursor:pointer;}
22239
- .vela-ind-cal-switch:not(:disabled):hover{background:var(--vela-hover);}
22240
- .vela-ind-cal-switch:disabled{cursor:default;}
22241
- .vela-ind-cal-nav{width:24px;height:24px;border:none;background:transparent;color:var(--vela-fg-muted);border-radius:4px;padding:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;}
22242
- .vela-ind-cal-nav:hover{background:var(--vela-hover);color:var(--vela-fg-bright);}
22243
- .vela-ind-cal-week,.vela-ind-cal-grid{display:grid;grid-template-columns:repeat(7,28px);gap:2px;}
22244
- .vela-ind-cal-week{margin-bottom:4px;color:var(--vela-fg-muted);font-size:11px;text-align:center;}
22245
- .vela-ind-cal-week span{line-height:20px;}
22246
- .vela-ind-cal-blank{width:28px;height:28px;}
22247
- .vela-ind-cal-day{width:28px;height:28px;border:none;background:transparent;color:inherit;border-radius:4px;padding:0;cursor:pointer;font:inherit;font-size:14px;}
22248
- .vela-ind-cal-day:hover{background:var(--vela-hover);}
22249
- .vela-ind-cal-day[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
22250
- .vela-ind-cal-day[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
22251
- .vela-ind-cal-cells{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;width:calc(7 * 28px + 6 * 2px);}
22252
- .vela-ind-cal-cell{height:36px;border:none;background:transparent;color:inherit;border-radius:4px;padding:0 4px;cursor:pointer;font:inherit;font-size:14px;}
22253
- .vela-ind-cal-cell:hover{background:var(--vela-hover);}
22254
- .vela-ind-cal-cell[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
22255
- .vela-ind-cal-cell[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
22256
- .vela-ind-cal-cell[data-outside]{opacity:0.45;}
22257
23069
  ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
22258
23070
  .vela-ind-tab{font-weight:600;font-size:13px;line-height:20px;transition:color var(--vela-dur-fast) ease,border-color var(--vela-dur-fast) ease;}
22259
23071
  .vela-ind-tab:not(.vela-ind-tab-active):hover{color:var(--vela-fg-bright);}
@@ -22287,174 +23099,6 @@ var TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
22287
23099
  const v = `${hh}:${mm}`;
22288
23100
  return { value: v, label: v };
22289
23101
  });
22290
- var MONTH_LABELS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
22291
- var MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
22292
- var WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
22293
- function normalizeDateInput(raw) {
22294
- const t = raw.trim();
22295
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t);
22296
- if (!m) return null;
22297
- const y = Number(m[1]);
22298
- const mo = Number(m[2]);
22299
- const d = Number(m[3]);
22300
- const dt = new Date(y, mo - 1, d);
22301
- if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
22302
- return isoDate(dt);
22303
- }
22304
- function parseIsoDate(raw) {
22305
- const iso = normalizeDateInput(raw);
22306
- if (!iso) return null;
22307
- const [y, mo, d] = iso.split("-").map(Number);
22308
- return new Date(y, mo - 1, d);
22309
- }
22310
- function isoDate(d) {
22311
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
22312
- }
22313
- function fillCalendar(el, current, onPick) {
22314
- const today = /* @__PURE__ */ new Date();
22315
- const selected = parseIsoDate(current);
22316
- let year = selected?.getFullYear() ?? today.getFullYear();
22317
- let month = selected?.getMonth() ?? today.getMonth();
22318
- let mode = "date";
22319
- const prev2 = document.createElement("button");
22320
- prev2.type = "button";
22321
- prev2.className = "vela-ind-cal-nav";
22322
- prev2.innerHTML = iconAt("chevron-left", 14);
22323
- const next = document.createElement("button");
22324
- next.type = "button";
22325
- next.className = "vela-ind-cal-nav";
22326
- next.innerHTML = iconAt("chevron-right", 14);
22327
- const monthBtn = document.createElement("button");
22328
- monthBtn.type = "button";
22329
- monthBtn.className = "vela-ind-cal-switch";
22330
- monthBtn.setAttribute("aria-label", "Choose month");
22331
- const yearBtn = document.createElement("button");
22332
- yearBtn.type = "button";
22333
- yearBtn.className = "vela-ind-cal-switch";
22334
- yearBtn.setAttribute("aria-label", "Choose year");
22335
- const title = document.createElement("div");
22336
- title.className = "vela-ind-cal-title";
22337
- title.append(monthBtn, yearBtn);
22338
- const head = document.createElement("div");
22339
- head.className = "vela-ind-cal-head";
22340
- head.append(prev2, title, next);
22341
- const week = document.createElement("div");
22342
- week.className = "vela-ind-cal-week";
22343
- for (const d of WEEKDAY_LABELS) {
22344
- const cell = document.createElement("span");
22345
- cell.textContent = d;
22346
- week.appendChild(cell);
22347
- }
22348
- const grid = document.createElement("div");
22349
- const paint = () => {
22350
- const decade = Math.floor(year / 10) * 10;
22351
- monthBtn.hidden = mode !== "date";
22352
- monthBtn.textContent = MONTH_LABELS[month] ?? "";
22353
- yearBtn.textContent = mode === "year" ? `${decade}-${decade + 9}` : String(year);
22354
- yearBtn.disabled = mode === "year";
22355
- week.hidden = mode !== "date";
22356
- prev2.setAttribute("aria-label", mode === "date" ? "Previous month" : mode === "month" ? "Previous year" : "Previous decade");
22357
- next.setAttribute("aria-label", mode === "date" ? "Next month" : mode === "month" ? "Next year" : "Next decade");
22358
- grid.className = mode === "date" ? "vela-ind-cal-grid" : "vela-ind-cal-cells";
22359
- grid.replaceChildren();
22360
- if (mode === "date") {
22361
- const startPad = new Date(year, month, 1).getDay();
22362
- const days = new Date(year, month + 1, 0).getDate();
22363
- const todayIso = isoDate(today);
22364
- const selectedIso = selected ? isoDate(selected) : "";
22365
- for (let i = 0; i < startPad; i++) {
22366
- const blank = document.createElement("span");
22367
- blank.className = "vela-ind-cal-blank";
22368
- grid.appendChild(blank);
22369
- }
22370
- for (let day = 1; day <= days; day++) {
22371
- const iso = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
22372
- const b = document.createElement("button");
22373
- b.type = "button";
22374
- b.className = "vela-ind-cal-day";
22375
- b.textContent = String(day);
22376
- if (iso === selectedIso) b.dataset.checked = "1";
22377
- if (iso === todayIso) b.dataset.today = "1";
22378
- b.addEventListener("click", (e) => {
22379
- e.stopPropagation();
22380
- onPick(iso);
22381
- });
22382
- grid.appendChild(b);
22383
- }
22384
- return;
22385
- }
22386
- if (mode === "month") {
22387
- for (let m = 0; m < 12; m++) {
22388
- const b = document.createElement("button");
22389
- b.type = "button";
22390
- b.className = "vela-ind-cal-cell";
22391
- b.textContent = MONTH_SHORT[m] ?? "";
22392
- if (selected?.getFullYear() === year && selected.getMonth() === m) b.dataset.checked = "1";
22393
- if (today.getFullYear() === year && today.getMonth() === m) b.dataset.today = "1";
22394
- b.addEventListener("click", (e) => {
22395
- e.stopPropagation();
22396
- month = m;
22397
- mode = "date";
22398
- paint();
22399
- });
22400
- grid.appendChild(b);
22401
- }
22402
- return;
22403
- }
22404
- for (let y = decade - 1; y <= decade + 10; y++) {
22405
- const b = document.createElement("button");
22406
- b.type = "button";
22407
- b.className = "vela-ind-cal-cell";
22408
- b.textContent = String(y);
22409
- if (y < decade || y > decade + 9) b.dataset.outside = "1";
22410
- if (selected?.getFullYear() === y) b.dataset.checked = "1";
22411
- if (today.getFullYear() === y) b.dataset.today = "1";
22412
- b.addEventListener("click", (e) => {
22413
- e.stopPropagation();
22414
- year = y;
22415
- mode = "month";
22416
- paint();
22417
- });
22418
- grid.appendChild(b);
22419
- }
22420
- };
22421
- const step = (dir) => {
22422
- if (mode === "date") {
22423
- month += dir;
22424
- if (month < 0) {
22425
- month = 11;
22426
- year -= 1;
22427
- }
22428
- if (month > 11) {
22429
- month = 0;
22430
- year += 1;
22431
- }
22432
- } else {
22433
- year += mode === "month" ? dir : dir * 10;
22434
- }
22435
- paint();
22436
- };
22437
- prev2.addEventListener("click", (e) => {
22438
- e.stopPropagation();
22439
- step(-1);
22440
- });
22441
- next.addEventListener("click", (e) => {
22442
- e.stopPropagation();
22443
- step(1);
22444
- });
22445
- monthBtn.addEventListener("click", (e) => {
22446
- e.stopPropagation();
22447
- mode = "month";
22448
- paint();
22449
- });
22450
- yearBtn.addEventListener("click", (e) => {
22451
- e.stopPropagation();
22452
- mode = "year";
22453
- paint();
22454
- });
22455
- paint();
22456
- el.append(head, week, grid);
22457
- }
22458
23102
  function normalizeTimeInput(raw) {
22459
23103
  const t = raw.trim();
22460
23104
  const m = /^(\d{1,2}):(\d{2})$/.exec(t) ?? /^(\d{2})(\d{2})$/.exec(t);
@@ -25158,7 +25802,7 @@ var Scheduler = class {
25158
25802
 
25159
25803
  // src/renderers/native/core/Animator.ts
25160
25804
  var Animator = class {
25161
- constructor(tick, raf, cancel, now) {
25805
+ constructor(tick, raf, cancel, now2) {
25162
25806
  this.tick = tick;
25163
25807
  this.handle = null;
25164
25808
  this.last = 0;
@@ -25172,7 +25816,7 @@ var Animator = class {
25172
25816
  };
25173
25817
  this.raf = raf ?? ((cb) => requestAnimationFrame(cb));
25174
25818
  this.cancel = cancel ?? ((h) => cancelAnimationFrame(h));
25175
- this.now = now ?? (() => performance.now());
25819
+ this.now = now2 ?? (() => performance.now());
25176
25820
  }
25177
25821
  get active() {
25178
25822
  return this.handle !== null;
@@ -25971,6 +26615,8 @@ var SceneGraph = class {
25971
26615
  /** VPVR-layer config pushed by the VPVR native indicator (null ⇒ layer off). Ephemeral. */
25972
26616
  this.vpvrLayer = null;
25973
26617
  this.crosshair = null;
26618
+ /** Runtime override of the crosshair's lines and style (the `crosshairOverride` feature). Ephemeral. */
26619
+ this.crosshairOverride = null;
25974
26620
  /** How the base price series is drawn on the price pane (candles by default). */
25975
26621
  this.priceStyle = "candles";
25976
26622
  /** Price-series base painting for the ACTIVE style (see ChartTypeDefinition.basePainting). */
@@ -27887,9 +28533,9 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
27887
28533
  }
27888
28534
 
27889
28535
  // src/renderers/native/chrome/countdown.ts
27890
- function countdownText(barOpen, barMs, now) {
28536
+ function countdownText(barOpen, barMs, now2) {
27891
28537
  if (!(barMs > 0)) return null;
27892
- const remaining = barOpen + barMs - now;
28538
+ const remaining = barOpen + barMs - now2;
27893
28539
  if (remaining <= 0) return null;
27894
28540
  return formatCountdown(remaining);
27895
28541
  }
@@ -28701,19 +29347,35 @@ var CrosshairRenderer = class {
28701
29347
  const dataH = coords.height;
28702
29348
  if (!ch || ch.x < 0 || ch.x > dataW || ch.y < 0 || ch.y > dataH) return;
28703
29349
  const cs = scene.style.crosshair;
29350
+ const ov = scene.crosshairOverride;
29351
+ const vertical = ov?.vertical !== false;
29352
+ const horizontal = ov?.horizontal !== false;
28704
29353
  ctx.font = `${scene.style.fontSize}px ${theme.fontFamily}`;
28705
29354
  ctx.textBaseline = "middle";
28706
29355
  const logical = Math.round(coords.xToLogical(ch.x));
28707
- const x = Math.round(coords.logicalToX(logical)) + 0.5;
28708
- ctx.strokeStyle = cs.color ?? theme.textColor;
28709
- ctx.lineWidth = cs.width;
28710
- ctx.globalAlpha = cs.opacity;
28711
- setDash3(ctx, cs.style);
29356
+ const x = crisp(coords.logicalToX(logical), ov?.width ?? cs.width);
29357
+ if (vertical && ov?.shadeRight) {
29358
+ const from = Math.max(0, Math.round(coords.logicalToX(logical + 0.5)));
29359
+ if (from < dataW) {
29360
+ ctx.fillStyle = ov.shadeRight.color;
29361
+ ctx.globalAlpha = ov.shadeRight.opacity ?? 1;
29362
+ ctx.fillRect(from, 0, dataW - from, dataH);
29363
+ ctx.globalAlpha = 1;
29364
+ }
29365
+ }
29366
+ ctx.strokeStyle = ov?.color ?? cs.color ?? theme.textColor;
29367
+ ctx.lineWidth = ov?.width ?? cs.width;
29368
+ ctx.globalAlpha = ov?.opacity ?? cs.opacity;
29369
+ setDash3(ctx, ov?.style ?? cs.style);
28712
29370
  ctx.beginPath();
28713
- ctx.moveTo(x, 0);
28714
- ctx.lineTo(x, dataH);
28715
- ctx.moveTo(0, Math.round(ch.y) + 0.5);
28716
- ctx.lineTo(dataW, Math.round(ch.y) + 0.5);
29371
+ if (vertical) {
29372
+ ctx.moveTo(x, 0);
29373
+ ctx.lineTo(x, dataH);
29374
+ }
29375
+ if (horizontal) {
29376
+ ctx.moveTo(0, Math.round(ch.y) + 0.5);
29377
+ ctx.lineTo(dataW, Math.round(ch.y) + 0.5);
29378
+ }
28717
29379
  ctx.stroke();
28718
29380
  ctx.setLineDash([]);
28719
29381
  ctx.lineWidth = 1;
@@ -28726,11 +29388,11 @@ var CrosshairRenderer = class {
28726
29388
  }
28727
29389
  }
28728
29390
  const chipBg = cs.labelBackground ?? theme.borderColor;
28729
- if (pane && pane.axisFormat !== "none") {
29391
+ if (horizontal && pane && pane.axisFormat !== "none") {
28730
29392
  const price = coords.yToPrice(ch.y, pane.scale, pane.bounds);
28731
29393
  this.chip(ctx, dataW + 1, ch.y, formatAxisValue(pane.scale, pane.bounds.height, price, percentScaleFor(scene, pane), scene.priceMintick, pane.axisFormat), chipBg, "left", false, theme.background);
28732
29394
  }
28733
- this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
29395
+ if (vertical) this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
28734
29396
  }
28735
29397
  destroy() {
28736
29398
  this.canvas = null;
@@ -28741,23 +29403,37 @@ var CrosshairRenderer = class {
28741
29403
  * along), with that bar's time chip in this chart's own timezone and — when the
28742
29404
  * level resolved — the price chip on the right axis. The snap happened upstream
28743
29405
  * (`externalCrossPx`, floor-to-containing-bar) — this method only draws. Chips
28744
- * render slightly dimmed so the ghost still reads as foreign. */
29406
+ * render slightly dimmed so the ghost still reads as foreign. A `crosshairOverride`
29407
+ * applies to the ghost as well — its line style, a dropped horizontal line, and the
29408
+ * `shadeRight` veil from the ghost bar's right edge — so a pick that spans several
29409
+ * charts reads the same on every one of them. */
28745
29410
  drawExternal(ctx, ext, scene, coords, theme) {
28746
29411
  const cs = scene.style.crosshair;
29412
+ const ov = scene.crosshairOverride;
28747
29413
  const dataW = coords.width;
28748
29414
  const dataH = coords.height;
28749
- const x = Math.round(ext.x) + 0.5;
28750
- if (x < 0 || x > dataW) return;
29415
+ const x = crisp(ext.x, ov?.width ?? cs.width);
29416
+ if (ov?.vertical === false) return;
29417
+ if (ov?.shadeRight) {
29418
+ const from = Math.max(0, Math.round(coords.logicalToX(Math.round(coords.xToLogical(ext.x)) + 0.5)));
29419
+ if (from < dataW) {
29420
+ ctx.fillStyle = ov.shadeRight.color;
29421
+ ctx.globalAlpha = ov.shadeRight.opacity ?? 1;
29422
+ ctx.fillRect(from, 0, dataW - from, dataH);
29423
+ ctx.globalAlpha = 1;
29424
+ }
29425
+ }
29426
+ if (ext.line === false || x < 0 || x > dataW) return;
28751
29427
  ctx.font = `${scene.style.fontSize}px ${theme.fontFamily}`;
28752
29428
  ctx.textBaseline = "middle";
28753
- ctx.strokeStyle = cs.color ?? theme.textColor;
28754
- ctx.lineWidth = cs.width;
28755
- ctx.globalAlpha = cs.opacity * 0.55;
28756
- setDash3(ctx, cs.style);
29429
+ ctx.strokeStyle = ov?.color ?? cs.color ?? theme.textColor;
29430
+ ctx.lineWidth = ov?.width ?? cs.width;
29431
+ ctx.globalAlpha = (ov?.opacity ?? cs.opacity) * 0.55;
29432
+ setDash3(ctx, ov?.style ?? cs.style);
28757
29433
  ctx.beginPath();
28758
29434
  ctx.moveTo(x, 0);
28759
29435
  ctx.lineTo(x, dataH);
28760
- if (ext.y != null) {
29436
+ if (ext.y != null && ov?.horizontal !== false) {
28761
29437
  ctx.moveTo(0, Math.round(ext.y) + 0.5);
28762
29438
  ctx.lineTo(dataW, Math.round(ext.y) + 0.5);
28763
29439
  }
@@ -28766,7 +29442,7 @@ var CrosshairRenderer = class {
28766
29442
  ctx.lineWidth = 1;
28767
29443
  ctx.globalAlpha = 0.8;
28768
29444
  const chipBg = cs.labelBackground ?? theme.borderColor;
28769
- if (ext.y != null && ext.price != null) {
29445
+ if (ext.y != null && ext.price != null && ov?.horizontal !== false) {
28770
29446
  let pane;
28771
29447
  for (const p of scene.panes.values()) {
28772
29448
  if (ext.y >= p.bounds.top && ext.y <= p.bounds.top + p.bounds.height) {
@@ -28806,6 +29482,9 @@ var CrosshairRenderer = class {
28806
29482
  ctx.textAlign = "start";
28807
29483
  }
28808
29484
  };
29485
+ function crisp(px, width) {
29486
+ return Math.round(px) + (Math.round(width) % 2 === 1 ? 0.5 : 0);
29487
+ }
28809
29488
  function setDash3(ctx, style) {
28810
29489
  if (style === "dashed") ctx.setLineDash([6, 4]);
28811
29490
  else if (style === "dotted") ctx.setLineDash([2, 3]);
@@ -36244,7 +36923,7 @@ var NativeRenderer = class {
36244
36923
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
36245
36924
  this.priceStyleCbs = /* @__PURE__ */ new Set();
36246
36925
  this.name = "native";
36247
- this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "animScroll", "animAutoscale", "animLiveBar", "intro", "zoomAnchor", "axisDrag", "paneResize", "candleZOrder", "candleVisible", "seriesOrder", "highlights", "sessionZones", "gridlines", "axisLabels", "scaleMode", "invertScale", "paneScales", "autoScale", "timezone", "keyboard", "historyChords", "priceStyle", "priceBaseline", "baselinePrice", "settings", "attribution", "dialogHost", "tradeMarkers", "marks", "indicatorTitles", "indicatorValues"];
36926
+ this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "animScroll", "animAutoscale", "animLiveBar", "intro", "zoomAnchor", "axisDrag", "paneResize", "candleZOrder", "candleVisible", "seriesOrder", "highlights", "sessionZones", "gridlines", "axisLabels", "scaleMode", "invertScale", "paneScales", "autoScale", "timezone", "keyboard", "historyChords", "priceStyle", "priceBaseline", "baselinePrice", "settings", "attribution", "dialogHost", "tradeMarkers", "marks", "indicatorTitles", "indicatorValues", "crosshairOverride"];
36248
36927
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
36249
36928
  * so moving onto the button doesn't count as leaving). */
36250
36929
  this.onScrollProximityMove = (e) => {
@@ -36423,6 +37102,9 @@ var NativeRenderer = class {
36423
37102
  this.scene.marks = mergeMarksState(this.scene.marks, value);
36424
37103
  this.markPopover?.close();
36425
37104
  break;
37105
+ case "crosshairOverride":
37106
+ this.scene.crosshairOverride = sanitizeCrosshairOverride(value);
37107
+ break;
36426
37108
  case "keyboard":
36427
37109
  this.setKeyboardEnabled(Boolean(value));
36428
37110
  return;
@@ -36536,6 +37218,8 @@ var NativeRenderer = class {
36536
37218
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
36537
37219
  case "marks":
36538
37220
  return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
37221
+ case "crosshairOverride":
37222
+ return this.scene.crosshairOverride ? { ...this.scene.crosshairOverride } : null;
36539
37223
  case "keyboard":
36540
37224
  return this.keyboardEnabled;
36541
37225
  case "historyChords":
@@ -37114,8 +37798,8 @@ var NativeRenderer = class {
37114
37798
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
37115
37799
  this.modelAlpha = 0;
37116
37800
  const start = performance.now();
37117
- const step = (now) => {
37118
- const p = Math.min(1, (now - start) / duration);
37801
+ const step = (now2) => {
37802
+ const p = Math.min(1, (now2 - start) / duration);
37119
37803
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
37120
37804
  this.paintData();
37121
37805
  if (p < 1) {
@@ -37131,8 +37815,8 @@ var NativeRenderer = class {
37131
37815
  fadeInModels() {
37132
37816
  const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
37133
37817
  const start = performance.now();
37134
- const step = (now) => {
37135
- this.modelAlpha = Math.min(1, (now - start) / fade);
37818
+ const step = (now2) => {
37819
+ this.modelAlpha = Math.min(1, (now2 - start) / fade);
37136
37820
  this.paintData();
37137
37821
  if (this.modelAlpha < 1) {
37138
37822
  this.introRaf = requestAnimationFrame(step);
@@ -37655,10 +38339,13 @@ var NativeRenderer = class {
37655
38339
  this.animator.start();
37656
38340
  }
37657
38341
  } else if (!last || bar.time > last.time) {
38342
+ const vp = this.coords.getViewport();
38343
+ const holdView = n > 0 && vp.rightOffset < 0 && this.scrollTargetRO === null;
37658
38344
  this.bars.push(bar);
37659
38345
  this.syncLiveEase(bar);
37660
38346
  if (this.coords.barInterval > 0) this.coords.appendBar(bar.time);
37661
38347
  else this.coords.setBars(this.bars.map((b) => b.time));
38348
+ if (holdView) this.coords.setViewport({ ...vp, rightOffset: vp.rightOffset - 1 });
37662
38349
  } else {
37663
38350
  return;
37664
38351
  }
@@ -38340,10 +39027,10 @@ var NativeRenderer = class {
38340
39027
  if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
38341
39028
  const tick = () => {
38342
39029
  this.markPulseRaf = null;
38343
- const now = frameNow();
38344
- if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
39030
+ const now2 = frameNow();
39031
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now2) this.scene.marksFlash = null;
38345
39032
  this.scheduler?.invalidate(2 /* Chrome */);
38346
- const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
39033
+ const pulsing = this.scene.marksHoverKey !== null && now2 - this.scene.marksHoverSince < MARK_PULSE_MS;
38347
39034
  if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
38348
39035
  };
38349
39036
  this.markPulseRaf = requestAnimationFrame(tick);
@@ -38904,9 +39591,10 @@ var NativeRenderer = class {
38904
39591
  const ext = this.externalCross;
38905
39592
  if (!ext || this.coords.barCount === 0) return null;
38906
39593
  const logical = Math.floor(this.coords.timeToLogical(ext.time));
38907
- if (logical < 0 || logical >= this.coords.barCount) return null;
38908
- const x = this.coords.logicalToX(logical);
38909
- if (!Number.isFinite(x) || x < 0 || x > this.coords.width) return null;
39594
+ if (!(logical < this.coords.barCount)) return null;
39595
+ const x = this.coords.logicalToX(Math.max(logical, -1));
39596
+ if (!Number.isFinite(x)) return null;
39597
+ const line = logical >= 0 && x >= 0 && x <= this.coords.width;
38910
39598
  let y = null;
38911
39599
  if (ext.price != null) {
38912
39600
  const pricePane = this.scene.orderedPanes().find((p) => p.kind === "price");
@@ -38915,7 +39603,7 @@ var NativeRenderer = class {
38915
39603
  if (!Number.isFinite(y) || y < pricePane.bounds.top || y > pricePane.bounds.top + pricePane.bounds.height) y = null;
38916
39604
  }
38917
39605
  }
38918
- return { x, y, time: this.coords.logicalToTime(logical), price: y != null ? ext.price : null };
39606
+ return { x, y, time: this.coords.logicalToTime(Math.max(logical, 0)), price: y != null ? ext.price : null, line };
38919
39607
  }
38920
39608
  /** Set the sticky magnet mode (called by the drawings toolbar's 3-state button). */
38921
39609
  setSnapMode(mode) {
@@ -43591,6 +44279,7 @@ var Vela = class {
43591
44279
  this.panesControl = new PanesControl(this.orchestrator);
43592
44280
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
43593
44281
  this.marksControl = new MarksControl(this.orchestrator.marks);
44282
+ this.replayControl = new ReplayControl(this.orchestrator);
43594
44283
  }
43595
44284
  /**
43596
44285
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -43830,6 +44519,16 @@ var Vela = class {
43830
44519
  get marks() {
43831
44520
  return this.marksControl;
43832
44521
  }
44522
+ /**
44523
+ * The chart's bar-replay control surface: rewind to a past bar and reveal the
44524
+ * following ones by hand or on a timer, from the history already loaded —
44525
+ * `await chart.replay.start({ from })`, `chart.replay.play(2000)`, `chart.replay.step()`,
44526
+ * `chart.replay.stop()`. Revealed bars behave like live bars for indicators; live
44527
+ * updates pause meanwhile. Follow it with the `replay:*` events.
44528
+ */
44529
+ get replay() {
44530
+ return this.replayControl;
44531
+ }
43833
44532
  on(event, handler) {
43834
44533
  return this.orchestrator.events.on(event, handler);
43835
44534
  }
@@ -44025,9 +44724,15 @@ var CSS21 = `
44025
44724
  }
44026
44725
  .vela-statusline .vela-sl-symbol { font-weight: 600; font-size: var(--vela-font-size-lg); }
44027
44726
  .vela-statusline .vela-sl-meta { color: var(--vela-fg-muted); font-size: var(--vela-font-size-md); font-weight: 600; }
44727
+ /* The meta opens with "\xB7 " \u2014 sit its dot one space-width after the ticker, as far as the
44728
+ * venue sits after it, not a full row gap away. */
44729
+ .vela-statusline .vela-sl-symbol + .vela-sl-meta { margin-left: calc(var(--vela-space-1) - var(--vela-space-2)); }
44028
44730
  /* Market status badge \u2014 a kit callout bubble (icon-only 16px circle, label on hover
44029
- * via the kit tooltip); the session tint is applied per status in setMarketStatus. */
44030
- .vela-statusline .vela-sl-market { align-self: center; }
44731
+ * via the kit tooltip); the session tint is applied per status in setMarketStatus. While
44732
+ * the chart replays past bars it wears the replay badge instead (the inverse chip). */
44733
+ .vela-statusline .vela-sl-market { align-self: center; display: inline-flex; }
44734
+ .vela-statusline .vela-sl-market > [hidden] { display: none !important; }
44735
+ .vela-statusline .vela-sl-replay-badge, .vela-statusline .vela-sl-replay-badge svg { display: block; width: 16px; height: 16px; }
44031
44736
  .vela-statusline .vela-sl-ohlc { display: flex; gap: var(--vela-space-1); color: var(--vela-fg-muted); }
44032
44737
  .vela-statusline .vela-sl-ohlc b { color: var(--vela-fg); font-weight: 500; }
44033
44738
  /* The change value wears the SAME ink as the OHLC values (set inline per render) \u2014
@@ -44080,6 +44785,8 @@ var MARKET_LABELS = {
44080
44785
  closed: "Market Closed",
44081
44786
  holiday: "Market Holiday"
44082
44787
  };
44788
+ var REPLAY_LABEL = "Replay Mode";
44789
+ var REPLAY_BADGE_SVG = '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="8" style="fill: var(--vela-selected-bg)"/><path d="M11.7 4.75v6.5L7.2 8zM7.2 4.75v6.5L2.7 8z" style="fill: var(--vela-selected-fg); stroke: var(--vela-selected-fg); stroke-width: 0.8; stroke-linejoin: round"/></svg>';
44083
44790
  var MARKET_INKS = {
44084
44791
  open: "var(--vela-up)",
44085
44792
  pre: SESSION_PRE,
@@ -44118,6 +44825,9 @@ var Statusline = class {
44118
44825
  this.host = host;
44119
44826
  this.iconFor = iconFor;
44120
44827
  this.parts = { logo: true, name: true, market: true, ohlc: true, change: true };
44828
+ this.marketStatus = "open";
44829
+ /** The chart replays past bars — the badge shows the replay mode (see {@link setReplaying}). */
44830
+ this.replaying = false;
44121
44831
  /** The right-click action menu — present once a host wires it via {@link attachMenu}. */
44122
44832
  this.menu = null;
44123
44833
  this.menuHooks = null;
@@ -44169,8 +44879,13 @@ var Statusline = class {
44169
44879
  label: MARKET_LABELS.open,
44170
44880
  host
44171
44881
  });
44172
- this.marketEl = this.marketBubble.el;
44173
- this.marketEl.classList.add("vela-sl-market");
44882
+ this.replayBadge = doc.createElement("span");
44883
+ this.replayBadge.className = "vela-sl-replay-badge";
44884
+ this.replayBadge.innerHTML = REPLAY_BADGE_SVG;
44885
+ this.replayBadge.hidden = true;
44886
+ this.marketEl = doc.createElement("span");
44887
+ this.marketEl.className = "vela-sl-market";
44888
+ this.marketEl.append(this.marketBubble.el, this.replayBadge);
44174
44889
  this.ohlcEl = doc.createElement("span");
44175
44890
  this.ohlcEl.className = "vela-sl-ohlc";
44176
44891
  this.changeEl = doc.createElement("span");
@@ -44292,6 +45007,25 @@ var Statusline = class {
44292
45007
  /** Dress the market badge for a session state: its icon, tinted circle, and the
44293
45008
  * hover label. Callers with no session model leave the constructor's 'open'. */
44294
45009
  setMarketStatus(status) {
45010
+ this.marketStatus = status;
45011
+ this.dressBadge();
45012
+ }
45013
+ /** The chart entered or left a bar replay: the badge shows the replay mode meanwhile
45014
+ * (the session state of a past bar says nothing about the market now). */
45015
+ setReplaying(replaying) {
45016
+ if (replaying === this.replaying) return;
45017
+ this.replaying = replaying;
45018
+ this.dressBadge();
45019
+ }
45020
+ dressBadge() {
45021
+ this.marketBubble.el.hidden = this.replaying;
45022
+ this.replayBadge.hidden = !this.replaying;
45023
+ if (this.replaying) {
45024
+ this.marketEl.dataset.status = "replay";
45025
+ this.marketTip.setContent(REPLAY_LABEL);
45026
+ return;
45027
+ }
45028
+ const status = this.marketStatus;
44295
45029
  this.marketEl.dataset.status = status;
44296
45030
  const ink = MARKET_INKS[status];
44297
45031
  this.marketBubble.set({
@@ -44364,11 +45098,23 @@ var Statusline = class {
44364
45098
  this.lastBar = b;
44365
45099
  if (!this.hoverBar) this.render();
44366
45100
  }),
45101
+ // A replay cut or restore replaces the newest bar without a `bar` event.
45102
+ chart.on("replay:start", () => {
45103
+ this.lastBar = null;
45104
+ this.setReplaying(true);
45105
+ this.render();
45106
+ }),
45107
+ chart.on("replay:end", () => {
45108
+ this.lastBar = null;
45109
+ this.setReplaying(false);
45110
+ this.render();
45111
+ }),
44367
45112
  chart.renderer.onCrosshairMove((e) => {
44368
45113
  this.hoverBar = e.ohlc;
44369
45114
  this.render();
44370
45115
  })
44371
45116
  );
45117
+ this.setReplaying(chart.replay.state.active);
44372
45118
  this.render();
44373
45119
  }
44374
45120
  destroy() {
@@ -44609,26 +45355,26 @@ function isWeekday(ms, tz) {
44609
45355
  return false;
44610
45356
  }
44611
45357
  }
44612
- function deriveMarketStatus(now, w, tz, overnight = false) {
44613
- const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
45358
+ function deriveMarketStatus(now2, w, tz, overnight = false) {
45359
+ const within = (ws) => ws.find(([s, e]) => now2 >= s && now2 < e);
44614
45360
  if (within(w.regular)) return "open";
44615
45361
  const ext = within(w.extended);
44616
45362
  if (ext) {
44617
45363
  if (overnight) return "extended";
44618
- return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
45364
+ return w.regular.some(([s]) => s >= now2 && s < ext[1]) ? "pre" : "post";
44619
45365
  }
44620
- if (isWeekday(now, tz)) {
44621
- const today = civilDate(now, tz);
45366
+ if (isWeekday(now2, tz)) {
45367
+ const today = civilDate(now2, tz);
44622
45368
  if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
44623
45369
  }
44624
45370
  return "closed";
44625
45371
  }
44626
- function nextStatusBoundary(now, w) {
45372
+ function nextStatusBoundary(now2, w) {
44627
45373
  let next = null;
44628
45374
  for (const ws of [w.regular, w.extended]) {
44629
45375
  for (const [s, e] of ws) {
44630
45376
  for (const t of [s, e]) {
44631
- if (t > now && (next == null || t < next)) next = t;
45377
+ if (t > now2 && (next == null || t < next)) next = t;
44632
45378
  }
44633
45379
  }
44634
45380
  }
@@ -44667,8 +45413,8 @@ var MarketStatusTracker = class {
44667
45413
  return;
44668
45414
  }
44669
45415
  const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
44670
- const now = Date.now();
44671
- const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
45416
+ const now2 = Date.now();
45417
+ const range = { from: now2 - FETCH_BACK_MS, to: now2 + FETCH_AHEAD_MS };
44672
45418
  const [regular, extended] = await Promise.all([
44673
45419
  provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
44674
45420
  provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
@@ -44680,9 +45426,9 @@ var MarketStatusTracker = class {
44680
45426
  }
44681
45427
  const w = { regular, extended };
44682
45428
  const overnight = parseSessionSpec(si)?.overnight === true;
44683
- this.onStatus(deriveMarketStatus(now, w, tz, overnight));
44684
- const boundary = nextStatusBoundary(now, w);
44685
- const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
45429
+ this.onStatus(deriveMarketStatus(now2, w, tz, overnight));
45430
+ const boundary = nextStatusBoundary(now2, w);
45431
+ const delay = Math.min(boundary != null ? boundary - now2 : MAX_TIMER_MS, MAX_TIMER_MS);
44686
45432
  this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
44687
45433
  }
44688
45434
  arm(my, data, symbol, delay) {
@@ -44715,6 +45461,7 @@ var CSS22 = `
44715
45461
  left: var(--vela-toolbar-gutter, 0px);
44716
45462
  right: var(--vela-scale-gutter, 0px);
44717
45463
  display: flex;
45464
+ flex-direction: column;
44718
45465
  align-items: center;
44719
45466
  justify-content: center;
44720
45467
  overflow: hidden;
@@ -44728,6 +45475,14 @@ var CSS22 = `
44728
45475
  user-select: none;
44729
45476
  white-space: nowrap;
44730
45477
  }
45478
+ .vela-watermark-replay {
45479
+ display: inline-flex;
45480
+ align-items: center;
45481
+ gap: 0.25em;
45482
+ margin-top: 0.1em;
45483
+ font-size: 0.8em;
45484
+ }
45485
+ .vela-watermark [hidden] { display: none !important; }
44731
45486
  `;
44732
45487
  function watermarkFontPx(availPx, textPxAtMax) {
44733
45488
  if (textPxAtMax <= 0) return MAX_FONT_PX;
@@ -44738,6 +45493,10 @@ var Watermark = class {
44738
45493
  this.resizeObserver = null;
44739
45494
  /** The host's visibility preference (the persisted watermark toggle). */
44740
45495
  this.shown = true;
45496
+ /** The host's preference for the replay line (its own persisted toggle). */
45497
+ this.replayShown = true;
45498
+ /** The chart is replaying past bars. */
45499
+ this.replaying = false;
44741
45500
  /** A bar load is in flight with nothing painted — the loading affordance owns the
44742
45501
  * canvas, so the mark stays out of its way. Starts true: the FIRST `load:start`
44743
45502
  * fires during chart construction, before any subscriber can see it. */
@@ -44747,7 +45506,10 @@ var Watermark = class {
44747
45506
  this.el.className = "vela-watermark";
44748
45507
  this.el.dataset.velaScreenshot = "under";
44749
45508
  this.text = host.ownerDocument.createElement("span");
44750
- this.el.appendChild(this.text);
45509
+ this.replayLine = host.ownerDocument.createElement("span");
45510
+ this.replayLine.className = "vela-watermark-replay";
45511
+ this.replayLine.append(iconEl("replay", host.ownerDocument), host.ownerDocument.createTextNode("Replay"));
45512
+ this.el.append(this.text, this.replayLine);
44751
45513
  host.appendChild(this.el);
44752
45514
  if (typeof ResizeObserver !== "undefined") {
44753
45515
  this.resizeObserver = new ResizeObserver(() => this.fit());
@@ -44760,13 +45522,26 @@ var Watermark = class {
44760
45522
  this.shown = visible;
44761
45523
  this.sync();
44762
45524
  }
45525
+ /** Show/hide the replay line (the host's toggle); it only ever shows while replaying. */
45526
+ setReplayVisible(visible) {
45527
+ this.replayShown = visible;
45528
+ this.sync();
45529
+ }
45530
+ /** The chart entered or left a bar replay. */
45531
+ setReplaying(replaying) {
45532
+ this.replaying = replaying;
45533
+ this.sync();
45534
+ }
44763
45535
  /** Loading and the watermark never share the canvas — hidden while a load is up. */
44764
45536
  setLoading(loading) {
44765
45537
  this.loading = loading;
44766
45538
  this.sync();
44767
45539
  }
44768
45540
  sync() {
44769
- this.el.style.display = this.shown && !this.loading ? "" : "none";
45541
+ const replay = this.replayShown && this.replaying;
45542
+ this.text.hidden = !this.shown;
45543
+ this.replayLine.hidden = !replay;
45544
+ this.el.style.display = (this.shown || replay) && !this.loading ? "" : "none";
44770
45545
  }
44771
45546
  update(symbol, timeframe) {
44772
45547
  this.text.textContent = symbol ? `${parseSymbol(symbol).ticker} \xB7 ${timeframeLabel(timeframe)}` : "";
@@ -45355,8 +46130,12 @@ var ChartCell = class {
45355
46130
  this.indicatorValuesOn = seed.indicatorValues ?? true;
45356
46131
  if (!this.indicatorValuesOn) this.inner.renderer.set("indicatorValues", false);
45357
46132
  this.watermarkOn = seed.watermark ?? deps.watermark;
46133
+ this.replayWatermarkOn = seed.replayWatermark ?? true;
45358
46134
  this.watermark = deps.watermark ? new Watermark(this.host, symbol ?? "", seed.timeframe ?? "60") : null;
45359
46135
  if (!this.watermarkOn) this.watermark?.setVisible(false);
46136
+ if (!this.replayWatermarkOn) this.watermark?.setReplayVisible(false);
46137
+ this.inner.on("replay:start", () => this.watermark?.setReplaying(true));
46138
+ this.inner.on("replay:end", () => this.watermark?.setReplaying(false));
45360
46139
  this.statusline = deps.statusline ? new Statusline(this.host, symbol ?? "", (sym) => this.inner?.data.symbolIcon(sym)) : null;
45361
46140
  this.statusline?.setMeta(seed.timeframe ?? "60", this.state.provider ?? "");
45362
46141
  this.statusline?.onChart(this.inner);
@@ -45551,10 +46330,10 @@ var ChartCell = class {
45551
46330
  const chart = this.inner;
45552
46331
  const symbol = this.state.symbol;
45553
46332
  if (!chart || !symbol) return;
45554
- const now = Date.now();
46333
+ const now2 = Date.now();
45555
46334
  const requestedSpan = Math.max(this.state.bars ?? 1e3, this.rangeBars) * timeframeMs(this.state.timeframe ?? "60");
45556
46335
  const fallbackSpan = Number.isFinite(requestedSpan) ? Math.max(3 * 864e5, requestedSpan) : 3 * 864e5;
45557
- const range = chart.getVisibleRange() ?? { from: now - fallbackSpan, to: now };
46336
+ const range = chart.getVisibleRange() ?? { from: now2 - fallbackSpan, to: now2 };
45558
46337
  this.sessionShading.track(chart.data, symbol, { session: this.session, timeframe: this.timeframe, range });
45559
46338
  }
45560
46339
  /** The session-shade colors live in the renderer CONFIG (persisted with it, edited
@@ -45670,6 +46449,13 @@ var ChartCell = class {
45670
46449
  id: "visible",
45671
46450
  get: () => this.watermarkOn,
45672
46451
  set: (v) => this.setWatermarkVisible(v)
46452
+ },
46453
+ {
46454
+ kind: "toggle",
46455
+ label: "Replay watermark",
46456
+ id: "replay",
46457
+ get: () => this.replayWatermarkOn,
46458
+ set: (v) => this.setReplayWatermarkVisible(v)
45673
46459
  }
45674
46460
  ]
45675
46461
  };
@@ -45738,6 +46524,12 @@ var ChartCell = class {
45738
46524
  this.watermark?.setVisible(visible);
45739
46525
  this.deps.onStateDirty();
45740
46526
  }
46527
+ /** Show/hide the "Replay" line under this cell's watermark while it replays (persisted per cell). */
46528
+ setReplayWatermarkVisible(visible) {
46529
+ this.replayWatermarkOn = visible;
46530
+ this.watermark?.setReplayVisible(visible);
46531
+ this.deps.onStateDirty();
46532
+ }
45741
46533
  /** Show/hide this cell's indicator titles — the in-chart legend rows (persisted per cell). */
45742
46534
  setIndicatorTitlesVisible(visible) {
45743
46535
  this.indicatorTitlesOn = visible;
@@ -46211,6 +47003,7 @@ var ChartCell = class {
46211
47003
  if (!this.inner || this.destroyed) return;
46212
47004
  if (cs.priceStyle && cs.priceStyle !== this.priceStyle) this.setPriceStyle(cs.priceStyle);
46213
47005
  if (cs.watermark !== void 0 && cs.watermark !== this.watermarkOn) this.setWatermarkVisible(cs.watermark);
47006
+ if (cs.replayWatermark !== void 0 && cs.replayWatermark !== this.replayWatermarkOn) this.setReplayWatermarkVisible(cs.replayWatermark);
46214
47007
  if (cs.indicatorTitles !== void 0 && cs.indicatorTitles !== this.indicatorTitlesOn) this.setIndicatorTitlesVisible(cs.indicatorTitles);
46215
47008
  if (cs.indicatorValues !== void 0 && cs.indicatorValues !== this.indicatorValuesOn) this.setIndicatorValuesVisible(cs.indicatorValues);
46216
47009
  if (cs.rendererConfig != null) this.inner.renderer.applyConfig(cs.rendererConfig);
@@ -46245,6 +47038,7 @@ var ChartCell = class {
46245
47038
  ...live ? { symbol: live.symbol, provider: live.provider, timeframe: live.timeframe } : {},
46246
47039
  priceStyle: this.priceStyle,
46247
47040
  watermark: this.watermarkOn,
47041
+ replayWatermark: this.replayWatermarkOn,
46248
47042
  indicatorTitles: this.indicatorTitlesOn,
46249
47043
  indicatorValues: this.indicatorValuesOn,
46250
47044
  rendererConfig: this.inner?.renderer.getConfig() ?? void 0,
@@ -46309,6 +47103,7 @@ function buildContext(host) {
46309
47103
  setPriceStyle: (style) => host.active()?.setPriceStyle(style),
46310
47104
  openSymbolSearch: (query) => host.openSymbolSearch(query),
46311
47105
  togglePanel: (id, open2) => host.togglePanel(id, open2),
47106
+ dockStrip: (el) => host.dockStrip(el),
46312
47107
  host: host.root,
46313
47108
  toast: (message, kind) => host.toast(message, kind),
46314
47109
  addIndicator: (entry) => host.active()?.addExternalIndicator(entry),
@@ -46320,10 +47115,303 @@ function buildContext(host) {
46320
47115
  get activeCellId() {
46321
47116
  return host.active()?.id ?? "";
46322
47117
  },
46323
- setActiveCell: (id) => host.setActiveCell(id)
47118
+ setActiveCell: (id) => host.setActiveCell(id),
47119
+ replay: host.replay
46324
47120
  };
46325
47121
  }
46326
47122
 
47123
+ // src/workspace/WorkspaceReplay.ts
47124
+ var MAX_BARS_PER_STEP = 1e5;
47125
+ var WorkspaceReplay = class {
47126
+ constructor(host) {
47127
+ this.host = host;
47128
+ this.bus = new TypedEventBus();
47129
+ /** The running session: the shared replay time (every chart shows the bars closed by it). */
47130
+ this.session = null;
47131
+ this.playing = false;
47132
+ this.intervalMs = 1e3;
47133
+ this.timer = null;
47134
+ /** Cells whose replay a symbol switch ended — restarted at the shared time once their bars land. */
47135
+ this.rejoining = /* @__PURE__ */ new Set();
47136
+ this.cellSubs = /* @__PURE__ */ new Map();
47137
+ /** Inside a group step: chart events are the controller's own doing. */
47138
+ this.stepping = false;
47139
+ /** Inside `finish`: the charts' `replay:end` events are the controller's own doing. */
47140
+ this.stopping = false;
47141
+ /** Driving a chart's own timer: its play/pause events are not the user's. */
47142
+ this.muted = 0;
47143
+ /** A chart finished during a group step — the session ends right after it. */
47144
+ this.finishedDuringStep = false;
47145
+ for (const cell of host.cells()) this.watch(cell);
47146
+ this.offHost = host.onCells((e) => {
47147
+ if (e.kind === "created") this.onCellCreated(e.id);
47148
+ else if (e.kind === "destroyed") this.onCellDestroyed(e.id);
47149
+ });
47150
+ }
47151
+ /**
47152
+ * Enter replay on every cell (or seek, when already replaying) — paused. Resolves once
47153
+ * every chart shows its rewound history (a chart whose history does not reach that far
47154
+ * loads older bars first). A chart with nothing to replay after the shared time stays
47155
+ * as it is.
47156
+ */
47157
+ async start(opts) {
47158
+ const cells = this.host.cells();
47159
+ const ref = cells.find((c) => c.id === (opts.cell ?? this.host.activeId())) ?? cells[0];
47160
+ if (!ref) return;
47161
+ if (this.playing) this.pause();
47162
+ this.rejoining.clear();
47163
+ await ref.chart.replay.start({ from: opts.from });
47164
+ const cursor = ref.chart.replay.state.cursorTime;
47165
+ if (cursor == null) return;
47166
+ const session = { clock: barClose(cursor, ref.chart.market.timeframe) };
47167
+ this.session = session;
47168
+ await Promise.all(this.host.cells().filter((c) => c.id !== ref.id).map((c) => this.startCell(c, session.clock)));
47169
+ if (this.session !== session) return;
47170
+ this.syncPlayback();
47171
+ const s = this.activeChart()?.replay.state;
47172
+ this.bus.emit("replay:start", { cursorTime: s?.cursorTime ?? cursor, remaining: s?.remaining ?? 0 });
47173
+ }
47174
+ /** Reveal the next bar(s): the clock moves to the next bar close on any chart. Returns false when nothing is left. */
47175
+ step() {
47176
+ const solo = this.solo();
47177
+ return solo ? solo.replay.step() : this.stepGroup();
47178
+ }
47179
+ /** Reveal the next update — in a single-chart layout, the chart's `stepUpdate()` (tick replay); otherwise {@link step}. */
47180
+ stepUpdate() {
47181
+ const solo = this.solo();
47182
+ return solo ? solo.replay.stepUpdate() : this.stepGroup();
47183
+ }
47184
+ /** Advance one update every `intervalMs` (default: the last pace, initially 1000). Calling it again while playing changes the pace. */
47185
+ play(intervalMs2) {
47186
+ if (!this.session) return this;
47187
+ if (intervalMs2 !== void 0) {
47188
+ if (!(Number.isFinite(intervalMs2) && intervalMs2 > 0)) {
47189
+ console.warn(`[vela] workspace.replay.play(${intervalMs2}) ignored \u2014 the interval must be a positive number of ms`);
47190
+ return this;
47191
+ }
47192
+ this.intervalMs = intervalMs2;
47193
+ }
47194
+ this.playing = true;
47195
+ this.clearTimer();
47196
+ this.syncPlayback();
47197
+ this.bus.emit("replay:play", { intervalMs: this.intervalMs });
47198
+ return this;
47199
+ }
47200
+ pause() {
47201
+ if (!this.playing) return this;
47202
+ this.playing = false;
47203
+ this.syncPlayback();
47204
+ this.bus.emit("replay:pause", void 0);
47205
+ return this;
47206
+ }
47207
+ /** Leave replay on every chart: full history back, live updates resumed. */
47208
+ stop() {
47209
+ this.finish("stopped");
47210
+ return this;
47211
+ }
47212
+ /** `active`/`playing`/`intervalMs` for the whole workspace; the cursor fields read the ACTIVE cell's chart. */
47213
+ get state() {
47214
+ const s = this.session ? this.activeChart()?.replay.state : void 0;
47215
+ return {
47216
+ active: this.session !== null,
47217
+ playing: this.playing,
47218
+ cursorTime: s?.cursorTime ?? null,
47219
+ remaining: s?.remaining ?? 0,
47220
+ nextTime: s?.nextTime ?? null,
47221
+ intervalMs: this.intervalMs
47222
+ };
47223
+ }
47224
+ /** The active cell's replayable history (`chart.replay.bounds`). */
47225
+ get bounds() {
47226
+ return this.activeChart()?.replay.bounds ?? null;
47227
+ }
47228
+ on(event, handler) {
47229
+ return this.bus.on(event, handler);
47230
+ }
47231
+ destroy() {
47232
+ this.session = null;
47233
+ this.playing = false;
47234
+ this.clearTimer();
47235
+ this.offHost();
47236
+ for (const off of this.cellSubs.values()) off();
47237
+ this.cellSubs.clear();
47238
+ this.bus.clear();
47239
+ }
47240
+ // ── internals ──
47241
+ /** The one chart of a single-chart layout — it replays on its own, tick replay included. */
47242
+ solo() {
47243
+ const cells = this.host.cells();
47244
+ return cells.length === 1 ? cells[0].chart : null;
47245
+ }
47246
+ activeChart() {
47247
+ const cells = this.host.cells();
47248
+ return (cells.find((c) => c.id === this.host.activeId()) ?? cells[0])?.chart ?? null;
47249
+ }
47250
+ /** Rewind one chart to the shared time: every bar that closed by `clock`, none after. */
47251
+ async startCell(cell, clock) {
47252
+ const replay = cell.chart.replay;
47253
+ const from = lastOpenClosedBy(clock, cell.chart.market.timeframe);
47254
+ const bounds = replay.bounds;
47255
+ if (!bounds || bounds.last <= from) return;
47256
+ await replay.start({ from });
47257
+ }
47258
+ /** Move the clock to the next bar close on any chart; each chart reveals what closed by then. */
47259
+ stepGroup() {
47260
+ const session = this.session;
47261
+ if (!session) return false;
47262
+ const cells = this.host.cells();
47263
+ let next = Infinity;
47264
+ for (const c of cells) {
47265
+ const t = c.chart.replay.state.nextTime;
47266
+ if (t != null) next = Math.min(next, barClose(t, c.chart.market.timeframe));
47267
+ }
47268
+ if (!Number.isFinite(next)) return false;
47269
+ this.stepping = true;
47270
+ try {
47271
+ for (const c of cells) {
47272
+ const tf = c.chart.market.timeframe;
47273
+ for (let n = 0; n < MAX_BARS_PER_STEP; n += 1) {
47274
+ const t = c.chart.replay.state.nextTime;
47275
+ if (t == null || barClose(t, tf) > next) break;
47276
+ if (!c.chart.replay.step()) break;
47277
+ }
47278
+ }
47279
+ } finally {
47280
+ this.stepping = false;
47281
+ }
47282
+ session.clock = next;
47283
+ if (this.finishedDuringStep) {
47284
+ this.finishedDuringStep = false;
47285
+ this.finish("finished");
47286
+ return true;
47287
+ }
47288
+ const s = this.activeChart()?.replay.state;
47289
+ if (s?.cursorTime != null) this.bus.emit("replay:step", { cursorTime: s.cursorTime, remaining: s.remaining });
47290
+ return true;
47291
+ }
47292
+ /** Put the timers in the state `playing` asks for: a single chart runs its own (it paces
47293
+ * ticks), several run on the controller's clock. */
47294
+ syncPlayback() {
47295
+ const solo = this.solo();
47296
+ const run = this.playing && this.session !== null;
47297
+ this.muted += 1;
47298
+ try {
47299
+ for (const c of this.host.cells()) {
47300
+ if (c.chart === solo && run) {
47301
+ if (!c.chart.replay.state.playing || c.chart.replay.state.intervalMs !== this.intervalMs) c.chart.replay.play(this.intervalMs);
47302
+ } else if (c.chart.replay.state.playing) {
47303
+ c.chart.replay.pause();
47304
+ }
47305
+ }
47306
+ } finally {
47307
+ this.muted -= 1;
47308
+ }
47309
+ if (!run || solo) this.clearTimer();
47310
+ else if (this.timer == null) this.schedule();
47311
+ }
47312
+ schedule() {
47313
+ this.timer = setTimeout(() => {
47314
+ this.timer = null;
47315
+ if (!this.playing || !this.session || this.solo()) return;
47316
+ if (this.stepGroup() || this.rejoining.size > 0) {
47317
+ if (this.playing && this.session && this.timer == null) this.schedule();
47318
+ } else {
47319
+ this.pause();
47320
+ }
47321
+ }, this.intervalMs);
47322
+ }
47323
+ clearTimer() {
47324
+ if (this.timer != null) clearTimeout(this.timer);
47325
+ this.timer = null;
47326
+ }
47327
+ /** End the session on every chart (idempotent). */
47328
+ finish(reason) {
47329
+ const had = this.session !== null;
47330
+ this.session = null;
47331
+ this.playing = false;
47332
+ this.clearTimer();
47333
+ this.rejoining.clear();
47334
+ this.stopping = true;
47335
+ try {
47336
+ for (const c of this.host.cells()) c.chart.replay.stop();
47337
+ } finally {
47338
+ this.stopping = false;
47339
+ }
47340
+ if (had) this.bus.emit("replay:end", { reason });
47341
+ }
47342
+ watch(cell) {
47343
+ const { id, chart } = cell;
47344
+ const isActive = () => (this.host.activeId() ?? this.host.cells()[0]?.id) === id;
47345
+ const offs = [
47346
+ chart.on("replay:end", ({ reason }) => {
47347
+ if (!this.session || this.stopping) return;
47348
+ if (reason === "market") this.rejoining.add(id);
47349
+ else if (this.stepping) this.finishedDuringStep || (this.finishedDuringStep = reason === "finished");
47350
+ else this.finish(reason);
47351
+ }),
47352
+ chart.on("market:changed", () => {
47353
+ if (!this.rejoining.delete(id) || !this.session) return;
47354
+ void this.startCell(cell, this.session.clock).then(() => this.syncPlayback());
47355
+ }),
47356
+ chart.on("replay:step", (e) => {
47357
+ if (this.session && !this.stepping && isActive()) this.bus.emit("replay:step", e);
47358
+ }),
47359
+ chart.on("replay:tick", (e) => {
47360
+ if (this.session && isActive()) this.bus.emit("replay:tick", e);
47361
+ }),
47362
+ // A single chart runs its own timer: a pause it takes on its own (a seek) is the session's.
47363
+ chart.on("replay:pause", () => {
47364
+ if (!this.session || this.muted > 0 || !this.playing || chart !== this.solo()) return;
47365
+ this.playing = false;
47366
+ this.bus.emit("replay:pause", void 0);
47367
+ })
47368
+ ];
47369
+ this.cellSubs.set(id, () => {
47370
+ for (const off of offs) off();
47371
+ });
47372
+ }
47373
+ onCellCreated(id) {
47374
+ const cell = this.host.cells().find((c) => c.id === id);
47375
+ if (!cell) return;
47376
+ this.watch(cell);
47377
+ const session = this.session;
47378
+ if (!session) return;
47379
+ this.rejoining.add(id);
47380
+ void cell.chart.ready().then(async () => {
47381
+ if (this.session !== session || !this.rejoining.delete(id)) return;
47382
+ await this.startCell(cell, session.clock);
47383
+ this.syncPlayback();
47384
+ });
47385
+ this.syncPlayback();
47386
+ }
47387
+ onCellDestroyed(id) {
47388
+ this.cellSubs.get(id)?.();
47389
+ this.cellSubs.delete(id);
47390
+ this.rejoining.delete(id);
47391
+ this.syncPlayback();
47392
+ }
47393
+ };
47394
+ function barClose(open2, timeframe) {
47395
+ const months = monthSpan(timeframe);
47396
+ if (months > 0) {
47397
+ const d = new Date(open2);
47398
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
47399
+ }
47400
+ return open2 + timeframeToMs(timeframe ?? "60");
47401
+ }
47402
+ function lastOpenClosedBy(time, timeframe) {
47403
+ const months = monthSpan(timeframe);
47404
+ if (months > 0) {
47405
+ const d = new Date(time);
47406
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
47407
+ }
47408
+ return time - timeframeToMs(timeframe ?? "60");
47409
+ }
47410
+ function monthSpan(timeframe) {
47411
+ const m = /^(\d*)M$/.exec((timeframe ?? "").trim());
47412
+ return m ? Number(m[1] || 1) : 0;
47413
+ }
47414
+
46327
47415
  // src/workspace/layouts.ts
46328
47416
  var registry5 = /* @__PURE__ */ new Map();
46329
47417
  function registerLayout(def) {
@@ -46622,6 +47710,8 @@ var CSS24 = `
46622
47710
  .vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
46623
47711
  .vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
46624
47712
  .vela-ws-toolbar { position: relative; flex: none; }
47713
+ .vela-ws-strips { position: relative; flex: none; display: flex; flex-direction: column; min-width: 0; }
47714
+ .vela-ws-strips:empty { display: none; }
46625
47715
  .vela-ws-grid { position: relative; flex: 1 1 auto; min-width: 0; display: grid; gap: ${GAP_PX}px; background: var(--vela-border-soft); }
46626
47716
  .vela-cell { background: var(--vela-bg); position: relative; }
46627
47717
  /* Active-cell highlight: an overlay ring ABOVE the chart's own canvas stack (a plain
@@ -46793,6 +47883,20 @@ var VelaWorkspace = class {
46793
47883
  const hostEl = typeof container === "string" ? document.querySelector(container) : container;
46794
47884
  if (!hostEl) throw new Error(`VelaWorkspace: container not found: ${String(container)}`);
46795
47885
  this.opts = opts;
47886
+ this.replay = new WorkspaceReplay({
47887
+ cells: () => this.cells(),
47888
+ activeId: () => this.activeId,
47889
+ onCells: (handler) => {
47890
+ const offs = [
47891
+ this.events.on("cell:created", ({ id }) => handler({ kind: "created", id })),
47892
+ this.events.on("cell:destroyed", ({ id }) => handler({ kind: "destroyed", id })),
47893
+ this.events.on("cell:active", ({ id }) => handler({ kind: "active", id }))
47894
+ ];
47895
+ return () => {
47896
+ for (const off of offs) off();
47897
+ };
47898
+ }
47899
+ });
46796
47900
  this.persistKey = opts.persist === void 0 || opts.persist === false ? null : opts.persist === true ? "vela-workspace" : opts.persist;
46797
47901
  this.storage = opts.storage ?? localStorageAdapter();
46798
47902
  let boot = null;
@@ -46930,6 +48034,9 @@ var VelaWorkspace = class {
46930
48034
  this.dock.refresh();
46931
48035
  if (boot?.panels) this.dock.applyState(boot.panels);
46932
48036
  this.root.appendChild(main);
48037
+ this.stripsEl = doc.createElement("div");
48038
+ this.stripsEl.className = "vela-ws-strips";
48039
+ this.root.appendChild(this.stripsEl);
46933
48040
  this.toastHost = new Toast(this.gridEl);
46934
48041
  const attribution = rendererDefaults().attribution;
46935
48042
  if (attribution !== false) {
@@ -47164,9 +48271,16 @@ var VelaWorkspace = class {
47164
48271
  setActiveCell: (id) => this.setActiveCell(id),
47165
48272
  openSymbolSearch: (query) => this.symbolPicker.open(query ?? ""),
47166
48273
  togglePanel: (id, open2) => this.dock.toggle(id, open2),
48274
+ dockStrip: (el) => {
48275
+ this.stripsEl.appendChild(el);
48276
+ return () => {
48277
+ if (el.parentElement === this.stripsEl) el.remove();
48278
+ };
48279
+ },
47167
48280
  root: this.root,
47168
48281
  toast: (message, kind) => this.toastHost.show(message, kind),
47169
- stateDirty: () => this.markStateDirty()
48282
+ stateDirty: () => this.markStateDirty(),
48283
+ replay: this.replay
47170
48284
  });
47171
48285
  }
47172
48286
  /** Re-project contributed topbar actions + side panels, and mount late-registered attachments. */
@@ -47474,6 +48588,7 @@ var VelaWorkspace = class {
47474
48588
  if (this.destroyed) return;
47475
48589
  this.flushPendingState();
47476
48590
  this.destroyed = true;
48591
+ this.replay.destroy();
47477
48592
  if (this.persistKey !== null && typeof window !== "undefined") window.removeEventListener("beforeunload", this.onUnload);
47478
48593
  this.resizeObserver?.disconnect();
47479
48594
  this.splitters.destroy();
@@ -48240,14 +49355,13 @@ var VelaWorkspace = class {
48240
49355
  panels: () => has("panels") ? [...this.dock.list()] : [],
48241
49356
  onTogglePanel: (id) => this.dock.toggle(id),
48242
49357
  ...has("alerts") ? { alerts: () => this.alerts.map((a) => ({ title: `${a.source} \xB7 ${a.title}`, message: a.message, time: a.time })) } : {},
48243
- // Left-aligned actions have their own bottom-bar stop — only the rest
48244
- // lands in the drawer, or every left action would appear twice. Built-in-id
48245
- // actions are slot OVERRIDES: they reach the drawer through the slot's own
48246
- // routed button (screenshot) or stop (indicators), never as an extra row.
48247
- actions: () => {
48248
- const builtin = new Set(TOPBAR_BUILTIN_IDS);
48249
- return widgetActions("topbar", this.context()).filter((a) => a.align !== "left" && !builtin.has(a.id)).map((a) => ({ label: a.label, icon: a.icon, run: () => a.run(this.context()) }));
48250
- },
49358
+ // Actions placed on the bottom bar (left-aligned by default) have their stop
49359
+ // there — only the menu-placed ones land in the drawer, or they would appear
49360
+ // twice: left (primary) ones with the primary rows, right ones at the end.
49361
+ // Built-in-id actions are slot OVERRIDES: they reach the drawer through the
49362
+ // slot's own routed button (screenshot) or stop (indicators), never as a row.
49363
+ primaryActions: () => this.drawerActions("left"),
49364
+ actions: () => this.drawerActions("right"),
48251
49365
  // The desktop layout dropdown's whole surface — the grid canvas, the
48252
49366
  // non-canvas presets and the sync switches — relocated into the kebab
48253
49367
  // drawer (the topbar is hidden on mobile). Same reads as the topbar block;
@@ -48272,6 +49386,11 @@ var VelaWorkspace = class {
48272
49386
  }));
48273
49387
  this.moreDrawer.open();
48274
49388
  }
49389
+ /** The contributed topbar actions the mobile menu lists, from one cluster. */
49390
+ drawerActions(cluster) {
49391
+ const builtin = new Set(TOPBAR_BUILTIN_IDS);
49392
+ return widgetActions("topbar", this.context()).filter((a) => mobilePlacement(a) === "menu" && a.align === "left" === (cluster === "left") && !builtin.has(a.id)).map((a) => ({ label: a.label, icon: a.icon, run: () => a.run(this.context()) }));
49393
+ }
48275
49394
  openTimezoneDrawer() {
48276
49395
  this.timezoneDrawer ?? (this.timezoneDrawer = new TimezoneDrawer({
48277
49396
  host: this.root,