@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
@@ -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 = `
@@ -4963,6 +5222,9 @@ function topbarHas(comp, id) {
4963
5222
  }
4964
5223
 
4965
5224
  // src/widget/contributions.ts
5225
+ function mobilePlacement(action) {
5226
+ return action.mobile ?? (action.align === "left" ? "bar" : "menu");
5227
+ }
4966
5228
  var registry3 = /* @__PURE__ */ new Map();
4967
5229
  var attachments = /* @__PURE__ */ new Map();
4968
5230
  var panels = /* @__PURE__ */ new Map();
@@ -5150,6 +5412,22 @@ function priceStyleIds() {
5150
5412
  for (const t of chartTypes()) if (!out.includes(t.id)) out.push(t.id);
5151
5413
  return out;
5152
5414
  }
5415
+ function sanitizeCrosshairOverride(value) {
5416
+ if (!value || typeof value !== "object") return null;
5417
+ const v = value;
5418
+ const out = {};
5419
+ if (isBool(v.vertical)) out.vertical = v.vertical;
5420
+ if (isBool(v.horizontal)) out.horizontal = v.horizontal;
5421
+ if (isColor(v.color)) out.color = v.color;
5422
+ if (isNum(v.width)) out.width = Math.max(0.5, Math.min(8, v.width));
5423
+ if (isLineStyle(v.style)) out.style = v.style;
5424
+ if (isNum(v.opacity)) out.opacity = clampOpacity(v.opacity);
5425
+ const shade = v.shadeRight;
5426
+ if (shade && typeof shade === "object" && isColor(shade.color)) {
5427
+ out.shadeRight = isNum(shade.opacity) ? { color: shade.color, opacity: clampOpacity(shade.opacity) } : { color: shade.color };
5428
+ }
5429
+ return Object.keys(out).length > 0 ? out : null;
5430
+ }
5153
5431
  function isColor(v) {
5154
5432
  return typeof v === "string" && v.trim().length > 0;
5155
5433
  }
@@ -6037,8 +6315,8 @@ var Topbar = class {
6037
6315
  // src/core/util/wall-clock.ts
6038
6316
  var BOUNDARY_SLACK_MS = 5;
6039
6317
  var SecondClock = class _SecondClock {
6040
- constructor(now = () => Date.now()) {
6041
- this.now = now;
6318
+ constructor(now2 = () => Date.now()) {
6319
+ this.now = now2;
6042
6320
  this.subs = /* @__PURE__ */ new Set();
6043
6321
  this.timer = null;
6044
6322
  }
@@ -6054,15 +6332,15 @@ var SecondClock = class _SecondClock {
6054
6332
  };
6055
6333
  }
6056
6334
  /** Milliseconds from `now` to just past the next second boundary. */
6057
- static delayToNextSecond(now) {
6058
- const intoSecond = (now % 1e3 + 1e3) % 1e3;
6335
+ static delayToNextSecond(now2) {
6336
+ const intoSecond = (now2 % 1e3 + 1e3) % 1e3;
6059
6337
  return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
6060
6338
  }
6061
6339
  arm() {
6062
6340
  this.timer = setTimeout(() => {
6063
6341
  this.timer = null;
6064
- const now = this.now();
6065
- for (const cb of this.subs) cb(now);
6342
+ const now2 = this.now();
6343
+ for (const cb of this.subs) cb(now2);
6066
6344
  if (this.subs.size > 0) this.arm();
6067
6345
  }, _SecondClock.delayToNextSecond(this.now()));
6068
6346
  }
@@ -15288,16 +15566,16 @@ var MobileBar = class {
15288
15566
  host.appendChild(this.el);
15289
15567
  this.renderActions();
15290
15568
  }
15291
- /** Re-project the left-aligned contributed actions as icon-only stops in the
15292
- * indicators slot (call after registrations change). Right-aligned actions stay
15293
- * in the three-dots drawer — a primary stop is what `align: 'left'` opts into. */
15569
+ /** Re-project the contributed actions placed on the bar (left-aligned by default —
15570
+ * see `mobilePlacement`) as icon-only stops in the indicators slot (call after
15571
+ * registrations change). The rest are three-dots drawer rows. */
15294
15572
  renderActions() {
15295
15573
  const ctx = this.opts.getContext?.();
15296
15574
  if (!ctx) return;
15297
15575
  const doc = this.el.ownerDocument;
15298
15576
  this.actionsHost.replaceChildren();
15299
15577
  const builtin = new Set(TOPBAR_BUILTIN_IDS);
15300
- for (const action of widgetActions("topbar", ctx).filter((a) => a.align === "left" && !builtin.has(a.id))) {
15578
+ for (const action of widgetActions("topbar", ctx).filter((a) => mobilePlacement(a) === "bar" && !builtin.has(a.id))) {
15301
15579
  const b = doc.createElement("button");
15302
15580
  b.className = "vela-mb-item";
15303
15581
  b.setAttribute("aria-label", action.label);
@@ -15853,6 +16131,7 @@ var MoreDrawer = class {
15853
16131
  const value = shape ? `${shape.cols} \xD7 ${shape.rows}` : this.opts.layout.presets().find((p) => p.checked)?.label;
15854
16132
  list.appendChild(this.row(doc, "Layout", { icon: "layout", value, chevron: true, onClick: () => this.show("layout") }));
15855
16133
  }
16134
+ for (const act of this.opts.primaryActions?.() ?? []) list.appendChild(this.actionRow(doc, act));
15856
16135
  for (const panel of this.opts.panels()) {
15857
16136
  list.appendChild(
15858
16137
  this.row(doc, panel.title, {
@@ -15868,19 +16147,19 @@ var MoreDrawer = class {
15868
16147
  const alertCount = this.opts.alerts().length;
15869
16148
  list.appendChild(this.row(doc, "Alerts", { icon: "bell", value: alertCount > 0 ? String(alertCount) : void 0, chevron: true, onClick: () => this.show("alerts") }));
15870
16149
  }
15871
- for (const act of this.opts.actions()) {
15872
- list.appendChild(
15873
- this.row(doc, act.label, {
15874
- icon: act.icon,
15875
- onClick: () => {
15876
- act.run();
15877
- this.drawer.hide();
15878
- }
15879
- })
15880
- );
15881
- }
16150
+ for (const act of this.opts.actions()) list.appendChild(this.actionRow(doc, act));
15882
16151
  this.drawer.body.appendChild(list);
15883
16152
  }
16153
+ /** A contributed action's row: runs it and closes the drawer. */
16154
+ actionRow(doc, act) {
16155
+ return this.row(doc, act.label, {
16156
+ icon: act.icon,
16157
+ onClick: () => {
16158
+ act.run();
16159
+ this.drawer.hide();
16160
+ }
16161
+ });
16162
+ }
15884
16163
  renderStyle(doc) {
15885
16164
  const list = doc.createElement("div");
15886
16165
  list.className = "vela-md-list";
@@ -18254,6 +18533,7 @@ function sanitizeCell(raw) {
18254
18533
  if (typeof c.bars === "number" && Number.isFinite(c.bars) && c.bars > 0) out.bars = c.bars;
18255
18534
  if (c.session === "regular" || c.session === "extended") out.session = c.session;
18256
18535
  if (typeof c.watermark === "boolean") out.watermark = c.watermark;
18536
+ if (typeof c.replayWatermark === "boolean") out.replayWatermark = c.replayWatermark;
18257
18537
  if (typeof c.indicatorTitles === "boolean") out.indicatorTitles = c.indicatorTitles;
18258
18538
  if (typeof c.indicatorValues === "boolean") out.indicatorValues = c.indicatorValues;
18259
18539
  if (c.rendererConfig != null && typeof c.rendererConfig === "object") out.rendererConfig = c.rendererConfig;
@@ -19006,6 +19286,7 @@ var CHUNK_BARS = 1e4;
19006
19286
  var FIRST_PAINT_BARS = 20;
19007
19287
  var GAP_FACTOR = 1.5;
19008
19288
  var HEAL_COOLDOWN_MS = 5e3;
19289
+ var MIN_TICK_UPDATE_MS = 16;
19009
19290
  var EngineOrchestrator = class _EngineOrchestrator {
19010
19291
  constructor(container, renderer, feed, engines, config, dataControl) {
19011
19292
  this.renderer = renderer;
@@ -19058,6 +19339,32 @@ var EngineOrchestrator = class _EngineOrchestrator {
19058
19339
  this.lastHealAt = 0;
19059
19340
  /** The `visibilitychange` catch-up listener (removed on destroy). */
19060
19341
  this.onVisible = null;
19342
+ // ── bar replay: `rawBars` holds the history up to the cursor, the rest waits here ──
19343
+ /** Bars still hidden right of the replay cursor, oldest first; null ≡ replay off. */
19344
+ this.replayQueue = null;
19345
+ this.replayTimer = null;
19346
+ this.replayPlaying = false;
19347
+ this.replayIntervalMs = 1e3;
19348
+ /** The depth (`market.bars`) before a replay deepened the history — restored when it ends. */
19349
+ this.replayDepth = null;
19350
+ /** A start is loading older history before it can cut; `seek` = a replay was running
19351
+ * (and left silently to deepen), so abandoning the load must announce its end. */
19352
+ this.replayLoading = null;
19353
+ /** A replay carried over a timeframe/session switch, between the old series' silent end
19354
+ * and the cut of the new one: still ACTIVE for the outside (`replayState`), resumed
19355
+ * at `revealedEnd` once the new bars land, unless stopped (`cancelled`) meanwhile. */
19356
+ this.replayCarrying = null;
19357
+ /** Intrabar prices for the bars replay reveals — null ≡ whole bars. */
19358
+ this.replayTickSource = null;
19359
+ /** The bar being revealed tick by tick: its stored form (what it settles on), its ticks,
19360
+ * the next tick to apply, the candle built so far, the volume each tick adds when the
19361
+ * ticks carry none, and its pacing (ticks per update, ms between updates — one play
19362
+ * interval per tick — and the clock time tick 0 is due at, so late timers catch up). */
19363
+ this.replayForming = null;
19364
+ /** Tick requests by bar time, shared by prefetch and playback; `ticks` once they landed. */
19365
+ this.replayTickFetches = /* @__PURE__ */ new Map();
19366
+ /** Aborts the tick requests in flight — renewed whenever the replay moves on. */
19367
+ this.replayTickAbort = null;
19061
19368
  /** What the last applied bar did: refine the open candle, or open a new one (which
19062
19369
  * settles the previous). Read by the `script:run` cause attribution. */
19063
19370
  this.barCause = "tick";
@@ -19281,6 +19588,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
19281
19588
  const paint = (bars, final) => {
19282
19589
  if (this.generation !== gen || !final && bars.length === 0) return;
19283
19590
  if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
19591
+ if (painted && this.replayQueue) {
19592
+ this.mergeHistoryIntoReplay(bars);
19593
+ return;
19594
+ }
19284
19595
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
19285
19596
  if (!painted && bars.length > 0) {
19286
19597
  painted = true;
@@ -19423,6 +19734,36 @@ var EngineOrchestrator = class _EngineOrchestrator {
19423
19734
  // exactly like a timeframe change; the cache keys the sessions apart.
19424
19735
  next.session !== void 0 && next.session !== m.session || next.data !== void 0;
19425
19736
  const depthChanged = next.bars !== void 0 && next.bars !== m.bars;
19737
+ const sameClock = identityChanged && next.data === void 0 && (next.symbol === void 0 || next.symbol === m.symbol) && (next.timeframe !== void 0 || next.session !== void 0);
19738
+ const pending = identityChanged ? this.replayCarrying : null;
19739
+ if (pending) {
19740
+ this.replayCarrying = null;
19741
+ if (!sameClock) {
19742
+ pending.cancelled = true;
19743
+ this.events.emit("replay:end", { reason: "market" });
19744
+ }
19745
+ }
19746
+ 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;
19747
+ if (pending) pending.cancelled = true;
19748
+ if (replayCarry) this.replayCarrying = replayCarry;
19749
+ if (replayCarry && this.replayQueue) {
19750
+ this.endReplay(null, { restore: false, resume: false });
19751
+ const depth = this.replayDepth;
19752
+ this.replayDepth = null;
19753
+ if (depth && next.bars === void 0) {
19754
+ if (depth.bars === void 0) delete m.bars;
19755
+ else m.bars = depth.bars;
19756
+ }
19757
+ }
19758
+ if (this.replayQueue && (identityChanged || depthChanged)) this.endReplay("market", { restore: !identityChanged, resume: false });
19759
+ if (this.replayLoading && (identityChanged || depthChanged)) {
19760
+ const depth = this.replayDepth;
19761
+ this.abandonReplayLoad("market");
19762
+ if (depth && next.bars === void 0) {
19763
+ if (depth.bars === void 0) delete m.bars;
19764
+ else m.bars = depth.bars;
19765
+ }
19766
+ }
19426
19767
  const depthOnly = depthChanged && !identityChanged && this.rawBars.length > 0 && !m.data?.length && ((next.bars ?? 0) <= this.rawBars.length || typeof this.feed.loadRange === "function");
19427
19768
  if (!identityChanged && !depthChanged) {
19428
19769
  if (typeof next.visibleRange === "string") this.setVisibleRangePreset(next.visibleRange);
@@ -19480,15 +19821,20 @@ var EngineOrchestrator = class _EngineOrchestrator {
19480
19821
  const from = bars[Math.max(0, idx - (carriedCount - 1))].time;
19481
19822
  this.renderer.setVisibleRange({ from, to: Math.max(carried.right, bars[idx].time + 1) });
19482
19823
  }
19483
- if (!depthOnly) {
19824
+ const carry = replayCarry && !replayCarry.cancelled ? replayCarry : null;
19825
+ const carryFrom = carry ? carry.revealedEnd - this.barIntervalMs() : 0;
19826
+ const carryNow = carry != null && this.rawBars.length > 0 && carryFrom >= this.rawBars[0].time;
19827
+ const replayBack = carryNow && this.resumeCarriedReplay(this.replayStart({ from: carryFrom }), carry);
19828
+ if (!depthOnly && !replayBack) {
19484
19829
  this.restartNativeIndicators();
19485
19830
  this.restartChartTypeEngine();
19486
19831
  this.reexecuteIndicators();
19487
19832
  }
19488
- this.startLive();
19833
+ if (!replayBack) this.startLive();
19489
19834
  if (identityChanged) {
19490
19835
  this.events.emit("market:changed", { symbol: m.symbol ?? "TEST", timeframe: m.timeframe ?? "60", prev: prev2 });
19491
19836
  }
19837
+ if (carry && !carryNow) this.resumeCarriedReplay(this.replayStart({ from: carryFrom }), carry);
19492
19838
  }
19493
19839
  /**
19494
19840
  * Restart every native indicator over the new market: a `NativeIndicatorContext`
@@ -19612,6 +19958,397 @@ var EngineOrchestrator = class _EngineOrchestrator {
19612
19958
  historyComplete() {
19613
19959
  return this.historyCompletePromise;
19614
19960
  }
19961
+ // ── bar replay ──────────────────────────────────────────────
19962
+ /**
19963
+ * Enter replay — or seek, when already replaying — keeping every bar opened at or before
19964
+ * `opts.from` and queueing the rest. A `from` inside the loaded bars cuts at once, even
19965
+ * while older history is still streaming in: what lands later joins the tape's head
19966
+ * (see {@link mergeHistoryIntoReplay}) and leaves the cut alone. An older `from` waits
19967
+ * for that load, then deepens if it still has to. Consumers restart over the cut exactly
19968
+ * as on a market switch — their models describe bars that are now hidden — and the
19969
+ * active chart-type engine is rebuilt without live data.
19970
+ */
19971
+ async replayStart(opts) {
19972
+ if (this.rawBars.length === 0) {
19973
+ console.warn("[vela] chart.replay.start() ignored \u2014 the chart has no bars yet");
19974
+ return;
19975
+ }
19976
+ let gen = this.generation;
19977
+ if (opts.from < this.rawBars[0].time) {
19978
+ await this.historyCompletePromise;
19979
+ if (this.generation !== gen || this.rawBars.length === 0) return;
19980
+ }
19981
+ const oldest = this.rawBars[0].time;
19982
+ if (opts.from < oldest && this.canHeal() && typeof this.feed.loadRange === "function") {
19983
+ const held = this.rawBars.length + (this.replayQueue?.length ?? 0);
19984
+ const bars = held + Math.ceil((oldest - opts.from) / this.barIntervalMs()) + 1;
19985
+ const seek = this.replayQueue !== null;
19986
+ if (seek) this.endReplay(null, { restore: true, resume: false });
19987
+ this.replayDepth ?? (this.replayDepth = { bars: this.config.market.bars });
19988
+ const deepened = this.setMarket({ bars });
19989
+ this.replayLoading = { seek };
19990
+ await deepened;
19991
+ gen = this.generation;
19992
+ await this.historyCompletePromise;
19993
+ if (!this.replayLoading || this.generation !== gen || this.rawBars.length === 0) return;
19994
+ this.replayLoading = null;
19995
+ }
19996
+ this.settleFormingBar();
19997
+ this.dropTickFetches();
19998
+ const tape = this.replayQueue ? [...this.rawBars, ...this.replayQueue] : this.rawBars;
19999
+ let cut = 0;
20000
+ while (cut < tape.length && tape[cut].time <= opts.from) cut += 1;
20001
+ cut = Math.max(1, cut);
20002
+ if (cut >= tape.length) {
20003
+ console.warn("[vela] chart.replay.start() ignored \u2014 no bar opens after the requested time");
20004
+ return;
20005
+ }
20006
+ const wasPlaying = this.replayPlaying;
20007
+ this.clearReplayTimer();
20008
+ if (!this.replayQueue) this.stopLive();
20009
+ this.replayQueue = tape.slice(cut);
20010
+ this.setBarSeries(tape.slice(0, cut));
20011
+ this.restartBarConsumers({ blank: true });
20012
+ if (wasPlaying) this.events.emit("replay:pause", void 0);
20013
+ this.events.emit("replay:start", { cursorTime: tape[cut - 1].time, remaining: this.replayQueue.length });
20014
+ this.prefetchTicks();
20015
+ }
20016
+ /** Reveal the next queued bar through the live-bar path — or complete the bar forming
20017
+ * tick by tick; the last one ends the replay. */
20018
+ replayStep() {
20019
+ if (this.replayForming) {
20020
+ this.finishFormingBar();
20021
+ return true;
20022
+ }
20023
+ const queue = this.replayQueue;
20024
+ const bar = queue?.shift();
20025
+ if (!queue || !bar) return false;
20026
+ this.replayTickFetches.delete(bar.time);
20027
+ this.applyBar(bar);
20028
+ this.completeReveal(bar);
20029
+ this.prefetchTicks();
20030
+ return true;
20031
+ }
20032
+ /**
20033
+ * Follow up a replay carried onto a new series of the same symbol (another timeframe or
20034
+ * session). The cut asked for keeps the bars that CLOSED by the end of what was revealed —
20035
+ * every finer bar of the last revealed one, never a coarser bar still open at that point
20036
+ * (no look-ahead). Once it settles: playback resumes if it was on; if the replay did not
20037
+ * come back (nothing left after that point, or a later switch took over), it ends as a
20038
+ * market switch would. Returns whether the replay is back already — a start with no
20039
+ * history to wait for cuts synchronously (an async function runs up to its first await).
20040
+ */
20041
+ resumeCarriedReplay(start, carry) {
20042
+ if (this.replayQueue && this.replayCarrying === carry) this.replayCarrying = null;
20043
+ void start.then(() => {
20044
+ if (this.replayCarrying === carry) this.replayCarrying = null;
20045
+ if (carry.cancelled) return;
20046
+ if (!this.replayQueue) this.events.emit("replay:end", { reason: "market" });
20047
+ else if (carry.playing) this.replayPlay();
20048
+ });
20049
+ return this.replayQueue !== null;
20050
+ }
20051
+ replayStepUpdate() {
20052
+ if (!this.replayQueue) return false;
20053
+ if (this.replayForming) {
20054
+ this.applyReplayTicks(1);
20055
+ return true;
20056
+ }
20057
+ const bar = this.replayQueue[0];
20058
+ if (!bar) return false;
20059
+ const entry = this.replayTickSource ? this.requestTicks(bar) : null;
20060
+ if (entry && !entry.ticks) {
20061
+ const epoch = this.replayTickAbort;
20062
+ void entry.done.then(() => {
20063
+ if (!this.replayPlaying && !this.replayForming && this.replayTickAbort === epoch && this.replayQueue?.[0] === bar) this.replayStepUpdate();
20064
+ });
20065
+ return true;
20066
+ }
20067
+ if (!entry?.ticks?.length) return this.replayStep();
20068
+ this.openFormingBar(entry.ticks, 1);
20069
+ return true;
20070
+ }
20071
+ replaySetTicks(source) {
20072
+ this.finishFormingBar();
20073
+ this.dropTickFetches();
20074
+ this.replayTickSource = source;
20075
+ this.prefetchTicks();
20076
+ }
20077
+ replayPlay(intervalMs2) {
20078
+ if (!this.replayQueue) return;
20079
+ if (intervalMs2 !== void 0) {
20080
+ if (!(Number.isFinite(intervalMs2) && intervalMs2 > 0)) {
20081
+ console.warn(`[vela] chart.replay.play(${intervalMs2}) ignored \u2014 the interval must be a positive number of ms`);
20082
+ return;
20083
+ }
20084
+ this.replayIntervalMs = intervalMs2;
20085
+ }
20086
+ this.clearReplayTimer();
20087
+ this.replayPlaying = true;
20088
+ if (this.replayForming) this.paceFormingBar(this.replayForming);
20089
+ this.scheduleReplayStep(this.replayForming?.delayMs);
20090
+ this.events.emit("replay:play", { intervalMs: this.replayIntervalMs });
20091
+ }
20092
+ replayPause() {
20093
+ if (!this.replayPlaying) return;
20094
+ this.clearReplayTimer();
20095
+ this.events.emit("replay:pause", void 0);
20096
+ }
20097
+ replayStop() {
20098
+ const carrying = this.replayCarrying;
20099
+ if (carrying) {
20100
+ carrying.cancelled = true;
20101
+ this.replayCarrying = null;
20102
+ this.events.emit("replay:end", { reason: "stopped" });
20103
+ }
20104
+ if (this.replayLoading) {
20105
+ const depth = this.replayDepth;
20106
+ this.abandonReplayLoad("stopped");
20107
+ void this.setMarket({ bars: depth?.bars ?? 500 });
20108
+ return;
20109
+ }
20110
+ this.endReplay("stopped", { restore: true, resume: true });
20111
+ }
20112
+ abandonReplayLoad(reason) {
20113
+ const loading = this.replayLoading;
20114
+ if (!loading) return;
20115
+ this.replayLoading = null;
20116
+ this.replayDepth = null;
20117
+ if (loading.seek) this.events.emit("replay:end", { reason });
20118
+ }
20119
+ replayState() {
20120
+ const active = this.replayQueue !== null;
20121
+ return {
20122
+ active: active || this.replayCarrying !== null,
20123
+ // a replay carried over a switch stays on while the new bars load
20124
+ playing: this.replayCarrying?.playing ?? this.replayPlaying,
20125
+ cursorTime: active ? this.rawBars[this.rawBars.length - 1]?.time ?? null : null,
20126
+ remaining: this.replayQueue?.length ?? 0,
20127
+ nextTime: this.replayForming?.bar.time ?? this.replayQueue?.[0]?.time ?? null,
20128
+ intervalMs: this.replayIntervalMs
20129
+ };
20130
+ }
20131
+ replayBounds() {
20132
+ const first = this.rawBars[0];
20133
+ const last = this.replayQueue?.[this.replayQueue.length - 1] ?? this.rawBars[this.rawBars.length - 1];
20134
+ return first && last ? { first: first.time, last: last.time } : null;
20135
+ }
20136
+ scheduleReplayStep(delayMs = this.replayIntervalMs) {
20137
+ this.replayTimer = setTimeout(() => {
20138
+ this.replayTimer = null;
20139
+ this.replayAdvance();
20140
+ }, delayMs);
20141
+ }
20142
+ /** One beat of timed playback: the forming bar's next ticks, else the next bar — whole,
20143
+ * or opened from its ticks (waiting for them when they have not landed yet). */
20144
+ replayAdvance() {
20145
+ if (!this.replayPlaying || !this.replayQueue) return;
20146
+ const forming = this.replayForming;
20147
+ if (forming) {
20148
+ const due = Math.floor((now() - forming.origin) / this.replayIntervalMs) + 1;
20149
+ this.applyReplayTicks(Math.max(forming.batch, due - forming.next));
20150
+ if (this.replayPlaying) this.scheduleReplayStep(forming.delayMs);
20151
+ return;
20152
+ }
20153
+ const bar = this.replayQueue[0];
20154
+ if (!bar) return;
20155
+ const entry = this.replayTickSource ? this.requestTicks(bar) : null;
20156
+ if (entry && !entry.ticks) {
20157
+ const epoch = this.replayTickAbort;
20158
+ void entry.done.then(() => {
20159
+ if (this.replayPlaying && this.replayTimer == null && this.replayTickAbort === epoch && this.replayQueue?.[0] === bar) this.replayAdvance();
20160
+ });
20161
+ return;
20162
+ }
20163
+ if (!entry?.ticks?.length) {
20164
+ if (this.replayStep() && this.replayPlaying) this.scheduleReplayStep();
20165
+ return;
20166
+ }
20167
+ this.openFormingBar(entry.ticks);
20168
+ if (this.replayPlaying) this.scheduleReplayStep(this.replayForming?.delayMs);
20169
+ }
20170
+ /** Take the next queued bar off the tape and open it at its first tick (the first
20171
+ * `count` ticks — default: one timed update's worth). */
20172
+ openFormingBar(ticks, count) {
20173
+ const bar = this.replayQueue.shift();
20174
+ const volumeEach = ticks.every((t) => t.volume === void 0) ? (bar.volume ?? 0) / ticks.length : null;
20175
+ const forming = { bar, ticks, next: 0, candle: null, volumeEach, batch: 1, delayMs: this.replayIntervalMs, origin: 0 };
20176
+ this.paceFormingBar(forming);
20177
+ this.replayForming = forming;
20178
+ this.applyReplayTicks(count ?? forming.batch);
20179
+ this.prefetchTicks();
20180
+ }
20181
+ /** One tick per interval, batched when the interval is shorter than a frame; the clock
20182
+ * is anchored on the ticks already applied (a resume or a pace change carries on from
20183
+ * there, never jumps). */
20184
+ paceFormingBar(f) {
20185
+ f.batch = Math.max(1, Math.ceil(MIN_TICK_UPDATE_MS / this.replayIntervalMs));
20186
+ f.delayMs = this.replayIntervalMs * f.batch;
20187
+ f.origin = now() - Math.max(0, f.next - 1) * this.replayIntervalMs;
20188
+ }
20189
+ /** Fold the next `count` ticks into the forming candle; the last one settles the bar. */
20190
+ applyReplayTicks(count) {
20191
+ const f = this.replayForming;
20192
+ if (!f) return;
20193
+ let c = f.candle;
20194
+ const stop = Math.min(f.ticks.length, f.next + count);
20195
+ for (; f.next < stop; f.next += 1) {
20196
+ const t = f.ticks[f.next];
20197
+ const v = f.volumeEach ?? t.volume ?? 0;
20198
+ const hi = Math.max(t.high ?? t.price, t.price);
20199
+ const lo = Math.min(t.low ?? t.price, t.price);
20200
+ if (c) {
20201
+ c = { ...c, high: Math.max(c.high, hi), low: Math.min(c.low, lo), close: t.price, volume: (c.volume ?? 0) + v };
20202
+ } else {
20203
+ const open2 = t.open ?? t.price;
20204
+ c = { time: f.bar.time, open: open2, high: Math.max(open2, hi), low: Math.min(open2, lo), close: t.price, volume: v };
20205
+ }
20206
+ }
20207
+ f.candle = c;
20208
+ if (f.next >= f.ticks.length) {
20209
+ this.finishFormingBar();
20210
+ return;
20211
+ }
20212
+ this.applyBar(c);
20213
+ this.events.emit("replay:tick", { cursorTime: f.bar.time, index: f.next - 1, count: f.ticks.length });
20214
+ }
20215
+ /** Settle the forming bar on its stored values and announce it revealed. */
20216
+ finishFormingBar() {
20217
+ const f = this.replayForming;
20218
+ if (!f) return;
20219
+ this.replayForming = null;
20220
+ this.replayTickFetches.delete(f.bar.time);
20221
+ this.applyBar(f.bar);
20222
+ this.events.emit("replay:tick", { cursorTime: f.bar.time, index: f.ticks.length - 1, count: f.ticks.length });
20223
+ this.completeReveal(f.bar);
20224
+ }
20225
+ /** Put the forming bar back in its stored form, silently — a seek or an end rebuilds
20226
+ * from the tape, which must hold the stored bar. */
20227
+ settleFormingBar() {
20228
+ const f = this.replayForming;
20229
+ if (!f) return;
20230
+ this.replayForming = null;
20231
+ this.applyBar(f.bar);
20232
+ }
20233
+ /**
20234
+ * A history answer landing mid-replay (a progressive snapshot — the whole answer so far):
20235
+ * the tape keeps its cut and its hidden queue; bars older than the tape join its head,
20236
+ * bars newer than it join the queue's end.
20237
+ */
20238
+ mergeHistoryIntoReplay(bars) {
20239
+ const queue = this.replayQueue;
20240
+ const head = this.rawBars[0];
20241
+ if (!queue || !head) return;
20242
+ const tail = queue[queue.length - 1] ?? this.rawBars[this.rawBars.length - 1];
20243
+ let older = 0;
20244
+ while (older < bars.length && bars[older].time < head.time) older += 1;
20245
+ let newer = bars.length;
20246
+ while (newer > older && bars[newer - 1].time > tail.time) newer -= 1;
20247
+ if (newer < bars.length) queue.push(...bars.slice(newer));
20248
+ if (older > 0) {
20249
+ this.setBarSeries([...bars.slice(0, older), ...this.rawBars], { preserveView: true });
20250
+ this.notifySessionsBars("backfill");
20251
+ }
20252
+ }
20253
+ /** A bar is fully on screen: announce it, and end the replay if it was the last. */
20254
+ completeReveal(bar) {
20255
+ const queue = this.replayQueue;
20256
+ if (!queue) return;
20257
+ this.events.emit("replay:step", { cursorTime: bar.time, remaining: queue.length });
20258
+ if (queue.length === 0) this.endReplay("finished", { restore: true, resume: true });
20259
+ }
20260
+ /** Ask for the ticks of the next hidden bar ahead of time. */
20261
+ prefetchTicks() {
20262
+ const next = this.replayQueue?.[0];
20263
+ if (next && this.replayTickSource) this.requestTicks(next);
20264
+ }
20265
+ /** The (shared) tick request of a queued bar — the source runs once per bar. */
20266
+ requestTicks(bar) {
20267
+ const known = this.replayTickFetches.get(bar.time);
20268
+ if (known) return known;
20269
+ const source = this.replayTickSource;
20270
+ this.replayTickAbort ?? (this.replayTickAbort = new AbortController());
20271
+ const signal = this.replayTickAbort.signal;
20272
+ const queue = this.replayQueue ?? [];
20273
+ const end = queue[queue.indexOf(bar) + 1]?.time ?? bar.time + this.barIntervalMs();
20274
+ const entry = { ticks: null, done: Promise.resolve() };
20275
+ const land = (ticks) => {
20276
+ entry.ticks = usableTicks(ticks);
20277
+ };
20278
+ const fail = (e) => {
20279
+ if (!signal.aborted) console.warn(`[vela] replay tick source failed \u2014 revealing the bar whole (${e instanceof Error ? e.message : String(e)})`);
20280
+ land([]);
20281
+ };
20282
+ try {
20283
+ const out = source(bar, { end, signal });
20284
+ if (Array.isArray(out)) land(out);
20285
+ else entry.done = Promise.resolve(out).then(land, fail);
20286
+ } catch (e) {
20287
+ fail(e);
20288
+ }
20289
+ this.replayTickFetches.set(bar.time, entry);
20290
+ return entry;
20291
+ }
20292
+ /** Abort the tick requests in flight and forget the landed ones. */
20293
+ dropTickFetches() {
20294
+ this.replayTickAbort?.abort();
20295
+ this.replayTickAbort = null;
20296
+ this.replayTickFetches.clear();
20297
+ }
20298
+ clearReplayTimer() {
20299
+ if (this.replayTimer != null) clearTimeout(this.replayTimer);
20300
+ this.replayTimer = null;
20301
+ this.replayPlaying = false;
20302
+ }
20303
+ /**
20304
+ * Leave replay. `restore` puts the queued bars back and restarts the consumers over the
20305
+ * full history (a market switch skips it: its load replaces everything anyway); `resume`
20306
+ * restarts live updates and heals the bars that closed while replaying. A null reason
20307
+ * leaves silently — a seek that re-enters right after.
20308
+ */
20309
+ endReplay(reason, opts) {
20310
+ const queue = this.replayQueue;
20311
+ if (!queue) return;
20312
+ this.clearReplayTimer();
20313
+ if (opts.restore) this.settleFormingBar();
20314
+ else this.replayForming = null;
20315
+ this.dropTickFetches();
20316
+ this.replayQueue = null;
20317
+ const depth = reason ? this.replayDepth : null;
20318
+ if (reason) this.replayDepth = null;
20319
+ if (depth) {
20320
+ if (depth.bars === void 0) delete this.config.market.bars;
20321
+ else this.config.market.bars = depth.bars;
20322
+ }
20323
+ if (opts.restore) {
20324
+ const keep = depth ? depth.bars ?? 500 : Infinity;
20325
+ if (queue.length > 0 || this.rawBars.length > keep) {
20326
+ const full = [...this.rawBars, ...queue];
20327
+ this.setBarSeries(full.length > keep ? full.slice(full.length - keep) : full);
20328
+ this.restartBarConsumers({ blank: false });
20329
+ } else if (this.config.live) {
20330
+ this.restartNativeIndicators();
20331
+ this.restartChartTypeEngine();
20332
+ }
20333
+ }
20334
+ if (opts.resume && this.config.live) {
20335
+ this.startLive();
20336
+ const last = this.rawBars[this.rawBars.length - 1];
20337
+ if (last && this.canHeal()) void this.healGap(last.time);
20338
+ }
20339
+ if (reason) this.events.emit("replay:end", { reason });
20340
+ }
20341
+ /**
20342
+ * The bars were replaced by a cut or a restore of the SAME market: every consumer re-runs.
20343
+ * A cut also blanks the mounted models first — they still paint the hidden bars' values,
20344
+ * which must not show until the re-runs land.
20345
+ */
20346
+ restartBarConsumers(opts) {
20347
+ if (opts.blank) this.blankIndicatorVisuals();
20348
+ this.restartNativeIndicators();
20349
+ this.restartChartTypeEngine();
20350
+ this.reexecuteIndicators();
20351
+ }
19615
20352
  /** The primary symbol as the data layer resolves it, or null if no symbol is set. */
19616
20353
  qualifiedSymbol() {
19617
20354
  const market = this.config.market;
@@ -19683,7 +20420,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19683
20420
  engine.start({
19684
20421
  symbol: this.config.market.symbol ?? "TEST",
19685
20422
  timeframe: this.config.market.timeframe ?? "60",
19686
- live: this.config.live ?? false,
20423
+ live: (this.config.live ?? false) && this.replayQueue === null,
19687
20424
  session: this.config.market.session,
19688
20425
  bars: () => this.bars,
19689
20426
  data: this.dataControl,
@@ -19809,6 +20546,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19809
20546
  record.session.notifyBars(reason);
19810
20547
  } else if (record.native) record.native.instance.onBars();
19811
20548
  }
20549
+ if (this.activeEngineStyle) this.typeEngines.get(this.activeEngineStyle)?.onBars?.();
19812
20550
  }
19813
20551
  /** Gap healing needs a ranged feed and a provider-backed series (offline `data` has no source). */
19814
20552
  canHeal() {
@@ -19831,10 +20569,11 @@ var EngineOrchestrator = class _EngineOrchestrator {
19831
20569
  try {
19832
20570
  const bars = await this.feed.loadRange(this.config.market, { from: fromMs });
19833
20571
  if (this.generation !== gen) return;
20572
+ if (this.replayQueue) return;
19834
20573
  for (const b of bars) this.applyBar(b, false);
19835
20574
  } catch {
19836
20575
  } finally {
19837
- if (this.generation === gen) {
20576
+ if (this.generation === gen && !this.replayQueue) {
19838
20577
  this.healing = false;
19839
20578
  const pending = this.healBuffer;
19840
20579
  this.healBuffer = [];
@@ -20139,6 +20878,13 @@ var EngineOrchestrator = class _EngineOrchestrator {
20139
20878
  destroy() {
20140
20879
  this.bumpGeneration();
20141
20880
  this.resolveHistoryComplete();
20881
+ this.clearReplayTimer();
20882
+ this.replayQueue = null;
20883
+ this.replayForming = null;
20884
+ this.replayCarrying = null;
20885
+ this.dropTickFetches();
20886
+ this.replayDepth = null;
20887
+ this.replayLoading = null;
20142
20888
  this.stopLive();
20143
20889
  if (this.viewportTimer != null) clearTimeout(this.viewportTimer);
20144
20890
  this.viewportUnsub?.();
@@ -20237,6 +20983,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20237
20983
  this.emitScriptRun(id, cause, first);
20238
20984
  },
20239
20985
  onAlert: (a) => {
20986
+ if (this.replayQueue || this.replayCarrying) return;
20240
20987
  const indicator = record.options?.title ?? record.prepared?.meta.title ?? record.title;
20241
20988
  this.events.emit("alert", { ...a, indicator });
20242
20989
  handle.emit("alert", { id: a.id, message: a.message, title: a.title, time: a.time });
@@ -20263,7 +21010,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20263
21010
  id,
20264
21011
  symbol: this.config.market.symbol ?? "TEST",
20265
21012
  timeframe: this.config.market.timeframe ?? "60",
20266
- live: this.config.live,
21013
+ live: this.config.live && this.replayQueue === null,
20267
21014
  session: this.config.market.session,
20268
21015
  bars: () => this.bars,
20269
21016
  data: this.dataControl,
@@ -20666,17 +21413,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
20666
21413
  }
20667
21414
  emitContextChanged(id) {
20668
21415
  if (!this.registry.get(id)?.session?.getContext) return;
20669
- const now = Date.now();
20670
- if (now - (this.contextEmitAt.get(id) ?? 0) < 1e3) return;
20671
- this.contextEmitAt.set(id, now);
21416
+ const now2 = Date.now();
21417
+ if (now2 - (this.contextEmitAt.get(id) ?? 0) < 1e3) return;
21418
+ this.contextEmitAt.set(id, now2);
20672
21419
  this.events.emit("context:changed", { id });
20673
21420
  }
20674
21421
  emitScriptRun(id, cause, first) {
20675
21422
  const record = this.registry.get(id);
20676
21423
  if (!record || record.native || !this.events.hasListeners("script:run")) return;
20677
- const now = Date.now();
20678
- if (cause === "tick" && now - (this.runEmitAt.get(id) ?? 0) < RUN_EMIT_THROTTLE_MS) return;
20679
- this.runEmitAt.set(id, now);
21424
+ const now2 = Date.now();
21425
+ if (cause === "tick" && now2 - (this.runEmitAt.get(id) ?? 0) < RUN_EMIT_THROTTLE_MS) return;
21426
+ this.runEmitAt.set(id, now2);
20680
21427
  void Promise.resolve().then(() => this.buildScriptRun(record, cause, first)).then((run) => {
20681
21428
  if (run && this.registry.get(id)) this.events.emit("script:run", run);
20682
21429
  });
@@ -20729,6 +21476,16 @@ var EngineOrchestrator = class _EngineOrchestrator {
20729
21476
  handle?.emit("error", { error });
20730
21477
  }
20731
21478
  };
21479
+ function now() {
21480
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
21481
+ }
21482
+ function usableTicks(value) {
21483
+ if (!Array.isArray(value)) return [];
21484
+ const optional = (n) => n === void 0 || Number.isFinite(n);
21485
+ return value.filter(
21486
+ (t) => t != null && Number.isFinite(t.price) && optional(t.volume) && optional(t.high) && optional(t.low) && optional(t.open)
21487
+ );
21488
+ }
20732
21489
  function yieldToPaint() {
20733
21490
  if (typeof requestAnimationFrame === "function") {
20734
21491
  return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
@@ -20929,6 +21686,12 @@ var RendererControl = class {
20929
21686
  onCrosshairMove(cb) {
20930
21687
  return this.renderer.onCrosshairMove(cb);
20931
21688
  }
21689
+ /** A click — or a touch tap — on the plot, never the end of a pan: the open time of
21690
+ * the bar under it (`null` off the bars). On touch, where a tap moves no crosshair,
21691
+ * this is how an interaction learns which bar was chosen. */
21692
+ onClick(cb) {
21693
+ return this.renderer.onClick(cb);
21694
+ }
20932
21695
  /** Touch long-press on a price or time axis strip — silent no-op without the seam. */
20933
21696
  onAxisLongPress(cb) {
20934
21697
  return this.renderer.onAxisLongPress?.(cb) ?? (() => void 0);
@@ -21427,6 +22190,71 @@ var MarksControl = class {
21427
22190
  }
21428
22191
  };
21429
22192
 
22193
+ // src/core/ReplayControl.ts
22194
+ var ReplayControl = class {
22195
+ constructor(ctrl) {
22196
+ this.ctrl = ctrl;
22197
+ }
22198
+ /**
22199
+ * Enter replay (or seek, when already replaying) at `opts.from`. Resolves once the
22200
+ * chart shows the rewound history — after any in-flight history load has finished.
22201
+ * Starts paused; call {@link play} or {@link step}.
22202
+ */
22203
+ start(opts) {
22204
+ return this.ctrl.replayStart(opts);
22205
+ }
22206
+ /** Reveal the next bar — or, mid-way through a bar played tick by tick, complete it.
22207
+ * Returns false when replay is off or nothing is left. */
22208
+ step() {
22209
+ return this.ctrl.replayStep();
22210
+ }
22211
+ /** Reveal the next UPDATE: with {@link setTicks}, the next tick of the forming bar — or
22212
+ * the next bar opened at its first tick (once its ticks are in, if still loading);
22213
+ * otherwise the next whole bar, like {@link step}. Returns false when nothing is left. */
22214
+ stepUpdate() {
22215
+ return this.ctrl.replayStepUpdate();
22216
+ }
22217
+ /** Reveal one bar (one tick, with {@link setTicks}) every `intervalMs` (default: the last
22218
+ * interval used, else 1000). Calling it again while playing changes the pace. */
22219
+ play(intervalMs2) {
22220
+ this.ctrl.replayPlay(intervalMs2);
22221
+ return this;
22222
+ }
22223
+ pause() {
22224
+ this.ctrl.replayPause();
22225
+ return this;
22226
+ }
22227
+ /** Leave replay: the full history comes back and live updates resume. Also cancels a
22228
+ * `start()` still loading older history (the chart returns to its previous depth). */
22229
+ stop() {
22230
+ this.ctrl.replayStop();
22231
+ return this;
22232
+ }
22233
+ get state() {
22234
+ return this.ctrl.replayState();
22235
+ }
22236
+ /** The loaded history a replay can walk — hidden bars included while replaying. Null
22237
+ * before any bar loaded. A start older than `first` deepens the history first. */
22238
+ get bounds() {
22239
+ return this.ctrl.replayBounds();
22240
+ }
22241
+ /**
22242
+ * Play each revealed bar as a series of intrabar updates instead of whole: while
22243
+ * playing, the forming candle opens at the first tick, then every tick moves its close
22244
+ * and stretches its high and low, exactly like a live tick — indicators, chart types
22245
+ * and the `bar` event follow — and the last one settles it on the stored bar. The play
22246
+ * interval is then the time between two TICKS: a bar lasts as many intervals as it
22247
+ * has ticks (below ~16 ms, ticks are batched per frame). The next bar's ticks are
22248
+ * requested while the current one plays; playback waits for them. `null` goes back to
22249
+ * whole bars. Takes effect from the next bar; a bar forming when the source changes
22250
+ * completes at once.
22251
+ */
22252
+ setTicks(source) {
22253
+ this.ctrl.replaySetTicks(source);
22254
+ return this;
22255
+ }
22256
+ };
22257
+
21430
22258
  // src/core/model/inputs.ts
21431
22259
  function inputVisible(when, values) {
21432
22260
  if (!when) return true;
@@ -22036,10 +22864,16 @@ var IndicatorInputsDialog = class {
22036
22864
  this.calendarPop = null;
22037
22865
  this.calendarAnchor = null;
22038
22866
  },
22039
- content: (el) => fillCalendar(el, current, (iso) => {
22040
- pop.hide();
22041
- onPick(iso);
22042
- })
22867
+ content: (el) => {
22868
+ const picker = new DatePicker({
22869
+ value: current,
22870
+ onPick: (iso) => {
22871
+ pop.hide();
22872
+ onPick(iso);
22873
+ }
22874
+ });
22875
+ el.append(picker.el);
22876
+ }
22043
22877
  });
22044
22878
  this.calendarPop = pop;
22045
22879
  this.calendarAnchor = anchor;
@@ -22164,28 +22998,6 @@ function ensureDialogStyles() {
22164
22998
  .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;}
22165
22999
  .vela-ind-combo-chevron:hover{opacity:0.9;}
22166
23000
  .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;}
22167
- .vela-ind-cal [hidden]{display:none !important;}
22168
- .vela-ind-cal-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
22169
- .vela-ind-cal-title{flex:1;display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;}
22170
- .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;}
22171
- .vela-ind-cal-switch:not(:disabled):hover{background:var(--vela-hover);}
22172
- .vela-ind-cal-switch:disabled{cursor:default;}
22173
- .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;}
22174
- .vela-ind-cal-nav:hover{background:var(--vela-hover);color:var(--vela-fg-bright);}
22175
- .vela-ind-cal-week,.vela-ind-cal-grid{display:grid;grid-template-columns:repeat(7,28px);gap:2px;}
22176
- .vela-ind-cal-week{margin-bottom:4px;color:var(--vela-fg-muted);font-size:11px;text-align:center;}
22177
- .vela-ind-cal-week span{line-height:20px;}
22178
- .vela-ind-cal-blank{width:28px;height:28px;}
22179
- .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;}
22180
- .vela-ind-cal-day:hover{background:var(--vela-hover);}
22181
- .vela-ind-cal-day[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
22182
- .vela-ind-cal-day[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
22183
- .vela-ind-cal-cells{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;width:calc(7 * 28px + 6 * 2px);}
22184
- .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;}
22185
- .vela-ind-cal-cell:hover{background:var(--vela-hover);}
22186
- .vela-ind-cal-cell[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
22187
- .vela-ind-cal-cell[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
22188
- .vela-ind-cal-cell[data-outside]{opacity:0.45;}
22189
23001
  ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
22190
23002
  .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;}
22191
23003
  .vela-ind-tab:not(.vela-ind-tab-active):hover{color:var(--vela-fg-bright);}
@@ -22219,174 +23031,6 @@ var TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
22219
23031
  const v = `${hh}:${mm}`;
22220
23032
  return { value: v, label: v };
22221
23033
  });
22222
- var MONTH_LABELS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
22223
- var MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
22224
- var WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
22225
- function normalizeDateInput(raw) {
22226
- const t = raw.trim();
22227
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t);
22228
- if (!m) return null;
22229
- const y = Number(m[1]);
22230
- const mo = Number(m[2]);
22231
- const d = Number(m[3]);
22232
- const dt = new Date(y, mo - 1, d);
22233
- if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
22234
- return isoDate(dt);
22235
- }
22236
- function parseIsoDate(raw) {
22237
- const iso = normalizeDateInput(raw);
22238
- if (!iso) return null;
22239
- const [y, mo, d] = iso.split("-").map(Number);
22240
- return new Date(y, mo - 1, d);
22241
- }
22242
- function isoDate(d) {
22243
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
22244
- }
22245
- function fillCalendar(el, current, onPick) {
22246
- const today = /* @__PURE__ */ new Date();
22247
- const selected = parseIsoDate(current);
22248
- let year = selected?.getFullYear() ?? today.getFullYear();
22249
- let month = selected?.getMonth() ?? today.getMonth();
22250
- let mode = "date";
22251
- const prev2 = document.createElement("button");
22252
- prev2.type = "button";
22253
- prev2.className = "vela-ind-cal-nav";
22254
- prev2.innerHTML = iconAt("chevron-left", 14);
22255
- const next = document.createElement("button");
22256
- next.type = "button";
22257
- next.className = "vela-ind-cal-nav";
22258
- next.innerHTML = iconAt("chevron-right", 14);
22259
- const monthBtn = document.createElement("button");
22260
- monthBtn.type = "button";
22261
- monthBtn.className = "vela-ind-cal-switch";
22262
- monthBtn.setAttribute("aria-label", "Choose month");
22263
- const yearBtn = document.createElement("button");
22264
- yearBtn.type = "button";
22265
- yearBtn.className = "vela-ind-cal-switch";
22266
- yearBtn.setAttribute("aria-label", "Choose year");
22267
- const title = document.createElement("div");
22268
- title.className = "vela-ind-cal-title";
22269
- title.append(monthBtn, yearBtn);
22270
- const head = document.createElement("div");
22271
- head.className = "vela-ind-cal-head";
22272
- head.append(prev2, title, next);
22273
- const week = document.createElement("div");
22274
- week.className = "vela-ind-cal-week";
22275
- for (const d of WEEKDAY_LABELS) {
22276
- const cell = document.createElement("span");
22277
- cell.textContent = d;
22278
- week.appendChild(cell);
22279
- }
22280
- const grid = document.createElement("div");
22281
- const paint = () => {
22282
- const decade = Math.floor(year / 10) * 10;
22283
- monthBtn.hidden = mode !== "date";
22284
- monthBtn.textContent = MONTH_LABELS[month] ?? "";
22285
- yearBtn.textContent = mode === "year" ? `${decade}-${decade + 9}` : String(year);
22286
- yearBtn.disabled = mode === "year";
22287
- week.hidden = mode !== "date";
22288
- prev2.setAttribute("aria-label", mode === "date" ? "Previous month" : mode === "month" ? "Previous year" : "Previous decade");
22289
- next.setAttribute("aria-label", mode === "date" ? "Next month" : mode === "month" ? "Next year" : "Next decade");
22290
- grid.className = mode === "date" ? "vela-ind-cal-grid" : "vela-ind-cal-cells";
22291
- grid.replaceChildren();
22292
- if (mode === "date") {
22293
- const startPad = new Date(year, month, 1).getDay();
22294
- const days = new Date(year, month + 1, 0).getDate();
22295
- const todayIso = isoDate(today);
22296
- const selectedIso = selected ? isoDate(selected) : "";
22297
- for (let i = 0; i < startPad; i++) {
22298
- const blank = document.createElement("span");
22299
- blank.className = "vela-ind-cal-blank";
22300
- grid.appendChild(blank);
22301
- }
22302
- for (let day = 1; day <= days; day++) {
22303
- const iso = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
22304
- const b = document.createElement("button");
22305
- b.type = "button";
22306
- b.className = "vela-ind-cal-day";
22307
- b.textContent = String(day);
22308
- if (iso === selectedIso) b.dataset.checked = "1";
22309
- if (iso === todayIso) b.dataset.today = "1";
22310
- b.addEventListener("click", (e) => {
22311
- e.stopPropagation();
22312
- onPick(iso);
22313
- });
22314
- grid.appendChild(b);
22315
- }
22316
- return;
22317
- }
22318
- if (mode === "month") {
22319
- for (let m = 0; m < 12; m++) {
22320
- const b = document.createElement("button");
22321
- b.type = "button";
22322
- b.className = "vela-ind-cal-cell";
22323
- b.textContent = MONTH_SHORT[m] ?? "";
22324
- if (selected?.getFullYear() === year && selected.getMonth() === m) b.dataset.checked = "1";
22325
- if (today.getFullYear() === year && today.getMonth() === m) b.dataset.today = "1";
22326
- b.addEventListener("click", (e) => {
22327
- e.stopPropagation();
22328
- month = m;
22329
- mode = "date";
22330
- paint();
22331
- });
22332
- grid.appendChild(b);
22333
- }
22334
- return;
22335
- }
22336
- for (let y = decade - 1; y <= decade + 10; y++) {
22337
- const b = document.createElement("button");
22338
- b.type = "button";
22339
- b.className = "vela-ind-cal-cell";
22340
- b.textContent = String(y);
22341
- if (y < decade || y > decade + 9) b.dataset.outside = "1";
22342
- if (selected?.getFullYear() === y) b.dataset.checked = "1";
22343
- if (today.getFullYear() === y) b.dataset.today = "1";
22344
- b.addEventListener("click", (e) => {
22345
- e.stopPropagation();
22346
- year = y;
22347
- mode = "month";
22348
- paint();
22349
- });
22350
- grid.appendChild(b);
22351
- }
22352
- };
22353
- const step = (dir) => {
22354
- if (mode === "date") {
22355
- month += dir;
22356
- if (month < 0) {
22357
- month = 11;
22358
- year -= 1;
22359
- }
22360
- if (month > 11) {
22361
- month = 0;
22362
- year += 1;
22363
- }
22364
- } else {
22365
- year += mode === "month" ? dir : dir * 10;
22366
- }
22367
- paint();
22368
- };
22369
- prev2.addEventListener("click", (e) => {
22370
- e.stopPropagation();
22371
- step(-1);
22372
- });
22373
- next.addEventListener("click", (e) => {
22374
- e.stopPropagation();
22375
- step(1);
22376
- });
22377
- monthBtn.addEventListener("click", (e) => {
22378
- e.stopPropagation();
22379
- mode = "month";
22380
- paint();
22381
- });
22382
- yearBtn.addEventListener("click", (e) => {
22383
- e.stopPropagation();
22384
- mode = "year";
22385
- paint();
22386
- });
22387
- paint();
22388
- el.append(head, week, grid);
22389
- }
22390
23034
  function normalizeTimeInput(raw) {
22391
23035
  const t = raw.trim();
22392
23036
  const m = /^(\d{1,2}):(\d{2})$/.exec(t) ?? /^(\d{2})(\d{2})$/.exec(t);
@@ -25090,7 +25734,7 @@ var Scheduler = class {
25090
25734
 
25091
25735
  // src/renderers/native/core/Animator.ts
25092
25736
  var Animator = class {
25093
- constructor(tick, raf, cancel, now) {
25737
+ constructor(tick, raf, cancel, now2) {
25094
25738
  this.tick = tick;
25095
25739
  this.handle = null;
25096
25740
  this.last = 0;
@@ -25104,7 +25748,7 @@ var Animator = class {
25104
25748
  };
25105
25749
  this.raf = raf ?? ((cb) => requestAnimationFrame(cb));
25106
25750
  this.cancel = cancel ?? ((h) => cancelAnimationFrame(h));
25107
- this.now = now ?? (() => performance.now());
25751
+ this.now = now2 ?? (() => performance.now());
25108
25752
  }
25109
25753
  get active() {
25110
25754
  return this.handle !== null;
@@ -25903,6 +26547,8 @@ var SceneGraph = class {
25903
26547
  /** VPVR-layer config pushed by the VPVR native indicator (null ⇒ layer off). Ephemeral. */
25904
26548
  this.vpvrLayer = null;
25905
26549
  this.crosshair = null;
26550
+ /** Runtime override of the crosshair's lines and style (the `crosshairOverride` feature). Ephemeral. */
26551
+ this.crosshairOverride = null;
25906
26552
  /** How the base price series is drawn on the price pane (candles by default). */
25907
26553
  this.priceStyle = "candles";
25908
26554
  /** Price-series base painting for the ACTIVE style (see ChartTypeDefinition.basePainting). */
@@ -27819,9 +28465,9 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
27819
28465
  }
27820
28466
 
27821
28467
  // src/renderers/native/chrome/countdown.ts
27822
- function countdownText(barOpen, barMs, now) {
28468
+ function countdownText(barOpen, barMs, now2) {
27823
28469
  if (!(barMs > 0)) return null;
27824
- const remaining = barOpen + barMs - now;
28470
+ const remaining = barOpen + barMs - now2;
27825
28471
  if (remaining <= 0) return null;
27826
28472
  return formatCountdown(remaining);
27827
28473
  }
@@ -28633,19 +29279,35 @@ var CrosshairRenderer = class {
28633
29279
  const dataH = coords.height;
28634
29280
  if (!ch || ch.x < 0 || ch.x > dataW || ch.y < 0 || ch.y > dataH) return;
28635
29281
  const cs = scene.style.crosshair;
29282
+ const ov = scene.crosshairOverride;
29283
+ const vertical = ov?.vertical !== false;
29284
+ const horizontal = ov?.horizontal !== false;
28636
29285
  ctx.font = `${scene.style.fontSize}px ${theme.fontFamily}`;
28637
29286
  ctx.textBaseline = "middle";
28638
29287
  const logical = Math.round(coords.xToLogical(ch.x));
28639
- const x = Math.round(coords.logicalToX(logical)) + 0.5;
28640
- ctx.strokeStyle = cs.color ?? theme.textColor;
28641
- ctx.lineWidth = cs.width;
28642
- ctx.globalAlpha = cs.opacity;
28643
- setDash3(ctx, cs.style);
29288
+ const x = crisp(coords.logicalToX(logical), ov?.width ?? cs.width);
29289
+ if (vertical && ov?.shadeRight) {
29290
+ const from = Math.max(0, Math.round(coords.logicalToX(logical + 0.5)));
29291
+ if (from < dataW) {
29292
+ ctx.fillStyle = ov.shadeRight.color;
29293
+ ctx.globalAlpha = ov.shadeRight.opacity ?? 1;
29294
+ ctx.fillRect(from, 0, dataW - from, dataH);
29295
+ ctx.globalAlpha = 1;
29296
+ }
29297
+ }
29298
+ ctx.strokeStyle = ov?.color ?? cs.color ?? theme.textColor;
29299
+ ctx.lineWidth = ov?.width ?? cs.width;
29300
+ ctx.globalAlpha = ov?.opacity ?? cs.opacity;
29301
+ setDash3(ctx, ov?.style ?? cs.style);
28644
29302
  ctx.beginPath();
28645
- ctx.moveTo(x, 0);
28646
- ctx.lineTo(x, dataH);
28647
- ctx.moveTo(0, Math.round(ch.y) + 0.5);
28648
- ctx.lineTo(dataW, Math.round(ch.y) + 0.5);
29303
+ if (vertical) {
29304
+ ctx.moveTo(x, 0);
29305
+ ctx.lineTo(x, dataH);
29306
+ }
29307
+ if (horizontal) {
29308
+ ctx.moveTo(0, Math.round(ch.y) + 0.5);
29309
+ ctx.lineTo(dataW, Math.round(ch.y) + 0.5);
29310
+ }
28649
29311
  ctx.stroke();
28650
29312
  ctx.setLineDash([]);
28651
29313
  ctx.lineWidth = 1;
@@ -28658,11 +29320,11 @@ var CrosshairRenderer = class {
28658
29320
  }
28659
29321
  }
28660
29322
  const chipBg = cs.labelBackground ?? theme.borderColor;
28661
- if (pane && pane.axisFormat !== "none") {
29323
+ if (horizontal && pane && pane.axisFormat !== "none") {
28662
29324
  const price = coords.yToPrice(ch.y, pane.scale, pane.bounds);
28663
29325
  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);
28664
29326
  }
28665
- this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
29327
+ if (vertical) this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
28666
29328
  }
28667
29329
  destroy() {
28668
29330
  this.canvas = null;
@@ -28673,23 +29335,37 @@ var CrosshairRenderer = class {
28673
29335
  * along), with that bar's time chip in this chart's own timezone and — when the
28674
29336
  * level resolved — the price chip on the right axis. The snap happened upstream
28675
29337
  * (`externalCrossPx`, floor-to-containing-bar) — this method only draws. Chips
28676
- * render slightly dimmed so the ghost still reads as foreign. */
29338
+ * render slightly dimmed so the ghost still reads as foreign. A `crosshairOverride`
29339
+ * applies to the ghost as well — its line style, a dropped horizontal line, and the
29340
+ * `shadeRight` veil from the ghost bar's right edge — so a pick that spans several
29341
+ * charts reads the same on every one of them. */
28677
29342
  drawExternal(ctx, ext, scene, coords, theme) {
28678
29343
  const cs = scene.style.crosshair;
29344
+ const ov = scene.crosshairOverride;
28679
29345
  const dataW = coords.width;
28680
29346
  const dataH = coords.height;
28681
- const x = Math.round(ext.x) + 0.5;
28682
- if (x < 0 || x > dataW) return;
29347
+ const x = crisp(ext.x, ov?.width ?? cs.width);
29348
+ if (ov?.vertical === false) return;
29349
+ if (ov?.shadeRight) {
29350
+ const from = Math.max(0, Math.round(coords.logicalToX(Math.round(coords.xToLogical(ext.x)) + 0.5)));
29351
+ if (from < dataW) {
29352
+ ctx.fillStyle = ov.shadeRight.color;
29353
+ ctx.globalAlpha = ov.shadeRight.opacity ?? 1;
29354
+ ctx.fillRect(from, 0, dataW - from, dataH);
29355
+ ctx.globalAlpha = 1;
29356
+ }
29357
+ }
29358
+ if (ext.line === false || x < 0 || x > dataW) return;
28683
29359
  ctx.font = `${scene.style.fontSize}px ${theme.fontFamily}`;
28684
29360
  ctx.textBaseline = "middle";
28685
- ctx.strokeStyle = cs.color ?? theme.textColor;
28686
- ctx.lineWidth = cs.width;
28687
- ctx.globalAlpha = cs.opacity * 0.55;
28688
- setDash3(ctx, cs.style);
29361
+ ctx.strokeStyle = ov?.color ?? cs.color ?? theme.textColor;
29362
+ ctx.lineWidth = ov?.width ?? cs.width;
29363
+ ctx.globalAlpha = (ov?.opacity ?? cs.opacity) * 0.55;
29364
+ setDash3(ctx, ov?.style ?? cs.style);
28689
29365
  ctx.beginPath();
28690
29366
  ctx.moveTo(x, 0);
28691
29367
  ctx.lineTo(x, dataH);
28692
- if (ext.y != null) {
29368
+ if (ext.y != null && ov?.horizontal !== false) {
28693
29369
  ctx.moveTo(0, Math.round(ext.y) + 0.5);
28694
29370
  ctx.lineTo(dataW, Math.round(ext.y) + 0.5);
28695
29371
  }
@@ -28698,7 +29374,7 @@ var CrosshairRenderer = class {
28698
29374
  ctx.lineWidth = 1;
28699
29375
  ctx.globalAlpha = 0.8;
28700
29376
  const chipBg = cs.labelBackground ?? theme.borderColor;
28701
- if (ext.y != null && ext.price != null) {
29377
+ if (ext.y != null && ext.price != null && ov?.horizontal !== false) {
28702
29378
  let pane;
28703
29379
  for (const p of scene.panes.values()) {
28704
29380
  if (ext.y >= p.bounds.top && ext.y <= p.bounds.top + p.bounds.height) {
@@ -28738,6 +29414,9 @@ var CrosshairRenderer = class {
28738
29414
  ctx.textAlign = "start";
28739
29415
  }
28740
29416
  };
29417
+ function crisp(px, width) {
29418
+ return Math.round(px) + (Math.round(width) % 2 === 1 ? 0.5 : 0);
29419
+ }
28741
29420
  function setDash3(ctx, style) {
28742
29421
  if (style === "dashed") ctx.setLineDash([6, 4]);
28743
29422
  else if (style === "dotted") ctx.setLineDash([2, 3]);
@@ -36176,7 +36855,7 @@ var NativeRenderer = class {
36176
36855
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
36177
36856
  this.priceStyleCbs = /* @__PURE__ */ new Set();
36178
36857
  this.name = "native";
36179
- 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"];
36858
+ 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"];
36180
36859
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
36181
36860
  * so moving onto the button doesn't count as leaving). */
36182
36861
  this.onScrollProximityMove = (e) => {
@@ -36355,6 +37034,9 @@ var NativeRenderer = class {
36355
37034
  this.scene.marks = mergeMarksState(this.scene.marks, value);
36356
37035
  this.markPopover?.close();
36357
37036
  break;
37037
+ case "crosshairOverride":
37038
+ this.scene.crosshairOverride = sanitizeCrosshairOverride(value);
37039
+ break;
36358
37040
  case "keyboard":
36359
37041
  this.setKeyboardEnabled(Boolean(value));
36360
37042
  return;
@@ -36468,6 +37150,8 @@ var NativeRenderer = class {
36468
37150
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
36469
37151
  case "marks":
36470
37152
  return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
37153
+ case "crosshairOverride":
37154
+ return this.scene.crosshairOverride ? { ...this.scene.crosshairOverride } : null;
36471
37155
  case "keyboard":
36472
37156
  return this.keyboardEnabled;
36473
37157
  case "historyChords":
@@ -37046,8 +37730,8 @@ var NativeRenderer = class {
37046
37730
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
37047
37731
  this.modelAlpha = 0;
37048
37732
  const start = performance.now();
37049
- const step = (now) => {
37050
- const p = Math.min(1, (now - start) / duration);
37733
+ const step = (now2) => {
37734
+ const p = Math.min(1, (now2 - start) / duration);
37051
37735
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
37052
37736
  this.paintData();
37053
37737
  if (p < 1) {
@@ -37063,8 +37747,8 @@ var NativeRenderer = class {
37063
37747
  fadeInModels() {
37064
37748
  const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
37065
37749
  const start = performance.now();
37066
- const step = (now) => {
37067
- this.modelAlpha = Math.min(1, (now - start) / fade);
37750
+ const step = (now2) => {
37751
+ this.modelAlpha = Math.min(1, (now2 - start) / fade);
37068
37752
  this.paintData();
37069
37753
  if (this.modelAlpha < 1) {
37070
37754
  this.introRaf = requestAnimationFrame(step);
@@ -37587,10 +38271,13 @@ var NativeRenderer = class {
37587
38271
  this.animator.start();
37588
38272
  }
37589
38273
  } else if (!last || bar.time > last.time) {
38274
+ const vp = this.coords.getViewport();
38275
+ const holdView = n > 0 && vp.rightOffset < 0 && this.scrollTargetRO === null;
37590
38276
  this.bars.push(bar);
37591
38277
  this.syncLiveEase(bar);
37592
38278
  if (this.coords.barInterval > 0) this.coords.appendBar(bar.time);
37593
38279
  else this.coords.setBars(this.bars.map((b) => b.time));
38280
+ if (holdView) this.coords.setViewport({ ...vp, rightOffset: vp.rightOffset - 1 });
37594
38281
  } else {
37595
38282
  return;
37596
38283
  }
@@ -38272,10 +38959,10 @@ var NativeRenderer = class {
38272
38959
  if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
38273
38960
  const tick = () => {
38274
38961
  this.markPulseRaf = null;
38275
- const now = frameNow();
38276
- if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
38962
+ const now2 = frameNow();
38963
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now2) this.scene.marksFlash = null;
38277
38964
  this.scheduler?.invalidate(2 /* Chrome */);
38278
- const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
38965
+ const pulsing = this.scene.marksHoverKey !== null && now2 - this.scene.marksHoverSince < MARK_PULSE_MS;
38279
38966
  if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
38280
38967
  };
38281
38968
  this.markPulseRaf = requestAnimationFrame(tick);
@@ -38836,9 +39523,10 @@ var NativeRenderer = class {
38836
39523
  const ext = this.externalCross;
38837
39524
  if (!ext || this.coords.barCount === 0) return null;
38838
39525
  const logical = Math.floor(this.coords.timeToLogical(ext.time));
38839
- if (logical < 0 || logical >= this.coords.barCount) return null;
38840
- const x = this.coords.logicalToX(logical);
38841
- if (!Number.isFinite(x) || x < 0 || x > this.coords.width) return null;
39526
+ if (!(logical < this.coords.barCount)) return null;
39527
+ const x = this.coords.logicalToX(Math.max(logical, -1));
39528
+ if (!Number.isFinite(x)) return null;
39529
+ const line = logical >= 0 && x >= 0 && x <= this.coords.width;
38842
39530
  let y = null;
38843
39531
  if (ext.price != null) {
38844
39532
  const pricePane = this.scene.orderedPanes().find((p) => p.kind === "price");
@@ -38847,7 +39535,7 @@ var NativeRenderer = class {
38847
39535
  if (!Number.isFinite(y) || y < pricePane.bounds.top || y > pricePane.bounds.top + pricePane.bounds.height) y = null;
38848
39536
  }
38849
39537
  }
38850
- return { x, y, time: this.coords.logicalToTime(logical), price: y != null ? ext.price : null };
39538
+ return { x, y, time: this.coords.logicalToTime(Math.max(logical, 0)), price: y != null ? ext.price : null, line };
38851
39539
  }
38852
39540
  /** Set the sticky magnet mode (called by the drawings toolbar's 3-state button). */
38853
39541
  setSnapMode(mode) {
@@ -43523,6 +44211,7 @@ var Vela = class {
43523
44211
  this.panesControl = new PanesControl(this.orchestrator);
43524
44212
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
43525
44213
  this.marksControl = new MarksControl(this.orchestrator.marks);
44214
+ this.replayControl = new ReplayControl(this.orchestrator);
43526
44215
  }
43527
44216
  /**
43528
44217
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -43762,6 +44451,16 @@ var Vela = class {
43762
44451
  get marks() {
43763
44452
  return this.marksControl;
43764
44453
  }
44454
+ /**
44455
+ * The chart's bar-replay control surface: rewind to a past bar and reveal the
44456
+ * following ones by hand or on a timer, from the history already loaded —
44457
+ * `await chart.replay.start({ from })`, `chart.replay.play(2000)`, `chart.replay.step()`,
44458
+ * `chart.replay.stop()`. Revealed bars behave like live bars for indicators; live
44459
+ * updates pause meanwhile. Follow it with the `replay:*` events.
44460
+ */
44461
+ get replay() {
44462
+ return this.replayControl;
44463
+ }
43765
44464
  on(event, handler) {
43766
44465
  return this.orchestrator.events.on(event, handler);
43767
44466
  }
@@ -43957,9 +44656,15 @@ var CSS21 = `
43957
44656
  }
43958
44657
  .vela-statusline .vela-sl-symbol { font-weight: 600; font-size: var(--vela-font-size-lg); }
43959
44658
  .vela-statusline .vela-sl-meta { color: var(--vela-fg-muted); font-size: var(--vela-font-size-md); font-weight: 600; }
44659
+ /* The meta opens with "\xB7 " \u2014 sit its dot one space-width after the ticker, as far as the
44660
+ * venue sits after it, not a full row gap away. */
44661
+ .vela-statusline .vela-sl-symbol + .vela-sl-meta { margin-left: calc(var(--vela-space-1) - var(--vela-space-2)); }
43960
44662
  /* Market status badge \u2014 a kit callout bubble (icon-only 16px circle, label on hover
43961
- * via the kit tooltip); the session tint is applied per status in setMarketStatus. */
43962
- .vela-statusline .vela-sl-market { align-self: center; }
44663
+ * via the kit tooltip); the session tint is applied per status in setMarketStatus. While
44664
+ * the chart replays past bars it wears the replay badge instead (the inverse chip). */
44665
+ .vela-statusline .vela-sl-market { align-self: center; display: inline-flex; }
44666
+ .vela-statusline .vela-sl-market > [hidden] { display: none !important; }
44667
+ .vela-statusline .vela-sl-replay-badge, .vela-statusline .vela-sl-replay-badge svg { display: block; width: 16px; height: 16px; }
43963
44668
  .vela-statusline .vela-sl-ohlc { display: flex; gap: var(--vela-space-1); color: var(--vela-fg-muted); }
43964
44669
  .vela-statusline .vela-sl-ohlc b { color: var(--vela-fg); font-weight: 500; }
43965
44670
  /* The change value wears the SAME ink as the OHLC values (set inline per render) \u2014
@@ -44012,6 +44717,8 @@ var MARKET_LABELS = {
44012
44717
  closed: "Market Closed",
44013
44718
  holiday: "Market Holiday"
44014
44719
  };
44720
+ var REPLAY_LABEL = "Replay Mode";
44721
+ 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>';
44015
44722
  var MARKET_INKS = {
44016
44723
  open: "var(--vela-up)",
44017
44724
  pre: SESSION_PRE,
@@ -44050,6 +44757,9 @@ var Statusline = class {
44050
44757
  this.host = host;
44051
44758
  this.iconFor = iconFor;
44052
44759
  this.parts = { logo: true, name: true, market: true, ohlc: true, change: true };
44760
+ this.marketStatus = "open";
44761
+ /** The chart replays past bars — the badge shows the replay mode (see {@link setReplaying}). */
44762
+ this.replaying = false;
44053
44763
  /** The right-click action menu — present once a host wires it via {@link attachMenu}. */
44054
44764
  this.menu = null;
44055
44765
  this.menuHooks = null;
@@ -44101,8 +44811,13 @@ var Statusline = class {
44101
44811
  label: MARKET_LABELS.open,
44102
44812
  host
44103
44813
  });
44104
- this.marketEl = this.marketBubble.el;
44105
- this.marketEl.classList.add("vela-sl-market");
44814
+ this.replayBadge = doc.createElement("span");
44815
+ this.replayBadge.className = "vela-sl-replay-badge";
44816
+ this.replayBadge.innerHTML = REPLAY_BADGE_SVG;
44817
+ this.replayBadge.hidden = true;
44818
+ this.marketEl = doc.createElement("span");
44819
+ this.marketEl.className = "vela-sl-market";
44820
+ this.marketEl.append(this.marketBubble.el, this.replayBadge);
44106
44821
  this.ohlcEl = doc.createElement("span");
44107
44822
  this.ohlcEl.className = "vela-sl-ohlc";
44108
44823
  this.changeEl = doc.createElement("span");
@@ -44224,6 +44939,25 @@ var Statusline = class {
44224
44939
  /** Dress the market badge for a session state: its icon, tinted circle, and the
44225
44940
  * hover label. Callers with no session model leave the constructor's 'open'. */
44226
44941
  setMarketStatus(status) {
44942
+ this.marketStatus = status;
44943
+ this.dressBadge();
44944
+ }
44945
+ /** The chart entered or left a bar replay: the badge shows the replay mode meanwhile
44946
+ * (the session state of a past bar says nothing about the market now). */
44947
+ setReplaying(replaying) {
44948
+ if (replaying === this.replaying) return;
44949
+ this.replaying = replaying;
44950
+ this.dressBadge();
44951
+ }
44952
+ dressBadge() {
44953
+ this.marketBubble.el.hidden = this.replaying;
44954
+ this.replayBadge.hidden = !this.replaying;
44955
+ if (this.replaying) {
44956
+ this.marketEl.dataset.status = "replay";
44957
+ this.marketTip.setContent(REPLAY_LABEL);
44958
+ return;
44959
+ }
44960
+ const status = this.marketStatus;
44227
44961
  this.marketEl.dataset.status = status;
44228
44962
  const ink = MARKET_INKS[status];
44229
44963
  this.marketBubble.set({
@@ -44296,11 +45030,23 @@ var Statusline = class {
44296
45030
  this.lastBar = b;
44297
45031
  if (!this.hoverBar) this.render();
44298
45032
  }),
45033
+ // A replay cut or restore replaces the newest bar without a `bar` event.
45034
+ chart.on("replay:start", () => {
45035
+ this.lastBar = null;
45036
+ this.setReplaying(true);
45037
+ this.render();
45038
+ }),
45039
+ chart.on("replay:end", () => {
45040
+ this.lastBar = null;
45041
+ this.setReplaying(false);
45042
+ this.render();
45043
+ }),
44299
45044
  chart.renderer.onCrosshairMove((e) => {
44300
45045
  this.hoverBar = e.ohlc;
44301
45046
  this.render();
44302
45047
  })
44303
45048
  );
45049
+ this.setReplaying(chart.replay.state.active);
44304
45050
  this.render();
44305
45051
  }
44306
45052
  destroy() {
@@ -44541,26 +45287,26 @@ function isWeekday(ms, tz) {
44541
45287
  return false;
44542
45288
  }
44543
45289
  }
44544
- function deriveMarketStatus(now, w, tz, overnight = false) {
44545
- const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
45290
+ function deriveMarketStatus(now2, w, tz, overnight = false) {
45291
+ const within = (ws) => ws.find(([s, e]) => now2 >= s && now2 < e);
44546
45292
  if (within(w.regular)) return "open";
44547
45293
  const ext = within(w.extended);
44548
45294
  if (ext) {
44549
45295
  if (overnight) return "extended";
44550
- return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
45296
+ return w.regular.some(([s]) => s >= now2 && s < ext[1]) ? "pre" : "post";
44551
45297
  }
44552
- if (isWeekday(now, tz)) {
44553
- const today = civilDate(now, tz);
45298
+ if (isWeekday(now2, tz)) {
45299
+ const today = civilDate(now2, tz);
44554
45300
  if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
44555
45301
  }
44556
45302
  return "closed";
44557
45303
  }
44558
- function nextStatusBoundary(now, w) {
45304
+ function nextStatusBoundary(now2, w) {
44559
45305
  let next = null;
44560
45306
  for (const ws of [w.regular, w.extended]) {
44561
45307
  for (const [s, e] of ws) {
44562
45308
  for (const t of [s, e]) {
44563
- if (t > now && (next == null || t < next)) next = t;
45309
+ if (t > now2 && (next == null || t < next)) next = t;
44564
45310
  }
44565
45311
  }
44566
45312
  }
@@ -44599,8 +45345,8 @@ var MarketStatusTracker = class {
44599
45345
  return;
44600
45346
  }
44601
45347
  const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
44602
- const now = Date.now();
44603
- const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
45348
+ const now2 = Date.now();
45349
+ const range = { from: now2 - FETCH_BACK_MS, to: now2 + FETCH_AHEAD_MS };
44604
45350
  const [regular, extended] = await Promise.all([
44605
45351
  provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
44606
45352
  provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
@@ -44612,9 +45358,9 @@ var MarketStatusTracker = class {
44612
45358
  }
44613
45359
  const w = { regular, extended };
44614
45360
  const overnight = parseSessionSpec(si)?.overnight === true;
44615
- this.onStatus(deriveMarketStatus(now, w, tz, overnight));
44616
- const boundary = nextStatusBoundary(now, w);
44617
- const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
45361
+ this.onStatus(deriveMarketStatus(now2, w, tz, overnight));
45362
+ const boundary = nextStatusBoundary(now2, w);
45363
+ const delay = Math.min(boundary != null ? boundary - now2 : MAX_TIMER_MS, MAX_TIMER_MS);
44618
45364
  this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
44619
45365
  }
44620
45366
  arm(my, data, symbol, delay) {
@@ -44647,6 +45393,7 @@ var CSS22 = `
44647
45393
  left: var(--vela-toolbar-gutter, 0px);
44648
45394
  right: var(--vela-scale-gutter, 0px);
44649
45395
  display: flex;
45396
+ flex-direction: column;
44650
45397
  align-items: center;
44651
45398
  justify-content: center;
44652
45399
  overflow: hidden;
@@ -44660,6 +45407,14 @@ var CSS22 = `
44660
45407
  user-select: none;
44661
45408
  white-space: nowrap;
44662
45409
  }
45410
+ .vela-watermark-replay {
45411
+ display: inline-flex;
45412
+ align-items: center;
45413
+ gap: 0.25em;
45414
+ margin-top: 0.1em;
45415
+ font-size: 0.8em;
45416
+ }
45417
+ .vela-watermark [hidden] { display: none !important; }
44663
45418
  `;
44664
45419
  function watermarkFontPx(availPx, textPxAtMax) {
44665
45420
  if (textPxAtMax <= 0) return MAX_FONT_PX;
@@ -44670,6 +45425,10 @@ var Watermark = class {
44670
45425
  this.resizeObserver = null;
44671
45426
  /** The host's visibility preference (the persisted watermark toggle). */
44672
45427
  this.shown = true;
45428
+ /** The host's preference for the replay line (its own persisted toggle). */
45429
+ this.replayShown = true;
45430
+ /** The chart is replaying past bars. */
45431
+ this.replaying = false;
44673
45432
  /** A bar load is in flight with nothing painted — the loading affordance owns the
44674
45433
  * canvas, so the mark stays out of its way. Starts true: the FIRST `load:start`
44675
45434
  * fires during chart construction, before any subscriber can see it. */
@@ -44679,7 +45438,10 @@ var Watermark = class {
44679
45438
  this.el.className = "vela-watermark";
44680
45439
  this.el.dataset.velaScreenshot = "under";
44681
45440
  this.text = host.ownerDocument.createElement("span");
44682
- this.el.appendChild(this.text);
45441
+ this.replayLine = host.ownerDocument.createElement("span");
45442
+ this.replayLine.className = "vela-watermark-replay";
45443
+ this.replayLine.append(iconEl("replay", host.ownerDocument), host.ownerDocument.createTextNode("Replay"));
45444
+ this.el.append(this.text, this.replayLine);
44683
45445
  host.appendChild(this.el);
44684
45446
  if (typeof ResizeObserver !== "undefined") {
44685
45447
  this.resizeObserver = new ResizeObserver(() => this.fit());
@@ -44692,13 +45454,26 @@ var Watermark = class {
44692
45454
  this.shown = visible;
44693
45455
  this.sync();
44694
45456
  }
45457
+ /** Show/hide the replay line (the host's toggle); it only ever shows while replaying. */
45458
+ setReplayVisible(visible) {
45459
+ this.replayShown = visible;
45460
+ this.sync();
45461
+ }
45462
+ /** The chart entered or left a bar replay. */
45463
+ setReplaying(replaying) {
45464
+ this.replaying = replaying;
45465
+ this.sync();
45466
+ }
44695
45467
  /** Loading and the watermark never share the canvas — hidden while a load is up. */
44696
45468
  setLoading(loading) {
44697
45469
  this.loading = loading;
44698
45470
  this.sync();
44699
45471
  }
44700
45472
  sync() {
44701
- this.el.style.display = this.shown && !this.loading ? "" : "none";
45473
+ const replay = this.replayShown && this.replaying;
45474
+ this.text.hidden = !this.shown;
45475
+ this.replayLine.hidden = !replay;
45476
+ this.el.style.display = (this.shown || replay) && !this.loading ? "" : "none";
44702
45477
  }
44703
45478
  update(symbol, timeframe) {
44704
45479
  this.text.textContent = symbol ? `${parseSymbol(symbol).ticker} \xB7 ${timeframeLabel(timeframe)}` : "";
@@ -45287,8 +46062,12 @@ var ChartCell = class {
45287
46062
  this.indicatorValuesOn = seed.indicatorValues ?? true;
45288
46063
  if (!this.indicatorValuesOn) this.inner.renderer.set("indicatorValues", false);
45289
46064
  this.watermarkOn = seed.watermark ?? deps.watermark;
46065
+ this.replayWatermarkOn = seed.replayWatermark ?? true;
45290
46066
  this.watermark = deps.watermark ? new Watermark(this.host, symbol ?? "", seed.timeframe ?? "60") : null;
45291
46067
  if (!this.watermarkOn) this.watermark?.setVisible(false);
46068
+ if (!this.replayWatermarkOn) this.watermark?.setReplayVisible(false);
46069
+ this.inner.on("replay:start", () => this.watermark?.setReplaying(true));
46070
+ this.inner.on("replay:end", () => this.watermark?.setReplaying(false));
45292
46071
  this.statusline = deps.statusline ? new Statusline(this.host, symbol ?? "", (sym) => this.inner?.data.symbolIcon(sym)) : null;
45293
46072
  this.statusline?.setMeta(seed.timeframe ?? "60", this.state.provider ?? "");
45294
46073
  this.statusline?.onChart(this.inner);
@@ -45483,10 +46262,10 @@ var ChartCell = class {
45483
46262
  const chart = this.inner;
45484
46263
  const symbol = this.state.symbol;
45485
46264
  if (!chart || !symbol) return;
45486
- const now = Date.now();
46265
+ const now2 = Date.now();
45487
46266
  const requestedSpan = Math.max(this.state.bars ?? 1e3, this.rangeBars) * timeframeMs(this.state.timeframe ?? "60");
45488
46267
  const fallbackSpan = Number.isFinite(requestedSpan) ? Math.max(3 * 864e5, requestedSpan) : 3 * 864e5;
45489
- const range = chart.getVisibleRange() ?? { from: now - fallbackSpan, to: now };
46268
+ const range = chart.getVisibleRange() ?? { from: now2 - fallbackSpan, to: now2 };
45490
46269
  this.sessionShading.track(chart.data, symbol, { session: this.session, timeframe: this.timeframe, range });
45491
46270
  }
45492
46271
  /** The session-shade colors live in the renderer CONFIG (persisted with it, edited
@@ -45602,6 +46381,13 @@ var ChartCell = class {
45602
46381
  id: "visible",
45603
46382
  get: () => this.watermarkOn,
45604
46383
  set: (v) => this.setWatermarkVisible(v)
46384
+ },
46385
+ {
46386
+ kind: "toggle",
46387
+ label: "Replay watermark",
46388
+ id: "replay",
46389
+ get: () => this.replayWatermarkOn,
46390
+ set: (v) => this.setReplayWatermarkVisible(v)
45605
46391
  }
45606
46392
  ]
45607
46393
  };
@@ -45670,6 +46456,12 @@ var ChartCell = class {
45670
46456
  this.watermark?.setVisible(visible);
45671
46457
  this.deps.onStateDirty();
45672
46458
  }
46459
+ /** Show/hide the "Replay" line under this cell's watermark while it replays (persisted per cell). */
46460
+ setReplayWatermarkVisible(visible) {
46461
+ this.replayWatermarkOn = visible;
46462
+ this.watermark?.setReplayVisible(visible);
46463
+ this.deps.onStateDirty();
46464
+ }
45673
46465
  /** Show/hide this cell's indicator titles — the in-chart legend rows (persisted per cell). */
45674
46466
  setIndicatorTitlesVisible(visible) {
45675
46467
  this.indicatorTitlesOn = visible;
@@ -46143,6 +46935,7 @@ var ChartCell = class {
46143
46935
  if (!this.inner || this.destroyed) return;
46144
46936
  if (cs.priceStyle && cs.priceStyle !== this.priceStyle) this.setPriceStyle(cs.priceStyle);
46145
46937
  if (cs.watermark !== void 0 && cs.watermark !== this.watermarkOn) this.setWatermarkVisible(cs.watermark);
46938
+ if (cs.replayWatermark !== void 0 && cs.replayWatermark !== this.replayWatermarkOn) this.setReplayWatermarkVisible(cs.replayWatermark);
46146
46939
  if (cs.indicatorTitles !== void 0 && cs.indicatorTitles !== this.indicatorTitlesOn) this.setIndicatorTitlesVisible(cs.indicatorTitles);
46147
46940
  if (cs.indicatorValues !== void 0 && cs.indicatorValues !== this.indicatorValuesOn) this.setIndicatorValuesVisible(cs.indicatorValues);
46148
46941
  if (cs.rendererConfig != null) this.inner.renderer.applyConfig(cs.rendererConfig);
@@ -46177,6 +46970,7 @@ var ChartCell = class {
46177
46970
  ...live ? { symbol: live.symbol, provider: live.provider, timeframe: live.timeframe } : {},
46178
46971
  priceStyle: this.priceStyle,
46179
46972
  watermark: this.watermarkOn,
46973
+ replayWatermark: this.replayWatermarkOn,
46180
46974
  indicatorTitles: this.indicatorTitlesOn,
46181
46975
  indicatorValues: this.indicatorValuesOn,
46182
46976
  rendererConfig: this.inner?.renderer.getConfig() ?? void 0,
@@ -46241,6 +47035,7 @@ function buildContext(host) {
46241
47035
  setPriceStyle: (style) => host.active()?.setPriceStyle(style),
46242
47036
  openSymbolSearch: (query) => host.openSymbolSearch(query),
46243
47037
  togglePanel: (id, open2) => host.togglePanel(id, open2),
47038
+ dockStrip: (el) => host.dockStrip(el),
46244
47039
  host: host.root,
46245
47040
  toast: (message, kind) => host.toast(message, kind),
46246
47041
  addIndicator: (entry) => host.active()?.addExternalIndicator(entry),
@@ -46252,10 +47047,303 @@ function buildContext(host) {
46252
47047
  get activeCellId() {
46253
47048
  return host.active()?.id ?? "";
46254
47049
  },
46255
- setActiveCell: (id) => host.setActiveCell(id)
47050
+ setActiveCell: (id) => host.setActiveCell(id),
47051
+ replay: host.replay
46256
47052
  };
46257
47053
  }
46258
47054
 
47055
+ // src/workspace/WorkspaceReplay.ts
47056
+ var MAX_BARS_PER_STEP = 1e5;
47057
+ var WorkspaceReplay = class {
47058
+ constructor(host) {
47059
+ this.host = host;
47060
+ this.bus = new TypedEventBus();
47061
+ /** The running session: the shared replay time (every chart shows the bars closed by it). */
47062
+ this.session = null;
47063
+ this.playing = false;
47064
+ this.intervalMs = 1e3;
47065
+ this.timer = null;
47066
+ /** Cells whose replay a symbol switch ended — restarted at the shared time once their bars land. */
47067
+ this.rejoining = /* @__PURE__ */ new Set();
47068
+ this.cellSubs = /* @__PURE__ */ new Map();
47069
+ /** Inside a group step: chart events are the controller's own doing. */
47070
+ this.stepping = false;
47071
+ /** Inside `finish`: the charts' `replay:end` events are the controller's own doing. */
47072
+ this.stopping = false;
47073
+ /** Driving a chart's own timer: its play/pause events are not the user's. */
47074
+ this.muted = 0;
47075
+ /** A chart finished during a group step — the session ends right after it. */
47076
+ this.finishedDuringStep = false;
47077
+ for (const cell of host.cells()) this.watch(cell);
47078
+ this.offHost = host.onCells((e) => {
47079
+ if (e.kind === "created") this.onCellCreated(e.id);
47080
+ else if (e.kind === "destroyed") this.onCellDestroyed(e.id);
47081
+ });
47082
+ }
47083
+ /**
47084
+ * Enter replay on every cell (or seek, when already replaying) — paused. Resolves once
47085
+ * every chart shows its rewound history (a chart whose history does not reach that far
47086
+ * loads older bars first). A chart with nothing to replay after the shared time stays
47087
+ * as it is.
47088
+ */
47089
+ async start(opts) {
47090
+ const cells = this.host.cells();
47091
+ const ref = cells.find((c) => c.id === (opts.cell ?? this.host.activeId())) ?? cells[0];
47092
+ if (!ref) return;
47093
+ if (this.playing) this.pause();
47094
+ this.rejoining.clear();
47095
+ await ref.chart.replay.start({ from: opts.from });
47096
+ const cursor = ref.chart.replay.state.cursorTime;
47097
+ if (cursor == null) return;
47098
+ const session = { clock: barClose(cursor, ref.chart.market.timeframe) };
47099
+ this.session = session;
47100
+ await Promise.all(this.host.cells().filter((c) => c.id !== ref.id).map((c) => this.startCell(c, session.clock)));
47101
+ if (this.session !== session) return;
47102
+ this.syncPlayback();
47103
+ const s = this.activeChart()?.replay.state;
47104
+ this.bus.emit("replay:start", { cursorTime: s?.cursorTime ?? cursor, remaining: s?.remaining ?? 0 });
47105
+ }
47106
+ /** Reveal the next bar(s): the clock moves to the next bar close on any chart. Returns false when nothing is left. */
47107
+ step() {
47108
+ const solo = this.solo();
47109
+ return solo ? solo.replay.step() : this.stepGroup();
47110
+ }
47111
+ /** Reveal the next update — in a single-chart layout, the chart's `stepUpdate()` (tick replay); otherwise {@link step}. */
47112
+ stepUpdate() {
47113
+ const solo = this.solo();
47114
+ return solo ? solo.replay.stepUpdate() : this.stepGroup();
47115
+ }
47116
+ /** Advance one update every `intervalMs` (default: the last pace, initially 1000). Calling it again while playing changes the pace. */
47117
+ play(intervalMs2) {
47118
+ if (!this.session) return this;
47119
+ if (intervalMs2 !== void 0) {
47120
+ if (!(Number.isFinite(intervalMs2) && intervalMs2 > 0)) {
47121
+ console.warn(`[vela] workspace.replay.play(${intervalMs2}) ignored \u2014 the interval must be a positive number of ms`);
47122
+ return this;
47123
+ }
47124
+ this.intervalMs = intervalMs2;
47125
+ }
47126
+ this.playing = true;
47127
+ this.clearTimer();
47128
+ this.syncPlayback();
47129
+ this.bus.emit("replay:play", { intervalMs: this.intervalMs });
47130
+ return this;
47131
+ }
47132
+ pause() {
47133
+ if (!this.playing) return this;
47134
+ this.playing = false;
47135
+ this.syncPlayback();
47136
+ this.bus.emit("replay:pause", void 0);
47137
+ return this;
47138
+ }
47139
+ /** Leave replay on every chart: full history back, live updates resumed. */
47140
+ stop() {
47141
+ this.finish("stopped");
47142
+ return this;
47143
+ }
47144
+ /** `active`/`playing`/`intervalMs` for the whole workspace; the cursor fields read the ACTIVE cell's chart. */
47145
+ get state() {
47146
+ const s = this.session ? this.activeChart()?.replay.state : void 0;
47147
+ return {
47148
+ active: this.session !== null,
47149
+ playing: this.playing,
47150
+ cursorTime: s?.cursorTime ?? null,
47151
+ remaining: s?.remaining ?? 0,
47152
+ nextTime: s?.nextTime ?? null,
47153
+ intervalMs: this.intervalMs
47154
+ };
47155
+ }
47156
+ /** The active cell's replayable history (`chart.replay.bounds`). */
47157
+ get bounds() {
47158
+ return this.activeChart()?.replay.bounds ?? null;
47159
+ }
47160
+ on(event, handler) {
47161
+ return this.bus.on(event, handler);
47162
+ }
47163
+ destroy() {
47164
+ this.session = null;
47165
+ this.playing = false;
47166
+ this.clearTimer();
47167
+ this.offHost();
47168
+ for (const off of this.cellSubs.values()) off();
47169
+ this.cellSubs.clear();
47170
+ this.bus.clear();
47171
+ }
47172
+ // ── internals ──
47173
+ /** The one chart of a single-chart layout — it replays on its own, tick replay included. */
47174
+ solo() {
47175
+ const cells = this.host.cells();
47176
+ return cells.length === 1 ? cells[0].chart : null;
47177
+ }
47178
+ activeChart() {
47179
+ const cells = this.host.cells();
47180
+ return (cells.find((c) => c.id === this.host.activeId()) ?? cells[0])?.chart ?? null;
47181
+ }
47182
+ /** Rewind one chart to the shared time: every bar that closed by `clock`, none after. */
47183
+ async startCell(cell, clock) {
47184
+ const replay = cell.chart.replay;
47185
+ const from = lastOpenClosedBy(clock, cell.chart.market.timeframe);
47186
+ const bounds = replay.bounds;
47187
+ if (!bounds || bounds.last <= from) return;
47188
+ await replay.start({ from });
47189
+ }
47190
+ /** Move the clock to the next bar close on any chart; each chart reveals what closed by then. */
47191
+ stepGroup() {
47192
+ const session = this.session;
47193
+ if (!session) return false;
47194
+ const cells = this.host.cells();
47195
+ let next = Infinity;
47196
+ for (const c of cells) {
47197
+ const t = c.chart.replay.state.nextTime;
47198
+ if (t != null) next = Math.min(next, barClose(t, c.chart.market.timeframe));
47199
+ }
47200
+ if (!Number.isFinite(next)) return false;
47201
+ this.stepping = true;
47202
+ try {
47203
+ for (const c of cells) {
47204
+ const tf = c.chart.market.timeframe;
47205
+ for (let n = 0; n < MAX_BARS_PER_STEP; n += 1) {
47206
+ const t = c.chart.replay.state.nextTime;
47207
+ if (t == null || barClose(t, tf) > next) break;
47208
+ if (!c.chart.replay.step()) break;
47209
+ }
47210
+ }
47211
+ } finally {
47212
+ this.stepping = false;
47213
+ }
47214
+ session.clock = next;
47215
+ if (this.finishedDuringStep) {
47216
+ this.finishedDuringStep = false;
47217
+ this.finish("finished");
47218
+ return true;
47219
+ }
47220
+ const s = this.activeChart()?.replay.state;
47221
+ if (s?.cursorTime != null) this.bus.emit("replay:step", { cursorTime: s.cursorTime, remaining: s.remaining });
47222
+ return true;
47223
+ }
47224
+ /** Put the timers in the state `playing` asks for: a single chart runs its own (it paces
47225
+ * ticks), several run on the controller's clock. */
47226
+ syncPlayback() {
47227
+ const solo = this.solo();
47228
+ const run = this.playing && this.session !== null;
47229
+ this.muted += 1;
47230
+ try {
47231
+ for (const c of this.host.cells()) {
47232
+ if (c.chart === solo && run) {
47233
+ if (!c.chart.replay.state.playing || c.chart.replay.state.intervalMs !== this.intervalMs) c.chart.replay.play(this.intervalMs);
47234
+ } else if (c.chart.replay.state.playing) {
47235
+ c.chart.replay.pause();
47236
+ }
47237
+ }
47238
+ } finally {
47239
+ this.muted -= 1;
47240
+ }
47241
+ if (!run || solo) this.clearTimer();
47242
+ else if (this.timer == null) this.schedule();
47243
+ }
47244
+ schedule() {
47245
+ this.timer = setTimeout(() => {
47246
+ this.timer = null;
47247
+ if (!this.playing || !this.session || this.solo()) return;
47248
+ if (this.stepGroup() || this.rejoining.size > 0) {
47249
+ if (this.playing && this.session && this.timer == null) this.schedule();
47250
+ } else {
47251
+ this.pause();
47252
+ }
47253
+ }, this.intervalMs);
47254
+ }
47255
+ clearTimer() {
47256
+ if (this.timer != null) clearTimeout(this.timer);
47257
+ this.timer = null;
47258
+ }
47259
+ /** End the session on every chart (idempotent). */
47260
+ finish(reason) {
47261
+ const had = this.session !== null;
47262
+ this.session = null;
47263
+ this.playing = false;
47264
+ this.clearTimer();
47265
+ this.rejoining.clear();
47266
+ this.stopping = true;
47267
+ try {
47268
+ for (const c of this.host.cells()) c.chart.replay.stop();
47269
+ } finally {
47270
+ this.stopping = false;
47271
+ }
47272
+ if (had) this.bus.emit("replay:end", { reason });
47273
+ }
47274
+ watch(cell) {
47275
+ const { id, chart } = cell;
47276
+ const isActive = () => (this.host.activeId() ?? this.host.cells()[0]?.id) === id;
47277
+ const offs = [
47278
+ chart.on("replay:end", ({ reason }) => {
47279
+ if (!this.session || this.stopping) return;
47280
+ if (reason === "market") this.rejoining.add(id);
47281
+ else if (this.stepping) this.finishedDuringStep || (this.finishedDuringStep = reason === "finished");
47282
+ else this.finish(reason);
47283
+ }),
47284
+ chart.on("market:changed", () => {
47285
+ if (!this.rejoining.delete(id) || !this.session) return;
47286
+ void this.startCell(cell, this.session.clock).then(() => this.syncPlayback());
47287
+ }),
47288
+ chart.on("replay:step", (e) => {
47289
+ if (this.session && !this.stepping && isActive()) this.bus.emit("replay:step", e);
47290
+ }),
47291
+ chart.on("replay:tick", (e) => {
47292
+ if (this.session && isActive()) this.bus.emit("replay:tick", e);
47293
+ }),
47294
+ // A single chart runs its own timer: a pause it takes on its own (a seek) is the session's.
47295
+ chart.on("replay:pause", () => {
47296
+ if (!this.session || this.muted > 0 || !this.playing || chart !== this.solo()) return;
47297
+ this.playing = false;
47298
+ this.bus.emit("replay:pause", void 0);
47299
+ })
47300
+ ];
47301
+ this.cellSubs.set(id, () => {
47302
+ for (const off of offs) off();
47303
+ });
47304
+ }
47305
+ onCellCreated(id) {
47306
+ const cell = this.host.cells().find((c) => c.id === id);
47307
+ if (!cell) return;
47308
+ this.watch(cell);
47309
+ const session = this.session;
47310
+ if (!session) return;
47311
+ this.rejoining.add(id);
47312
+ void cell.chart.ready().then(async () => {
47313
+ if (this.session !== session || !this.rejoining.delete(id)) return;
47314
+ await this.startCell(cell, session.clock);
47315
+ this.syncPlayback();
47316
+ });
47317
+ this.syncPlayback();
47318
+ }
47319
+ onCellDestroyed(id) {
47320
+ this.cellSubs.get(id)?.();
47321
+ this.cellSubs.delete(id);
47322
+ this.rejoining.delete(id);
47323
+ this.syncPlayback();
47324
+ }
47325
+ };
47326
+ function barClose(open2, timeframe) {
47327
+ const months = monthSpan(timeframe);
47328
+ if (months > 0) {
47329
+ const d = new Date(open2);
47330
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
47331
+ }
47332
+ return open2 + timeframeToMs(timeframe ?? "60");
47333
+ }
47334
+ function lastOpenClosedBy(time, timeframe) {
47335
+ const months = monthSpan(timeframe);
47336
+ if (months > 0) {
47337
+ const d = new Date(time);
47338
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
47339
+ }
47340
+ return time - timeframeToMs(timeframe ?? "60");
47341
+ }
47342
+ function monthSpan(timeframe) {
47343
+ const m = /^(\d*)M$/.exec((timeframe ?? "").trim());
47344
+ return m ? Number(m[1] || 1) : 0;
47345
+ }
47346
+
46259
47347
  // src/workspace/layouts.ts
46260
47348
  var registry5 = /* @__PURE__ */ new Map();
46261
47349
  function registerLayout(def) {
@@ -46560,6 +47648,8 @@ var CSS24 = `
46560
47648
  .vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
46561
47649
  .vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
46562
47650
  .vela-ws-toolbar { position: relative; flex: none; }
47651
+ .vela-ws-strips { position: relative; flex: none; display: flex; flex-direction: column; min-width: 0; }
47652
+ .vela-ws-strips:empty { display: none; }
46563
47653
  .vela-ws-grid { position: relative; flex: 1 1 auto; min-width: 0; display: grid; gap: ${GAP_PX}px; background: var(--vela-border-soft); }
46564
47654
  .vela-cell { background: var(--vela-bg); position: relative; }
46565
47655
  /* Active-cell highlight: an overlay ring ABOVE the chart's own canvas stack (a plain
@@ -46731,6 +47821,20 @@ var VelaWorkspace = class {
46731
47821
  const hostEl = typeof container === "string" ? document.querySelector(container) : container;
46732
47822
  if (!hostEl) throw new Error(`VelaWorkspace: container not found: ${String(container)}`);
46733
47823
  this.opts = opts;
47824
+ this.replay = new WorkspaceReplay({
47825
+ cells: () => this.cells(),
47826
+ activeId: () => this.activeId,
47827
+ onCells: (handler) => {
47828
+ const offs = [
47829
+ this.events.on("cell:created", ({ id }) => handler({ kind: "created", id })),
47830
+ this.events.on("cell:destroyed", ({ id }) => handler({ kind: "destroyed", id })),
47831
+ this.events.on("cell:active", ({ id }) => handler({ kind: "active", id }))
47832
+ ];
47833
+ return () => {
47834
+ for (const off of offs) off();
47835
+ };
47836
+ }
47837
+ });
46734
47838
  this.persistKey = opts.persist === void 0 || opts.persist === false ? null : opts.persist === true ? "vela-workspace" : opts.persist;
46735
47839
  this.storage = opts.storage ?? localStorageAdapter();
46736
47840
  let boot = null;
@@ -46868,6 +47972,9 @@ var VelaWorkspace = class {
46868
47972
  this.dock.refresh();
46869
47973
  if (boot?.panels) this.dock.applyState(boot.panels);
46870
47974
  this.root.appendChild(main);
47975
+ this.stripsEl = doc.createElement("div");
47976
+ this.stripsEl.className = "vela-ws-strips";
47977
+ this.root.appendChild(this.stripsEl);
46871
47978
  this.toastHost = new Toast(this.gridEl);
46872
47979
  const attribution = rendererDefaults().attribution;
46873
47980
  if (attribution !== false) {
@@ -47102,9 +48209,16 @@ var VelaWorkspace = class {
47102
48209
  setActiveCell: (id) => this.setActiveCell(id),
47103
48210
  openSymbolSearch: (query) => this.symbolPicker.open(query ?? ""),
47104
48211
  togglePanel: (id, open2) => this.dock.toggle(id, open2),
48212
+ dockStrip: (el) => {
48213
+ this.stripsEl.appendChild(el);
48214
+ return () => {
48215
+ if (el.parentElement === this.stripsEl) el.remove();
48216
+ };
48217
+ },
47105
48218
  root: this.root,
47106
48219
  toast: (message, kind) => this.toastHost.show(message, kind),
47107
- stateDirty: () => this.markStateDirty()
48220
+ stateDirty: () => this.markStateDirty(),
48221
+ replay: this.replay
47108
48222
  });
47109
48223
  }
47110
48224
  /** Re-project contributed topbar actions + side panels, and mount late-registered attachments. */
@@ -47412,6 +48526,7 @@ var VelaWorkspace = class {
47412
48526
  if (this.destroyed) return;
47413
48527
  this.flushPendingState();
47414
48528
  this.destroyed = true;
48529
+ this.replay.destroy();
47415
48530
  if (this.persistKey !== null && typeof window !== "undefined") window.removeEventListener("beforeunload", this.onUnload);
47416
48531
  this.resizeObserver?.disconnect();
47417
48532
  this.splitters.destroy();
@@ -48178,14 +49293,13 @@ var VelaWorkspace = class {
48178
49293
  panels: () => has("panels") ? [...this.dock.list()] : [],
48179
49294
  onTogglePanel: (id) => this.dock.toggle(id),
48180
49295
  ...has("alerts") ? { alerts: () => this.alerts.map((a) => ({ title: `${a.source} \xB7 ${a.title}`, message: a.message, time: a.time })) } : {},
48181
- // Left-aligned actions have their own bottom-bar stop — only the rest
48182
- // lands in the drawer, or every left action would appear twice. Built-in-id
48183
- // actions are slot OVERRIDES: they reach the drawer through the slot's own
48184
- // routed button (screenshot) or stop (indicators), never as an extra row.
48185
- actions: () => {
48186
- const builtin = new Set(TOPBAR_BUILTIN_IDS);
48187
- 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()) }));
48188
- },
49296
+ // Actions placed on the bottom bar (left-aligned by default) have their stop
49297
+ // there — only the menu-placed ones land in the drawer, or they would appear
49298
+ // twice: left (primary) ones with the primary rows, right ones at the end.
49299
+ // Built-in-id actions are slot OVERRIDES: they reach the drawer through the
49300
+ // slot's own routed button (screenshot) or stop (indicators), never as a row.
49301
+ primaryActions: () => this.drawerActions("left"),
49302
+ actions: () => this.drawerActions("right"),
48189
49303
  // The desktop layout dropdown's whole surface — the grid canvas, the
48190
49304
  // non-canvas presets and the sync switches — relocated into the kebab
48191
49305
  // drawer (the topbar is hidden on mobile). Same reads as the topbar block;
@@ -48210,6 +49324,11 @@ var VelaWorkspace = class {
48210
49324
  }));
48211
49325
  this.moreDrawer.open();
48212
49326
  }
49327
+ /** The contributed topbar actions the mobile menu lists, from one cluster. */
49328
+ drawerActions(cluster) {
49329
+ const builtin = new Set(TOPBAR_BUILTIN_IDS);
49330
+ 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()) }));
49331
+ }
48213
49332
  openTimezoneDrawer() {
48214
49333
  this.timezoneDrawer ?? (this.timezoneDrawer = new TimezoneDrawer({
48215
49334
  host: this.root,
@@ -48376,12 +49495,15 @@ var VelaWorkspace = class {
48376
49495
  exports.ChartCell = ChartCell;
48377
49496
  exports.GRID_PICKER_MAX = GRID_PICKER_MAX;
48378
49497
  exports.VelaWorkspace = VelaWorkspace;
49498
+ exports.WorkspaceReplay = WorkspaceReplay;
48379
49499
  exports.activeAfterLayout = activeAfterLayout;
49500
+ exports.barClose = barClose;
48380
49501
  exports.decodeState = decodeState;
48381
49502
  exports.encodeState = encodeState;
48382
49503
  exports.ensureLayout = ensureLayout;
48383
49504
  exports.evenTracks = evenTracks;
48384
49505
  exports.gridStyles = gridStyles;
49506
+ exports.lastOpenClosedBy = lastOpenClosedBy;
48385
49507
  exports.layoutDefinition = layoutDefinition;
48386
49508
  exports.layoutForGrid = layoutForGrid;
48387
49509
  exports.layoutShape = layoutShape;