@luxalgo/vela 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/NOTICE +28 -28
  2. package/README.md +15 -7
  3. package/dist/{DataProvider-CGa4KHRa.d.cts → DataProvider-BrlxntPN.d.cts} +1 -1
  4. package/dist/{DataProvider-CmWUtDVL.d.ts → DataProvider-DwFIoV4r.d.ts} +1 -1
  5. package/dist/{chunk-N4ZZYLMP.js → chunk-ATGTCWHJ.js} +726 -273
  6. package/dist/{chunk-CAFCLMPF.js → chunk-BZQM2XO7.js} +1 -0
  7. package/dist/{chunk-M6GP7DAX.js → chunk-DBDXKZUF.js} +474 -47
  8. package/dist/{chunk-BFA32GOU.js → chunk-EZ5FWVLA.js} +5 -2
  9. package/dist/{chunk-PVGWNF7V.js → chunk-LMBYEGUR.js} +2 -2
  10. package/dist/{chunk-WTWGEB7K.js → chunk-NELQJCGK.js} +298 -5
  11. package/dist/{contributions-DF_Ea07m.d.cts → contributions-CtlptxR8.d.cts} +187 -5
  12. package/dist/{contributions-nKOSeLy8.d.ts → contributions-tjNExo1o.d.ts} +187 -5
  13. package/dist/index.cjs +1044 -272
  14. package/dist/index.d.cts +24 -8
  15. package/dist/index.d.ts +24 -8
  16. package/dist/index.js +28 -4
  17. package/dist/{options-D1AJKsn_.d.ts → options-BnEPUuVV.d.cts} +1 -1
  18. package/dist/{options-D1AJKsn_.d.cts → options-BnEPUuVV.d.ts} +1 -1
  19. package/dist/{plugin-6F04gjQe.d.cts → plugin-BWxJRKzS.d.cts} +10 -4
  20. package/dist/{plugin-BLsNipkc.d.ts → plugin-e2WUEtiI.d.ts} +10 -4
  21. package/dist/plugin.cjs +1 -0
  22. package/dist/plugin.d.cts +4 -4
  23. package/dist/plugin.d.ts +4 -4
  24. package/dist/plugin.js +2 -2
  25. package/dist/providers/binance.d.cts +2 -2
  26. package/dist/providers/binance.d.ts +2 -2
  27. package/dist/providers/coinbase.d.cts +2 -2
  28. package/dist/providers/coinbase.d.ts +2 -2
  29. package/dist/providers/hyperliquid.d.cts +2 -2
  30. package/dist/providers/hyperliquid.d.ts +2 -2
  31. package/dist/{statusline-model-DAFyqVmR.d.ts → statusline-model-DFY7tuHj.d.cts} +5 -3
  32. package/dist/{statusline-model-BoWJ3cWQ.d.cts → statusline-model-zKXtGIjj.d.ts} +5 -3
  33. package/dist/ui.cjs +301 -3
  34. package/dist/ui.d.cts +75 -2
  35. package/dist/ui.d.ts +75 -2
  36. package/dist/ui.js +3 -3
  37. package/dist/vela.global.js +1046 -274
  38. package/dist/vela.global.min.js +82 -87
  39. package/dist/widget.cjs +1504 -327
  40. package/dist/widget.d.cts +26 -7
  41. package/dist/widget.d.ts +26 -7
  42. package/dist/widget.js +7 -7
  43. package/dist/workspace.cjs +1507 -327
  44. package/dist/workspace.d.cts +125 -7
  45. package/dist/workspace.d.ts +125 -7
  46. package/dist/workspace.js +6 -6
  47. package/package.json +2 -2
package/dist/widget.cjs CHANGED
@@ -1230,6 +1230,7 @@ registerIcon("chevron-down", S('<path d="M3.5 6 8 10.5 12.5 6"/>'));
1230
1230
  registerIcon("chevron-up", S('<path d="M3.5 10 8 5.5 12.5 10"/>'));
1231
1231
  registerIcon("chevrons-right", S('<path d="m4 3.5 4.5 4.5L4 12.5"/><path d="m8.5 3.5 4.5 4.5-4.5 4.5"/>'));
1232
1232
  registerIcon("chevrons-left", S('<path d="M12 3.5 7.5 8l4.5 4.5"/><path d="M7.5 3.5 3 8l4.5 4.5"/>'));
1233
+ registerIcon("replay", S('<path d="M14 4.2v7.6L8.6 8z"/><path d="M7.4 4.2v7.6L2 8z"/>'));
1233
1234
  registerIcon("check", S('<path d="m2.8 8.4 3.4 3.4 7-7.6"/>'));
1234
1235
  registerIcon("close", S('<path d="m3.8 3.8 8.4 8.4M12.2 3.8l-8.4 8.4"/>'));
1235
1236
  registerIcon("pin", S('<path d="M5.6 2.5h4.8M6.4 2.5v3.6L4.4 8.4v.9h7.2v-.9L9.6 6.1V2.5M8 9.3v4.2"/>'));
@@ -3616,14 +3617,49 @@ function buildColorPicker(color, theme, onChange, options = {}) {
3616
3617
  const knob = document.createElement("div");
3617
3618
  knob.style.cssText = "position:absolute;top:50%;width:15px;height:15px;border-radius:50%;background:var(--vela-selected-bg);box-shadow:0 1px 3px rgba(0,0,0,0.55);transform:translate(-50%,-50%);pointer-events:none;";
3618
3619
  track.appendChild(knob);
3619
- const pctBox = document.createElement("div");
3620
- pctBox.style.cssText = `min-width:42px;text-align:center;font:var(--vela-font-size-md) ${theme.fontFamily};color:var(--vela-fg);border:1px solid var(--vela-border);border-radius:5px;padding:3px 4px;`;
3620
+ const pctBox = document.createElement("input");
3621
+ pctBox.type = "text";
3622
+ pctBox.inputMode = "numeric";
3623
+ pctBox.title = "Opacity (%)";
3624
+ pctBox.style.cssText = `width:46px;box-sizing:border-box;text-align:center;font:var(--vela-font-size-md) ${theme.fontFamily};color:var(--vela-fg);background:transparent;border:1px solid var(--vela-border);border-radius:5px;padding:3px 4px;outline:none;`;
3625
+ pctBox.addEventListener("focus", () => {
3626
+ pctBox.style.borderColor = "var(--vela-border-strong)";
3627
+ pctBox.select();
3628
+ });
3621
3629
  opRow.append(track, pctBox);
3622
3630
  const paintOpacity = () => {
3623
3631
  track.style.background = `linear-gradient(to right, ${curHex}00, ${curHex}ff), ${CHECKER}`;
3624
3632
  knob.style.left = `${curAlpha * 100}%`;
3625
- pctBox.textContent = `${Math.round(curAlpha * 100)}%`;
3633
+ pctBox.value = `${Math.round(curAlpha * 100)}%`;
3626
3634
  };
3635
+ const commitTyped = () => {
3636
+ pctBox.style.borderColor = "var(--vela-border)";
3637
+ const n = parseFloat(pctBox.value.replace("%", ""));
3638
+ if (Number.isFinite(n)) {
3639
+ const next = Math.max(0, Math.min(100, n)) / 100;
3640
+ if (Math.round(next * 100) !== Math.round(curAlpha * 100)) {
3641
+ curAlpha = next;
3642
+ paintOpacity();
3643
+ emit();
3644
+ return;
3645
+ }
3646
+ }
3647
+ paintOpacity();
3648
+ };
3649
+ pctBox.addEventListener("change", commitTyped);
3650
+ pctBox.addEventListener("blur", () => {
3651
+ pctBox.style.borderColor = "var(--vela-border)";
3652
+ paintOpacity();
3653
+ });
3654
+ pctBox.addEventListener("keydown", (e) => {
3655
+ if (e.key === "Escape") return;
3656
+ e.stopPropagation();
3657
+ if (e.key === "Enter") {
3658
+ e.preventDefault();
3659
+ commitTyped();
3660
+ pctBox.blur();
3661
+ }
3662
+ });
3627
3663
  let dragging = false;
3628
3664
  const onDrag = (clientX) => {
3629
3665
  const r = track.getBoundingClientRect();
@@ -4550,6 +4586,264 @@ var CalloutBubble = class {
4550
4586
  }
4551
4587
  };
4552
4588
 
4589
+ // src/ui/components/date-picker/controller.ts
4590
+ var MONTH_LABELS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
4591
+ var MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4592
+ var WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
4593
+ function isoDate(d) {
4594
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
4595
+ }
4596
+ function normalizeDateInput(raw) {
4597
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw.trim());
4598
+ if (!m) return null;
4599
+ const y = Number(m[1]);
4600
+ const mo = Number(m[2]);
4601
+ const d = Number(m[3]);
4602
+ const dt = new Date(y, mo - 1, d);
4603
+ if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
4604
+ return isoDate(dt);
4605
+ }
4606
+ function parseIsoDate(raw) {
4607
+ const iso = raw ? normalizeDateInput(raw) : null;
4608
+ if (!iso) return null;
4609
+ const [y, mo, d] = iso.split("-").map(Number);
4610
+ return new Date(y, mo - 1, d);
4611
+ }
4612
+ function datePickerController(opts = {}) {
4613
+ const today = opts.today ?? /* @__PURE__ */ new Date();
4614
+ let selected = parseIsoDate(opts.value);
4615
+ let year = selected?.getFullYear() ?? today.getFullYear();
4616
+ let month = selected?.getMonth() ?? today.getMonth();
4617
+ let panel = "date";
4618
+ const decade = () => Math.floor(year / 10) * 10;
4619
+ return {
4620
+ get panel() {
4621
+ return panel;
4622
+ },
4623
+ get year() {
4624
+ return year;
4625
+ },
4626
+ get month() {
4627
+ return month;
4628
+ },
4629
+ get value() {
4630
+ return selected ? isoDate(selected) : null;
4631
+ },
4632
+ header() {
4633
+ return {
4634
+ month: MONTH_LABELS[month] ?? "",
4635
+ monthVisible: panel === "date",
4636
+ year: panel === "year" ? `${decade()}-${decade() + 9}` : String(year),
4637
+ yearDisabled: panel === "year"
4638
+ // in the decade panel the year reads as its title
4639
+ };
4640
+ },
4641
+ navLabels() {
4642
+ if (panel === "date") return { prev: "Previous month", next: "Next month" };
4643
+ if (panel === "month") return { prev: "Previous year", next: "Next year" };
4644
+ return { prev: "Previous decade", next: "Next decade" };
4645
+ },
4646
+ cells() {
4647
+ const out = [];
4648
+ if (panel === "date") {
4649
+ const startPad = new Date(year, month, 1).getDay();
4650
+ const days = new Date(year, month + 1, 0).getDate();
4651
+ const todayIso = isoDate(today);
4652
+ const selectedIso = selected ? isoDate(selected) : "";
4653
+ for (let i = 0; i < startPad; i++) out.push({ label: "", value: "", checked: false, today: false, blank: true });
4654
+ for (let day = 1; day <= days; day++) {
4655
+ const iso = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
4656
+ out.push({ label: String(day), value: iso, checked: iso === selectedIso, today: iso === todayIso });
4657
+ }
4658
+ return out;
4659
+ }
4660
+ if (panel === "month") {
4661
+ for (let m = 0; m < 12; m++) {
4662
+ out.push({
4663
+ label: MONTH_SHORT[m] ?? "",
4664
+ value: m,
4665
+ checked: selected?.getFullYear() === year && selected.getMonth() === m,
4666
+ today: today.getFullYear() === year && today.getMonth() === m
4667
+ });
4668
+ }
4669
+ return out;
4670
+ }
4671
+ const d = decade();
4672
+ for (let y = d - 1; y <= d + 10; y++) {
4673
+ out.push({ label: String(y), value: y, checked: selected?.getFullYear() === y, today: today.getFullYear() === y, outside: y < d || y > d + 9 });
4674
+ }
4675
+ return out;
4676
+ },
4677
+ step(dir) {
4678
+ if (panel === "date") {
4679
+ month += dir;
4680
+ if (month < 0) {
4681
+ month = 11;
4682
+ year -= 1;
4683
+ }
4684
+ if (month > 11) {
4685
+ month = 0;
4686
+ year += 1;
4687
+ }
4688
+ } else {
4689
+ year += panel === "month" ? dir : dir * 10;
4690
+ }
4691
+ },
4692
+ showMonths() {
4693
+ panel = "month";
4694
+ },
4695
+ showYears() {
4696
+ panel = "year";
4697
+ },
4698
+ choose(cell) {
4699
+ if (cell.blank) return null;
4700
+ if (panel === "date") {
4701
+ selected = parseIsoDate(String(cell.value));
4702
+ return selected ? isoDate(selected) : null;
4703
+ }
4704
+ if (panel === "month") {
4705
+ month = Number(cell.value);
4706
+ panel = "date";
4707
+ return null;
4708
+ }
4709
+ year = Number(cell.value);
4710
+ panel = "month";
4711
+ return null;
4712
+ },
4713
+ setValue(value) {
4714
+ selected = parseIsoDate(value);
4715
+ if (selected) {
4716
+ year = selected.getFullYear();
4717
+ month = selected.getMonth();
4718
+ }
4719
+ panel = "date";
4720
+ }
4721
+ };
4722
+ }
4723
+
4724
+ // src/ui/components/date-picker/styles.ts
4725
+ var DATE_PICKER_STYLE_ID = "vela-date-picker-styles";
4726
+ var DATE_PICKER_CSS = `
4727
+ .vela-date-picker{color:var(--vela-fg);font:14px var(--vela-font);user-select:none;}
4728
+ .vela-date-picker [hidden]{display:none !important;}
4729
+ .vela-date-picker-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
4730
+ .vela-date-picker-title{flex:1;display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;}
4731
+ .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;}
4732
+ .vela-date-picker-switch:not(:disabled):hover{background:var(--vela-hover);}
4733
+ .vela-date-picker-switch:disabled{cursor:default;}
4734
+ .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;}
4735
+ .vela-date-picker-nav:hover{background:var(--vela-hover);color:var(--vela-fg-bright);}
4736
+ .vela-date-picker-week,.vela-date-picker-grid{display:grid;grid-template-columns:repeat(7,28px);gap:2px;}
4737
+ .vela-date-picker-week{margin-bottom:4px;color:var(--vela-fg-muted);font-size:11px;text-align:center;}
4738
+ .vela-date-picker-week span{line-height:20px;}
4739
+ .vela-date-picker-blank{width:28px;height:28px;}
4740
+ .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;}
4741
+ .vela-date-picker-day:hover{background:var(--vela-hover);}
4742
+ .vela-date-picker-day[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
4743
+ .vela-date-picker-day[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
4744
+ .vela-date-picker-cells{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;width:calc(7 * 28px + 6 * 2px);}
4745
+ .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;}
4746
+ .vela-date-picker-cell:hover{background:var(--vela-hover);}
4747
+ .vela-date-picker-cell[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
4748
+ .vela-date-picker-cell[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
4749
+ .vela-date-picker-cell[data-outside]{opacity:0.45;}
4750
+ `;
4751
+
4752
+ // src/ui/components/date-picker/view.ts
4753
+ var DatePicker = class {
4754
+ constructor(opts = {}) {
4755
+ this.opts = opts;
4756
+ const doc = document;
4757
+ injectStyles(DATE_PICKER_STYLE_ID, DATE_PICKER_CSS, doc);
4758
+ this.ctrl = datePickerController(opts);
4759
+ const button = (className) => {
4760
+ const b = doc.createElement("button");
4761
+ b.type = "button";
4762
+ b.className = className;
4763
+ return b;
4764
+ };
4765
+ this.prev = button("vela-date-picker-nav");
4766
+ this.prev.append(iconEl("chevron-left", doc));
4767
+ this.next = button("vela-date-picker-nav");
4768
+ this.next.append(iconEl("chevron-right", doc));
4769
+ this.monthBtn = button("vela-date-picker-switch");
4770
+ this.monthBtn.setAttribute("aria-label", "Choose month");
4771
+ this.yearBtn = button("vela-date-picker-switch");
4772
+ this.yearBtn.setAttribute("aria-label", "Choose year");
4773
+ const title = doc.createElement("div");
4774
+ title.className = "vela-date-picker-title";
4775
+ title.append(this.monthBtn, this.yearBtn);
4776
+ const head = doc.createElement("div");
4777
+ head.className = "vela-date-picker-head";
4778
+ head.append(this.prev, title, this.next);
4779
+ this.week = doc.createElement("div");
4780
+ this.week.className = "vela-date-picker-week";
4781
+ for (const d of WEEKDAY_LABELS) {
4782
+ const cell = doc.createElement("span");
4783
+ cell.textContent = d;
4784
+ this.week.appendChild(cell);
4785
+ }
4786
+ this.grid = doc.createElement("div");
4787
+ const on = (el, run) => el.addEventListener("click", (e) => {
4788
+ e.stopPropagation();
4789
+ run();
4790
+ this.paint();
4791
+ });
4792
+ on(this.prev, () => this.ctrl.step(-1));
4793
+ on(this.next, () => this.ctrl.step(1));
4794
+ on(this.monthBtn, () => this.ctrl.showMonths());
4795
+ on(this.yearBtn, () => this.ctrl.showYears());
4796
+ this.el = doc.createElement("div");
4797
+ this.el.className = "vela-date-picker";
4798
+ this.el.append(head, this.week, this.grid);
4799
+ this.paint();
4800
+ }
4801
+ get value() {
4802
+ return this.ctrl.value;
4803
+ }
4804
+ /** Rewrite the selection without emitting (the calendar opens on its month). */
4805
+ setValue(value) {
4806
+ this.ctrl.setValue(value);
4807
+ this.paint();
4808
+ }
4809
+ paint() {
4810
+ const doc = this.el.ownerDocument;
4811
+ const h = this.ctrl.header();
4812
+ this.monthBtn.hidden = !h.monthVisible;
4813
+ this.monthBtn.textContent = h.month;
4814
+ this.yearBtn.textContent = h.year;
4815
+ this.yearBtn.disabled = h.yearDisabled;
4816
+ const nav = this.ctrl.navLabels();
4817
+ this.prev.setAttribute("aria-label", nav.prev);
4818
+ this.next.setAttribute("aria-label", nav.next);
4819
+ const dates = this.ctrl.panel === "date";
4820
+ this.week.hidden = !dates;
4821
+ this.grid.className = dates ? "vela-date-picker-grid" : "vela-date-picker-cells";
4822
+ this.grid.replaceChildren(...this.ctrl.cells().map((cell) => this.cellEl(doc, cell, dates)));
4823
+ }
4824
+ cellEl(doc, cell, dates) {
4825
+ if (cell.blank) {
4826
+ const blank = doc.createElement("span");
4827
+ blank.className = "vela-date-picker-blank";
4828
+ return blank;
4829
+ }
4830
+ const b = doc.createElement("button");
4831
+ b.type = "button";
4832
+ b.className = dates ? "vela-date-picker-day" : "vela-date-picker-cell";
4833
+ b.textContent = cell.label;
4834
+ if (cell.checked) b.dataset.checked = "1";
4835
+ if (cell.today) b.dataset.today = "1";
4836
+ if (cell.outside) b.dataset.outside = "1";
4837
+ b.addEventListener("click", (e) => {
4838
+ e.stopPropagation();
4839
+ const picked = this.ctrl.choose(cell);
4840
+ if (picked) this.opts.onPick?.(picked);
4841
+ else this.paint();
4842
+ });
4843
+ return b;
4844
+ }
4845
+ };
4846
+
4553
4847
  // src/widget/layout-picker.ts
4554
4848
  var STYLE_ID = "vela-widget-layout-picker-v14";
4555
4849
  var CSS2 = `
@@ -4967,6 +5261,9 @@ function pinnedTopbarActionIds(comp) {
4967
5261
  }
4968
5262
 
4969
5263
  // src/widget/contributions.ts
5264
+ function mobilePlacement(action) {
5265
+ return action.mobile ?? (action.align === "left" ? "bar" : "menu");
5266
+ }
4970
5267
  var registry3 = /* @__PURE__ */ new Map();
4971
5268
  var attachments = /* @__PURE__ */ new Map();
4972
5269
  var panels = /* @__PURE__ */ new Map();
@@ -5204,6 +5501,22 @@ function priceStyleIds() {
5204
5501
  for (const t of chartTypes()) if (!out.includes(t.id)) out.push(t.id);
5205
5502
  return out;
5206
5503
  }
5504
+ function sanitizeCrosshairOverride(value) {
5505
+ if (!value || typeof value !== "object") return null;
5506
+ const v = value;
5507
+ const out = {};
5508
+ if (isBool(v.vertical)) out.vertical = v.vertical;
5509
+ if (isBool(v.horizontal)) out.horizontal = v.horizontal;
5510
+ if (isColor(v.color)) out.color = v.color;
5511
+ if (isNum(v.width)) out.width = Math.max(0.5, Math.min(8, v.width));
5512
+ if (isLineStyle(v.style)) out.style = v.style;
5513
+ if (isNum(v.opacity)) out.opacity = clampOpacity(v.opacity);
5514
+ const shade = v.shadeRight;
5515
+ if (shade && typeof shade === "object" && isColor(shade.color)) {
5516
+ out.shadeRight = isNum(shade.opacity) ? { color: shade.color, opacity: clampOpacity(shade.opacity) } : { color: shade.color };
5517
+ }
5518
+ return Object.keys(out).length > 0 ? out : null;
5519
+ }
5207
5520
  function isColor(v) {
5208
5521
  return typeof v === "string" && v.trim().length > 0;
5209
5522
  }
@@ -6091,8 +6404,8 @@ var Topbar = class {
6091
6404
  // src/core/util/wall-clock.ts
6092
6405
  var BOUNDARY_SLACK_MS = 5;
6093
6406
  var SecondClock = class _SecondClock {
6094
- constructor(now = () => Date.now()) {
6095
- this.now = now;
6407
+ constructor(now2 = () => Date.now()) {
6408
+ this.now = now2;
6096
6409
  this.subs = /* @__PURE__ */ new Set();
6097
6410
  this.timer = null;
6098
6411
  }
@@ -6108,15 +6421,15 @@ var SecondClock = class _SecondClock {
6108
6421
  };
6109
6422
  }
6110
6423
  /** Milliseconds from `now` to just past the next second boundary. */
6111
- static delayToNextSecond(now) {
6112
- const intoSecond = (now % 1e3 + 1e3) % 1e3;
6424
+ static delayToNextSecond(now2) {
6425
+ const intoSecond = (now2 % 1e3 + 1e3) % 1e3;
6113
6426
  return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
6114
6427
  }
6115
6428
  arm() {
6116
6429
  this.timer = setTimeout(() => {
6117
6430
  this.timer = null;
6118
- const now = this.now();
6119
- for (const cb of this.subs) cb(now);
6431
+ const now2 = this.now();
6432
+ for (const cb of this.subs) cb(now2);
6120
6433
  if (this.subs.size > 0) this.arm();
6121
6434
  }, _SecondClock.delayToNextSecond(this.now()));
6122
6435
  }
@@ -14933,7 +15246,12 @@ var TimeframeQuick = class {
14933
15246
  content: (body) => body.append(this.input, this.hint),
14934
15247
  onOpenChange: (open2) => opts.onOpenChange?.(open2)
14935
15248
  });
14936
- this.input.addEventListener("input", () => this.renderHint());
15249
+ this.input.addEventListener("input", () => {
15250
+ const { selectionStart, selectionEnd } = this.input;
15251
+ this.input.value = this.input.value.toUpperCase();
15252
+ this.input.setSelectionRange(selectionStart, selectionEnd);
15253
+ this.renderHint();
15254
+ });
14937
15255
  this.input.addEventListener("keydown", (e) => {
14938
15256
  if (e.key !== "Enter") return;
14939
15257
  const parsed = parseTimeframe(this.input.value);
@@ -14945,7 +15263,7 @@ var TimeframeQuick = class {
14945
15263
  }
14946
15264
  open(seed = "") {
14947
15265
  this.dialog.show();
14948
- this.input.value = seed;
15266
+ this.input.value = seed.toUpperCase();
14949
15267
  this.renderHint();
14950
15268
  setTimeout(() => {
14951
15269
  this.input.focus();
@@ -14963,7 +15281,7 @@ var TimeframeQuick = class {
14963
15281
  this.open(text);
14964
15282
  return;
14965
15283
  }
14966
- this.input.value += text;
15284
+ this.input.value += text.toUpperCase();
14967
15285
  this.renderHint();
14968
15286
  }
14969
15287
  close() {
@@ -14976,7 +15294,7 @@ var TimeframeQuick = class {
14976
15294
  const raw = this.input.value.trim();
14977
15295
  const parsed = parseTimeframe(raw);
14978
15296
  if (!raw) {
14979
- this.hint.textContent = "e.g. 15, 4h, D, 3M";
15297
+ this.hint.textContent = "e.g. 15, 4H, D, 3M";
14980
15298
  delete this.hint.dataset.invalid;
14981
15299
  } else if (parsed.valid) {
14982
15300
  this.hint.textContent = parsed.label ?? "";
@@ -15079,7 +15397,7 @@ var ShortcutsHelp = class {
15079
15397
  addRow("Delete the drawing under the cursor", ["Middle-click"]);
15080
15398
  const s = doc.createElement("div");
15081
15399
  s.className = "vela-sh-static";
15082
- s.textContent = "Typing a letter opens the symbol search; typing a digit opens the timeframe entry.";
15400
+ s.textContent = "Typing a letter or 0 opens the symbol search; typing a digit from 1 to 9 opens the timeframe entry.";
15083
15401
  this.list.appendChild(s);
15084
15402
  }
15085
15403
  };
@@ -15370,16 +15688,16 @@ var MobileBar = class {
15370
15688
  host.appendChild(this.el);
15371
15689
  this.renderActions();
15372
15690
  }
15373
- /** Re-project the left-aligned contributed actions as icon-only stops in the
15374
- * indicators slot (call after registrations change). Right-aligned actions stay
15375
- * in the three-dots drawer — a primary stop is what `align: 'left'` opts into. */
15691
+ /** Re-project the contributed actions placed on the bar (left-aligned by default —
15692
+ * see `mobilePlacement`) as icon-only stops in the indicators slot (call after
15693
+ * registrations change). The rest are three-dots drawer rows. */
15376
15694
  renderActions() {
15377
15695
  const ctx = this.opts.getContext?.();
15378
15696
  if (!ctx) return;
15379
15697
  const doc = this.el.ownerDocument;
15380
15698
  this.actionsHost.replaceChildren();
15381
15699
  const builtin = new Set(TOPBAR_BUILTIN_IDS);
15382
- for (const action of widgetActions("topbar", ctx).filter((a) => a.align === "left" && !builtin.has(a.id))) {
15700
+ for (const action of widgetActions("topbar", ctx).filter((a) => mobilePlacement(a) === "bar" && !builtin.has(a.id))) {
15383
15701
  const b = doc.createElement("button");
15384
15702
  b.className = "vela-mb-item";
15385
15703
  b.setAttribute("aria-label", action.label);
@@ -15935,6 +16253,7 @@ var MoreDrawer = class {
15935
16253
  const value = shape ? `${shape.cols} \xD7 ${shape.rows}` : this.opts.layout.presets().find((p) => p.checked)?.label;
15936
16254
  list.appendChild(this.row(doc, "Layout", { icon: "layout", value, chevron: true, onClick: () => this.show("layout") }));
15937
16255
  }
16256
+ for (const act of this.opts.primaryActions?.() ?? []) list.appendChild(this.actionRow(doc, act));
15938
16257
  for (const panel of this.opts.panels()) {
15939
16258
  list.appendChild(
15940
16259
  this.row(doc, panel.title, {
@@ -15950,19 +16269,19 @@ var MoreDrawer = class {
15950
16269
  const alertCount = this.opts.alerts().length;
15951
16270
  list.appendChild(this.row(doc, "Alerts", { icon: "bell", value: alertCount > 0 ? String(alertCount) : void 0, chevron: true, onClick: () => this.show("alerts") }));
15952
16271
  }
15953
- for (const act of this.opts.actions()) {
15954
- list.appendChild(
15955
- this.row(doc, act.label, {
15956
- icon: act.icon,
15957
- onClick: () => {
15958
- act.run();
15959
- this.drawer.hide();
15960
- }
15961
- })
15962
- );
15963
- }
16272
+ for (const act of this.opts.actions()) list.appendChild(this.actionRow(doc, act));
15964
16273
  this.drawer.body.appendChild(list);
15965
16274
  }
16275
+ /** A contributed action's row: runs it and closes the drawer. */
16276
+ actionRow(doc, act) {
16277
+ return this.row(doc, act.label, {
16278
+ icon: act.icon,
16279
+ onClick: () => {
16280
+ act.run();
16281
+ this.drawer.hide();
16282
+ }
16283
+ });
16284
+ }
15966
16285
  renderStyle(doc) {
15967
16286
  const list = doc.createElement("div");
15968
16287
  list.className = "vela-md-list";
@@ -18100,23 +18419,21 @@ function arrowHitHtml(svg) {
18100
18419
  return `<span class="vela-dtb-hit vela-dtb-hit--arrow">${iconSpan(svg, 11)}</span>`;
18101
18420
  }
18102
18421
 
18103
- // src/renderers/native/chrome/luxalgo-logos.ts
18104
- var LUXALGO_SYMBOL_SVG = '<svg viewBox="0 0 45 40" aria-hidden="true"><g fill="currentColor"><path d="m40.25 38 4.58-7.998L28.802 2l-16.03 28 9.16-.001 6.87-12z"/><path d="M34.525 32.002 9.33 31.997 27.655 0h-9.158L.18 31.993 4.759 40h34.347z"/></g></svg>';
18105
- var LUXALGO_WORDMARK_SVG = '<svg viewBox="0 0 964 252" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M 614 94.500 L 614 189 633.500 189 L 653 189 653 94.500 L 653 0 633.500 0 L 614 0 614 94.500 M 0 97.500 L 0 189 63.023 189 L 126.045 189 125.773 170.750 L 125.500 152.500 84.250 152.239 L 43 151.978 43 78.989 L 43 6 21.500 6 L 0 6 0 97.500 M 450.387 96.451 C 421.871 146.253, 398.306 186.994, 398.020 186.987 C 397.423 186.972, 362 124.724, 362 123.691 C 362 123.316, 370.100 108.818, 380 91.472 C 389.900 74.127, 398 59.500, 398 58.968 C 398 58.380, 391.007 58, 380.210 58 L 362.420 58 348.060 83.138 C 334.400 107.052, 333.637 108.160, 332.399 105.888 C 331.682 104.575, 325.287 93.375, 318.187 81 L 305.277 58.500 287.139 58.227 C 277.162 58.078, 269 58.186, 269 58.469 C 269 58.751, 277.154 73.274, 287.120 90.741 C 297.086 108.209, 305.261 122.983, 305.287 123.574 C 305.312 124.164, 297.318 138.789, 287.522 156.074 C 277.725 173.358, 269.524 187.838, 269.296 188.250 C 269.068 188.662, 276.977 189, 286.870 189 L 304.859 189 319.163 163.959 C 331.321 142.674, 333.647 139.165, 334.670 140.563 C 335.331 141.468, 341.864 152.737, 349.186 165.606 L 362.500 189.003 402.442 188.752 L 442.384 188.500 472.080 136.695 C 488.413 108.203, 501.939 84.729, 502.138 84.531 C 502.337 84.333, 516 107.757, 532.500 136.583 L 562.500 188.994 584.750 188.997 C 596.987 188.999, 607 188.854, 607 188.675 C 607 188.319, 503.014 6.686, 502.532 6.201 C 502.369 6.037, 478.904 46.649, 450.387 96.451 M 717.500 55.017 C 697.175 57.389, 680.641 68.752, 672.036 86.264 C 665.866 98.818, 663.840 108.024, 663.840 123.500 C 663.840 133.409, 664.328 138.355, 665.892 144.300 C 672.076 167.810, 689.191 184.757, 711.529 189.489 C 730.549 193.518, 747.264 189.540, 761.282 177.648 C 764.944 174.542, 768.178 172, 768.470 172 C 769.345 172, 769.030 185.018, 767.979 192.285 C 765.618 208.613, 755.148 218.672, 738.473 220.632 C 732.586 221.324, 723.136 219.599, 717.709 216.843 C 712.693 214.295, 706.636 207.946, 703.972 202.443 C 702.789 199.999, 701.749 198, 701.661 198.001 C 700.201 198.007, 665.596 205.071, 665.230 205.437 C 664.326 206.341, 669.156 218.313, 672.607 223.721 C 682.146 238.672, 700.008 248.257, 723.500 251.033 C 758.132 255.124, 788.930 241.875, 800.287 218 C 806.923 204.047, 807 203.001, 807 126.542 L 807 58 788.500 58 L 770 58 770 66.500 C 770 71.175, 769.641 75, 769.203 75 C 768.765 75, 766.627 73.142, 764.453 70.872 C 752.805 58.708, 735.614 52.903, 717.500 55.017 M 878.500 55.031 C 850.107 59.412, 830.755 75.064, 822.285 100.500 C 819.905 107.644, 819.614 110.051, 819.565 123 C 819.501 139.581, 820.786 145.825, 826.763 158 C 832.104 168.880, 842.842 179.424, 854.077 184.822 C 867.651 191.343, 873.132 192.462, 891.500 192.462 C 909.868 192.462, 915.349 191.343, 928.923 184.822 C 944.653 177.265, 956.667 162.306, 961.710 144 C 963.074 139.048, 963.481 134.047, 963.418 123 C 963.346 110.140, 963.038 107.595, 960.694 100.500 C 953.325 78.193, 936.695 62.854, 913.500 56.969 C 904.262 54.625, 887.240 53.682, 878.500 55.031 M 134.015 102.750 C 134.027 139.250, 134.310 148.689, 135.549 153.947 C 141.139 177.663, 159.815 193, 183.105 193 C 196.453 193, 210.163 186.171, 217.936 175.650 C 219.826 173.093, 221.738 171, 222.186 171 C 222.634 171, 223 175.050, 223 180 L 223 189 242.507 189 L 262.014 189 261.757 123.750 L 261.500 58.500 242.500 58.500 L 223.500 58.500 223 99.500 C 222.513 139.406, 222.441 140.610, 220.289 144.634 C 209.729 164.377, 183.014 164.880, 174.299 145.500 C 172.677 141.895, 172.469 137.382, 172.190 99.750 L 171.880 58 152.940 58 L 134 58 134.015 102.750 M 729.932 87.998 C 719.311 89.821, 710.155 97.164, 705.141 107.880 C 702.851 112.772, 702.510 114.798, 702.505 123.521 C 702.500 132.834, 702.732 134.014, 705.787 140.218 C 709.761 148.290, 715.989 154.097, 723.853 157.063 C 731.409 159.912, 744.359 159.466, 750.795 156.136 C 767.289 147.601, 774.103 127.713, 767.019 108.780 C 761.433 93.851, 746.051 85.232, 729.932 87.998 M 883.972 88.348 C 865.749 92.310, 854.873 109.966, 858.119 130.316 C 860.933 147.963, 872.346 158.830, 889.084 159.799 C 900.714 160.472, 906.867 158.234, 914.650 150.497 C 919.856 145.323, 921.123 143.340, 922.900 137.587 C 925.412 129.456, 925.655 118.570, 923.489 111.275 C 918.479 94.405, 901.478 84.541, 883.972 88.348"/></svg>';
18422
+ // src/renderers/native/chrome/vela-logos.ts
18423
+ var VELA_MARK_SVG = '<svg viewBox="0 -142 161.66 148" aria-hidden="true"><g fill="currentColor"><path d="M0 -140L32.91 -140L77.52 -62.73L61.06 -34.24Z"/><path d="M161.66 -140L128.75 -140L84.14 -62.73L100.59 -34.24Z"/><path d="M80.83 -52.04L95.85 -26.02L80.83 0L65.81 -26.02Z"/></g></svg>';
18424
+ var VELA_LETTERS_SVG = '<svg viewBox="161.66 -142 310.57 148" aria-hidden="true"><g fill="currentColor"><path d="M226.3 1.17Q200.81 1.17 186.12 -12.99Q171.42 -27.15 171.42 -51.17Q171.42 -75.2 185.87 -89.6Q200.33 -104 224.25 -104Q247.4 -104 261.7 -90.58Q276.01 -77.15 276.3 -54.59Q276.3 -49.9 275.42 -45.02H200.81V-43.55Q201.2 -32.62 207.75 -26.42Q214.29 -20.21 225.03 -20.21Q233.72 -20.21 239.34 -23.93Q244.95 -27.64 246.81 -34.77H274.64Q272.1 -19.14 259.16 -8.98Q246.22 1.17 226.3 1.17ZM201.5 -63.38H247.88Q246.42 -72.75 240.27 -77.88Q234.12 -83.01 224.45 -83.01Q215.17 -83.01 208.92 -77.73Q202.67 -72.46 201.5 -63.38ZM287.54 0V-140.04H315.76V0ZM429.34 -23.83H432.56V0H418.11Q407.76 0 403.36 -4.44Q398.97 -8.89 399.16 -16.8Q387.83 1.17 365.57 1.17Q348.48 1.17 337.88 -6.74Q327.29 -14.65 327.29 -29Q327.29 -45.41 339.45 -54.3Q351.6 -63.18 374.55 -63.18H394.96V-68.16Q394.96 -75.2 390.08 -79.3Q385.2 -83.4 376.51 -83.4Q368.3 -83.4 363.03 -79.98Q357.76 -76.56 356.88 -70.8H329.73Q331.19 -85.84 344.08 -94.92Q356.97 -104 377.29 -104Q399.06 -104 410.88 -94.43Q422.7 -84.86 422.7 -66.6V-30.18Q422.7 -26.37 424.26 -25.1Q425.82 -23.83 429.34 -23.83ZM394.96 -42.38V-44.43H374.36Q365.47 -44.43 360.59 -40.92Q355.7 -37.4 355.7 -30.96Q355.7 -25.59 359.81 -22.41Q363.91 -19.24 370.94 -19.24Q381.97 -19.24 388.37 -25.54Q394.77 -31.84 394.96 -42.38Z"/><path fill-opacity="0.55" d="M437.44 -124.28H441.96V-113.41H445.34V-124.28H449.87V-127.4H437.44ZM451.43 -113.41H454.7V-123.29L458.97 -113.41H461.62L465.99 -123.29V-113.41H469.21V-127.4H464.53L460.42 -117.68L456.16 -127.4H451.43Z"/></g></svg>';
18106
18425
 
18107
18426
  // src/renderers/native/chrome/AttributionMark.ts
18108
- var ATTRIBUTION_URL = "https://luxalgo.com/vela";
18427
+ var ATTRIBUTION_URL = "https://velacharts.dev/?utm_source=vela&utm_medium=attribution";
18109
18428
  var STYLE_ID20 = "vela-attribution-styles";
18110
18429
  var CSS20 = `
18111
18430
  .vela-attribution {
18112
18431
  text-decoration: none;
18113
18432
  display: flex;
18114
18433
  align-items: center;
18115
- /* Gutter, wordmark size and offset below are the brand lockup's own ratios,
18116
- measured off the official horizontal logo: gutter 0.157, wordmark height
18117
- 0.966, and wordmark 0.109 LOWER than the symbol \u2014 box-centering the two
18118
- reads wrong because the symbol's ink hangs low and the wordmark descends. */
18119
- gap: 4px;
18434
+ /* No gutter: the V is the wordmark's first letter, and the letters' viewBox starts
18435
+ where the V's ends, so the kit's own letter spacing comes with them. */
18436
+ gap: 0;
18120
18437
  }
18121
18438
  .vela-attribution .vela-attr-symbol {
18122
18439
  flex: none;
@@ -18124,7 +18441,7 @@ var CSS20 = `
18124
18441
  line-height: 0;
18125
18442
  }
18126
18443
  .vela-attribution .vela-attr-symbol svg {
18127
- height: 28px;
18444
+ height: 24px;
18128
18445
  width: auto;
18129
18446
  display: block;
18130
18447
  filter: drop-shadow(0 1px 2px var(--vela-attr-shadow, rgba(0,0,0,0.45)));
@@ -18135,15 +18452,13 @@ var CSS20 = `
18135
18452
  opacity: 0;
18136
18453
  display: flex;
18137
18454
  align-items: center;
18138
- /* Offset here, not on the image: the clip box must not crop the descender. */
18139
- position: relative;
18140
- top: 3px;
18455
+ /* Same height and baseline as the V (one viewBox height), so no vertical offset. */
18141
18456
  transform: translateX(-8px);
18142
18457
  transition: max-width 0.3s ease, opacity 0.25s ease, transform 0.3s ease;
18143
18458
  flex: none;
18144
18459
  }
18145
18460
  .vela-attribution .vela-attr-wordmark svg {
18146
- height: 27px;
18461
+ height: 24px;
18147
18462
  width: auto;
18148
18463
  display: block;
18149
18464
  filter: drop-shadow(0 1px 2px var(--vela-attr-shadow, rgba(0,0,0,0.45)));
@@ -18158,14 +18473,11 @@ var CSS20 = `
18158
18473
  (data-layout): the workspace's single grid-wide mark lives OUTSIDE any renderer
18159
18474
  container, so only the shell attribute reaches it. */
18160
18475
  [data-vela-layout='mobile'] .vela-attribution .vela-attr-symbol svg,
18161
- [data-layout='mobile'] .vela-attribution .vela-attr-symbol svg { height: 22px; }
18476
+ [data-layout='mobile'] .vela-attribution .vela-attr-symbol svg { height: 20px; }
18162
18477
  [data-vela-layout='mobile'] .vela-attribution .vela-attr-wordmark,
18163
- [data-layout='mobile'] .vela-attribution .vela-attr-wordmark {
18164
- top: 2px;
18165
- transform: translateX(-6px);
18166
- }
18478
+ [data-layout='mobile'] .vela-attribution .vela-attr-wordmark { transform: translateX(-6px); }
18167
18479
  [data-vela-layout='mobile'] .vela-attribution .vela-attr-wordmark svg,
18168
- [data-layout='mobile'] .vela-attribution .vela-attr-wordmark svg { height: 21px; }
18480
+ [data-layout='mobile'] .vela-attribution .vela-attr-wordmark svg { height: 20px; }
18169
18481
  [data-vela-layout='mobile'] .vela-attribution:hover .vela-attr-wordmark,
18170
18482
  [data-layout='mobile'] .vela-attribution:hover .vela-attr-wordmark { transform: translateX(0); }
18171
18483
  `;
@@ -18222,10 +18534,10 @@ function createAttributionMark(doc, background) {
18222
18534
  const symbol = doc.createElement("span");
18223
18535
  symbol.className = "vela-attr-symbol";
18224
18536
  symbol.setAttribute("aria-hidden", "true");
18225
- symbol.innerHTML = LUXALGO_SYMBOL_SVG;
18537
+ symbol.innerHTML = VELA_MARK_SVG;
18226
18538
  const wordmark = doc.createElement("span");
18227
18539
  wordmark.className = "vela-attr-wordmark";
18228
- wordmark.innerHTML = LUXALGO_WORDMARK_SVG;
18540
+ wordmark.innerHTML = VELA_LETTERS_SVG;
18229
18541
  a.append(symbol, wordmark);
18230
18542
  return a;
18231
18543
  }
@@ -18336,6 +18648,7 @@ function sanitizeCell(raw) {
18336
18648
  if (typeof c.bars === "number" && Number.isFinite(c.bars) && c.bars > 0) out.bars = c.bars;
18337
18649
  if (c.session === "regular" || c.session === "extended") out.session = c.session;
18338
18650
  if (typeof c.watermark === "boolean") out.watermark = c.watermark;
18651
+ if (typeof c.replayWatermark === "boolean") out.replayWatermark = c.replayWatermark;
18339
18652
  if (typeof c.indicatorTitles === "boolean") out.indicatorTitles = c.indicatorTitles;
18340
18653
  if (typeof c.indicatorValues === "boolean") out.indicatorValues = c.indicatorValues;
18341
18654
  if (c.rendererConfig != null && typeof c.rendererConfig === "object") out.rendererConfig = c.rendererConfig;
@@ -19074,6 +19387,7 @@ var CHUNK_BARS = 1e4;
19074
19387
  var FIRST_PAINT_BARS = 20;
19075
19388
  var GAP_FACTOR = 1.5;
19076
19389
  var HEAL_COOLDOWN_MS = 5e3;
19390
+ var MIN_TICK_UPDATE_MS = 16;
19077
19391
  var EngineOrchestrator = class _EngineOrchestrator {
19078
19392
  constructor(container, renderer, feed, engines, config, dataControl) {
19079
19393
  this.renderer = renderer;
@@ -19126,6 +19440,32 @@ var EngineOrchestrator = class _EngineOrchestrator {
19126
19440
  this.lastHealAt = 0;
19127
19441
  /** The `visibilitychange` catch-up listener (removed on destroy). */
19128
19442
  this.onVisible = null;
19443
+ // ── bar replay: `rawBars` holds the history up to the cursor, the rest waits here ──
19444
+ /** Bars still hidden right of the replay cursor, oldest first; null ≡ replay off. */
19445
+ this.replayQueue = null;
19446
+ this.replayTimer = null;
19447
+ this.replayPlaying = false;
19448
+ this.replayIntervalMs = 1e3;
19449
+ /** The depth (`market.bars`) before a replay deepened the history — restored when it ends. */
19450
+ this.replayDepth = null;
19451
+ /** A start is loading older history before it can cut; `seek` = a replay was running
19452
+ * (and left silently to deepen), so abandoning the load must announce its end. */
19453
+ this.replayLoading = null;
19454
+ /** A replay carried over a timeframe/session switch, between the old series' silent end
19455
+ * and the cut of the new one: still ACTIVE for the outside (`replayState`), resumed
19456
+ * at `revealedEnd` once the new bars land, unless stopped (`cancelled`) meanwhile. */
19457
+ this.replayCarrying = null;
19458
+ /** Intrabar prices for the bars replay reveals — null ≡ whole bars. */
19459
+ this.replayTickSource = null;
19460
+ /** The bar being revealed tick by tick: its stored form (what it settles on), its ticks,
19461
+ * the next tick to apply, the candle built so far, the volume each tick adds when the
19462
+ * ticks carry none, and its pacing (ticks per update, ms between updates — one play
19463
+ * interval per tick — and the clock time tick 0 is due at, so late timers catch up). */
19464
+ this.replayForming = null;
19465
+ /** Tick requests by bar time, shared by prefetch and playback; `ticks` once they landed. */
19466
+ this.replayTickFetches = /* @__PURE__ */ new Map();
19467
+ /** Aborts the tick requests in flight — renewed whenever the replay moves on. */
19468
+ this.replayTickAbort = null;
19129
19469
  /** What the last applied bar did: refine the open candle, or open a new one (which
19130
19470
  * settles the previous). Read by the `script:run` cause attribution. */
19131
19471
  this.barCause = "tick";
@@ -19349,6 +19689,10 @@ var EngineOrchestrator = class _EngineOrchestrator {
19349
19689
  const paint = (bars, final) => {
19350
19690
  if (this.generation !== gen || !final && bars.length === 0) return;
19351
19691
  if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
19692
+ if (painted && this.replayQueue) {
19693
+ this.mergeHistoryIntoReplay(bars);
19694
+ return;
19695
+ }
19352
19696
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
19353
19697
  if (!painted && bars.length > 0) {
19354
19698
  painted = true;
@@ -19491,6 +19835,36 @@ var EngineOrchestrator = class _EngineOrchestrator {
19491
19835
  // exactly like a timeframe change; the cache keys the sessions apart.
19492
19836
  next.session !== void 0 && next.session !== m.session || next.data !== void 0;
19493
19837
  const depthChanged = next.bars !== void 0 && next.bars !== m.bars;
19838
+ const sameClock = identityChanged && next.data === void 0 && (next.symbol === void 0 || next.symbol === m.symbol) && (next.timeframe !== void 0 || next.session !== void 0);
19839
+ const pending = identityChanged ? this.replayCarrying : null;
19840
+ if (pending) {
19841
+ this.replayCarrying = null;
19842
+ if (!sameClock) {
19843
+ pending.cancelled = true;
19844
+ this.events.emit("replay:end", { reason: "market" });
19845
+ }
19846
+ }
19847
+ 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;
19848
+ if (pending) pending.cancelled = true;
19849
+ if (replayCarry) this.replayCarrying = replayCarry;
19850
+ if (replayCarry && this.replayQueue) {
19851
+ this.endReplay(null, { restore: false, resume: false });
19852
+ const depth = this.replayDepth;
19853
+ this.replayDepth = null;
19854
+ if (depth && next.bars === void 0) {
19855
+ if (depth.bars === void 0) delete m.bars;
19856
+ else m.bars = depth.bars;
19857
+ }
19858
+ }
19859
+ if (this.replayQueue && (identityChanged || depthChanged)) this.endReplay("market", { restore: !identityChanged, resume: false });
19860
+ if (this.replayLoading && (identityChanged || depthChanged)) {
19861
+ const depth = this.replayDepth;
19862
+ this.abandonReplayLoad("market");
19863
+ if (depth && next.bars === void 0) {
19864
+ if (depth.bars === void 0) delete m.bars;
19865
+ else m.bars = depth.bars;
19866
+ }
19867
+ }
19494
19868
  const depthOnly = depthChanged && !identityChanged && this.rawBars.length > 0 && !m.data?.length && ((next.bars ?? 0) <= this.rawBars.length || typeof this.feed.loadRange === "function");
19495
19869
  if (!identityChanged && !depthChanged) {
19496
19870
  if (typeof next.visibleRange === "string") this.setVisibleRangePreset(next.visibleRange);
@@ -19548,15 +19922,20 @@ var EngineOrchestrator = class _EngineOrchestrator {
19548
19922
  const from = bars[Math.max(0, idx - (carriedCount - 1))].time;
19549
19923
  this.renderer.setVisibleRange({ from, to: Math.max(carried.right, bars[idx].time + 1) });
19550
19924
  }
19551
- if (!depthOnly) {
19925
+ const carry = replayCarry && !replayCarry.cancelled ? replayCarry : null;
19926
+ const carryFrom = carry ? carry.revealedEnd - this.barIntervalMs() : 0;
19927
+ const carryNow = carry != null && this.rawBars.length > 0 && carryFrom >= this.rawBars[0].time;
19928
+ const replayBack = carryNow && this.resumeCarriedReplay(this.replayStart({ from: carryFrom }), carry);
19929
+ if (!depthOnly && !replayBack) {
19552
19930
  this.restartNativeIndicators();
19553
19931
  this.restartChartTypeEngine();
19554
19932
  this.reexecuteIndicators();
19555
19933
  }
19556
- this.startLive();
19934
+ if (!replayBack) this.startLive();
19557
19935
  if (identityChanged) {
19558
19936
  this.events.emit("market:changed", { symbol: m.symbol ?? "TEST", timeframe: m.timeframe ?? "60", prev: prev2 });
19559
19937
  }
19938
+ if (carry && !carryNow) this.resumeCarriedReplay(this.replayStart({ from: carryFrom }), carry);
19560
19939
  }
19561
19940
  /**
19562
19941
  * Restart every native indicator over the new market: a `NativeIndicatorContext`
@@ -19680,6 +20059,397 @@ var EngineOrchestrator = class _EngineOrchestrator {
19680
20059
  historyComplete() {
19681
20060
  return this.historyCompletePromise;
19682
20061
  }
20062
+ // ── bar replay ──────────────────────────────────────────────
20063
+ /**
20064
+ * Enter replay — or seek, when already replaying — keeping every bar opened at or before
20065
+ * `opts.from` and queueing the rest. A `from` inside the loaded bars cuts at once, even
20066
+ * while older history is still streaming in: what lands later joins the tape's head
20067
+ * (see {@link mergeHistoryIntoReplay}) and leaves the cut alone. An older `from` waits
20068
+ * for that load, then deepens if it still has to. Consumers restart over the cut exactly
20069
+ * as on a market switch — their models describe bars that are now hidden — and the
20070
+ * active chart-type engine is rebuilt without live data.
20071
+ */
20072
+ async replayStart(opts) {
20073
+ if (this.rawBars.length === 0) {
20074
+ console.warn("[vela] chart.replay.start() ignored \u2014 the chart has no bars yet");
20075
+ return;
20076
+ }
20077
+ let gen = this.generation;
20078
+ if (opts.from < this.rawBars[0].time) {
20079
+ await this.historyCompletePromise;
20080
+ if (this.generation !== gen || this.rawBars.length === 0) return;
20081
+ }
20082
+ const oldest = this.rawBars[0].time;
20083
+ if (opts.from < oldest && this.canHeal() && typeof this.feed.loadRange === "function") {
20084
+ const held = this.rawBars.length + (this.replayQueue?.length ?? 0);
20085
+ const bars = held + Math.ceil((oldest - opts.from) / this.barIntervalMs()) + 1;
20086
+ const seek = this.replayQueue !== null;
20087
+ if (seek) this.endReplay(null, { restore: true, resume: false });
20088
+ this.replayDepth ?? (this.replayDepth = { bars: this.config.market.bars });
20089
+ const deepened = this.setMarket({ bars });
20090
+ this.replayLoading = { seek };
20091
+ await deepened;
20092
+ gen = this.generation;
20093
+ await this.historyCompletePromise;
20094
+ if (!this.replayLoading || this.generation !== gen || this.rawBars.length === 0) return;
20095
+ this.replayLoading = null;
20096
+ }
20097
+ this.settleFormingBar();
20098
+ this.dropTickFetches();
20099
+ const tape = this.replayQueue ? [...this.rawBars, ...this.replayQueue] : this.rawBars;
20100
+ let cut = 0;
20101
+ while (cut < tape.length && tape[cut].time <= opts.from) cut += 1;
20102
+ cut = Math.max(1, cut);
20103
+ if (cut >= tape.length) {
20104
+ console.warn("[vela] chart.replay.start() ignored \u2014 no bar opens after the requested time");
20105
+ return;
20106
+ }
20107
+ const wasPlaying = this.replayPlaying;
20108
+ this.clearReplayTimer();
20109
+ if (!this.replayQueue) this.stopLive();
20110
+ this.replayQueue = tape.slice(cut);
20111
+ this.setBarSeries(tape.slice(0, cut));
20112
+ this.restartBarConsumers({ blank: true });
20113
+ if (wasPlaying) this.events.emit("replay:pause", void 0);
20114
+ this.events.emit("replay:start", { cursorTime: tape[cut - 1].time, remaining: this.replayQueue.length });
20115
+ this.prefetchTicks();
20116
+ }
20117
+ /** Reveal the next queued bar through the live-bar path — or complete the bar forming
20118
+ * tick by tick; the last one ends the replay. */
20119
+ replayStep() {
20120
+ if (this.replayForming) {
20121
+ this.finishFormingBar();
20122
+ return true;
20123
+ }
20124
+ const queue = this.replayQueue;
20125
+ const bar = queue?.shift();
20126
+ if (!queue || !bar) return false;
20127
+ this.replayTickFetches.delete(bar.time);
20128
+ this.applyBar(bar);
20129
+ this.completeReveal(bar);
20130
+ this.prefetchTicks();
20131
+ return true;
20132
+ }
20133
+ /**
20134
+ * Follow up a replay carried onto a new series of the same symbol (another timeframe or
20135
+ * session). The cut asked for keeps the bars that CLOSED by the end of what was revealed —
20136
+ * every finer bar of the last revealed one, never a coarser bar still open at that point
20137
+ * (no look-ahead). Once it settles: playback resumes if it was on; if the replay did not
20138
+ * come back (nothing left after that point, or a later switch took over), it ends as a
20139
+ * market switch would. Returns whether the replay is back already — a start with no
20140
+ * history to wait for cuts synchronously (an async function runs up to its first await).
20141
+ */
20142
+ resumeCarriedReplay(start, carry) {
20143
+ if (this.replayQueue && this.replayCarrying === carry) this.replayCarrying = null;
20144
+ void start.then(() => {
20145
+ if (this.replayCarrying === carry) this.replayCarrying = null;
20146
+ if (carry.cancelled) return;
20147
+ if (!this.replayQueue) this.events.emit("replay:end", { reason: "market" });
20148
+ else if (carry.playing) this.replayPlay();
20149
+ });
20150
+ return this.replayQueue !== null;
20151
+ }
20152
+ replayStepUpdate() {
20153
+ if (!this.replayQueue) return false;
20154
+ if (this.replayForming) {
20155
+ this.applyReplayTicks(1);
20156
+ return true;
20157
+ }
20158
+ const bar = this.replayQueue[0];
20159
+ if (!bar) return false;
20160
+ const entry = this.replayTickSource ? this.requestTicks(bar) : null;
20161
+ if (entry && !entry.ticks) {
20162
+ const epoch = this.replayTickAbort;
20163
+ void entry.done.then(() => {
20164
+ if (!this.replayPlaying && !this.replayForming && this.replayTickAbort === epoch && this.replayQueue?.[0] === bar) this.replayStepUpdate();
20165
+ });
20166
+ return true;
20167
+ }
20168
+ if (!entry?.ticks?.length) return this.replayStep();
20169
+ this.openFormingBar(entry.ticks, 1);
20170
+ return true;
20171
+ }
20172
+ replaySetTicks(source) {
20173
+ this.finishFormingBar();
20174
+ this.dropTickFetches();
20175
+ this.replayTickSource = source;
20176
+ this.prefetchTicks();
20177
+ }
20178
+ replayPlay(intervalMs2) {
20179
+ if (!this.replayQueue) return;
20180
+ if (intervalMs2 !== void 0) {
20181
+ if (!(Number.isFinite(intervalMs2) && intervalMs2 > 0)) {
20182
+ console.warn(`[vela] chart.replay.play(${intervalMs2}) ignored \u2014 the interval must be a positive number of ms`);
20183
+ return;
20184
+ }
20185
+ this.replayIntervalMs = intervalMs2;
20186
+ }
20187
+ this.clearReplayTimer();
20188
+ this.replayPlaying = true;
20189
+ if (this.replayForming) this.paceFormingBar(this.replayForming);
20190
+ this.scheduleReplayStep(this.replayForming?.delayMs);
20191
+ this.events.emit("replay:play", { intervalMs: this.replayIntervalMs });
20192
+ }
20193
+ replayPause() {
20194
+ if (!this.replayPlaying) return;
20195
+ this.clearReplayTimer();
20196
+ this.events.emit("replay:pause", void 0);
20197
+ }
20198
+ replayStop() {
20199
+ const carrying = this.replayCarrying;
20200
+ if (carrying) {
20201
+ carrying.cancelled = true;
20202
+ this.replayCarrying = null;
20203
+ this.events.emit("replay:end", { reason: "stopped" });
20204
+ }
20205
+ if (this.replayLoading) {
20206
+ const depth = this.replayDepth;
20207
+ this.abandonReplayLoad("stopped");
20208
+ void this.setMarket({ bars: depth?.bars ?? 500 });
20209
+ return;
20210
+ }
20211
+ this.endReplay("stopped", { restore: true, resume: true });
20212
+ }
20213
+ abandonReplayLoad(reason) {
20214
+ const loading = this.replayLoading;
20215
+ if (!loading) return;
20216
+ this.replayLoading = null;
20217
+ this.replayDepth = null;
20218
+ if (loading.seek) this.events.emit("replay:end", { reason });
20219
+ }
20220
+ replayState() {
20221
+ const active = this.replayQueue !== null;
20222
+ return {
20223
+ active: active || this.replayCarrying !== null,
20224
+ // a replay carried over a switch stays on while the new bars load
20225
+ playing: this.replayCarrying?.playing ?? this.replayPlaying,
20226
+ cursorTime: active ? this.rawBars[this.rawBars.length - 1]?.time ?? null : null,
20227
+ remaining: this.replayQueue?.length ?? 0,
20228
+ nextTime: this.replayForming?.bar.time ?? this.replayQueue?.[0]?.time ?? null,
20229
+ intervalMs: this.replayIntervalMs
20230
+ };
20231
+ }
20232
+ replayBounds() {
20233
+ const first = this.rawBars[0];
20234
+ const last = this.replayQueue?.[this.replayQueue.length - 1] ?? this.rawBars[this.rawBars.length - 1];
20235
+ return first && last ? { first: first.time, last: last.time } : null;
20236
+ }
20237
+ scheduleReplayStep(delayMs = this.replayIntervalMs) {
20238
+ this.replayTimer = setTimeout(() => {
20239
+ this.replayTimer = null;
20240
+ this.replayAdvance();
20241
+ }, delayMs);
20242
+ }
20243
+ /** One beat of timed playback: the forming bar's next ticks, else the next bar — whole,
20244
+ * or opened from its ticks (waiting for them when they have not landed yet). */
20245
+ replayAdvance() {
20246
+ if (!this.replayPlaying || !this.replayQueue) return;
20247
+ const forming = this.replayForming;
20248
+ if (forming) {
20249
+ const due = Math.floor((now() - forming.origin) / this.replayIntervalMs) + 1;
20250
+ this.applyReplayTicks(Math.max(forming.batch, due - forming.next));
20251
+ if (this.replayPlaying) this.scheduleReplayStep(forming.delayMs);
20252
+ return;
20253
+ }
20254
+ const bar = this.replayQueue[0];
20255
+ if (!bar) return;
20256
+ const entry = this.replayTickSource ? this.requestTicks(bar) : null;
20257
+ if (entry && !entry.ticks) {
20258
+ const epoch = this.replayTickAbort;
20259
+ void entry.done.then(() => {
20260
+ if (this.replayPlaying && this.replayTimer == null && this.replayTickAbort === epoch && this.replayQueue?.[0] === bar) this.replayAdvance();
20261
+ });
20262
+ return;
20263
+ }
20264
+ if (!entry?.ticks?.length) {
20265
+ if (this.replayStep() && this.replayPlaying) this.scheduleReplayStep();
20266
+ return;
20267
+ }
20268
+ this.openFormingBar(entry.ticks);
20269
+ if (this.replayPlaying) this.scheduleReplayStep(this.replayForming?.delayMs);
20270
+ }
20271
+ /** Take the next queued bar off the tape and open it at its first tick (the first
20272
+ * `count` ticks — default: one timed update's worth). */
20273
+ openFormingBar(ticks, count) {
20274
+ const bar = this.replayQueue.shift();
20275
+ const volumeEach = ticks.every((t) => t.volume === void 0) ? (bar.volume ?? 0) / ticks.length : null;
20276
+ const forming = { bar, ticks, next: 0, candle: null, volumeEach, batch: 1, delayMs: this.replayIntervalMs, origin: 0 };
20277
+ this.paceFormingBar(forming);
20278
+ this.replayForming = forming;
20279
+ this.applyReplayTicks(count ?? forming.batch);
20280
+ this.prefetchTicks();
20281
+ }
20282
+ /** One tick per interval, batched when the interval is shorter than a frame; the clock
20283
+ * is anchored on the ticks already applied (a resume or a pace change carries on from
20284
+ * there, never jumps). */
20285
+ paceFormingBar(f) {
20286
+ f.batch = Math.max(1, Math.ceil(MIN_TICK_UPDATE_MS / this.replayIntervalMs));
20287
+ f.delayMs = this.replayIntervalMs * f.batch;
20288
+ f.origin = now() - Math.max(0, f.next - 1) * this.replayIntervalMs;
20289
+ }
20290
+ /** Fold the next `count` ticks into the forming candle; the last one settles the bar. */
20291
+ applyReplayTicks(count) {
20292
+ const f = this.replayForming;
20293
+ if (!f) return;
20294
+ let c = f.candle;
20295
+ const stop = Math.min(f.ticks.length, f.next + count);
20296
+ for (; f.next < stop; f.next += 1) {
20297
+ const t = f.ticks[f.next];
20298
+ const v = f.volumeEach ?? t.volume ?? 0;
20299
+ const hi = Math.max(t.high ?? t.price, t.price);
20300
+ const lo = Math.min(t.low ?? t.price, t.price);
20301
+ if (c) {
20302
+ c = { ...c, high: Math.max(c.high, hi), low: Math.min(c.low, lo), close: t.price, volume: (c.volume ?? 0) + v };
20303
+ } else {
20304
+ const open2 = t.open ?? t.price;
20305
+ c = { time: f.bar.time, open: open2, high: Math.max(open2, hi), low: Math.min(open2, lo), close: t.price, volume: v };
20306
+ }
20307
+ }
20308
+ f.candle = c;
20309
+ if (f.next >= f.ticks.length) {
20310
+ this.finishFormingBar();
20311
+ return;
20312
+ }
20313
+ this.applyBar(c);
20314
+ this.events.emit("replay:tick", { cursorTime: f.bar.time, index: f.next - 1, count: f.ticks.length });
20315
+ }
20316
+ /** Settle the forming bar on its stored values and announce it revealed. */
20317
+ finishFormingBar() {
20318
+ const f = this.replayForming;
20319
+ if (!f) return;
20320
+ this.replayForming = null;
20321
+ this.replayTickFetches.delete(f.bar.time);
20322
+ this.applyBar(f.bar);
20323
+ this.events.emit("replay:tick", { cursorTime: f.bar.time, index: f.ticks.length - 1, count: f.ticks.length });
20324
+ this.completeReveal(f.bar);
20325
+ }
20326
+ /** Put the forming bar back in its stored form, silently — a seek or an end rebuilds
20327
+ * from the tape, which must hold the stored bar. */
20328
+ settleFormingBar() {
20329
+ const f = this.replayForming;
20330
+ if (!f) return;
20331
+ this.replayForming = null;
20332
+ this.applyBar(f.bar);
20333
+ }
20334
+ /**
20335
+ * A history answer landing mid-replay (a progressive snapshot — the whole answer so far):
20336
+ * the tape keeps its cut and its hidden queue; bars older than the tape join its head,
20337
+ * bars newer than it join the queue's end.
20338
+ */
20339
+ mergeHistoryIntoReplay(bars) {
20340
+ const queue = this.replayQueue;
20341
+ const head = this.rawBars[0];
20342
+ if (!queue || !head) return;
20343
+ const tail = queue[queue.length - 1] ?? this.rawBars[this.rawBars.length - 1];
20344
+ let older = 0;
20345
+ while (older < bars.length && bars[older].time < head.time) older += 1;
20346
+ let newer = bars.length;
20347
+ while (newer > older && bars[newer - 1].time > tail.time) newer -= 1;
20348
+ if (newer < bars.length) queue.push(...bars.slice(newer));
20349
+ if (older > 0) {
20350
+ this.setBarSeries([...bars.slice(0, older), ...this.rawBars], { preserveView: true });
20351
+ this.notifySessionsBars("backfill");
20352
+ }
20353
+ }
20354
+ /** A bar is fully on screen: announce it, and end the replay if it was the last. */
20355
+ completeReveal(bar) {
20356
+ const queue = this.replayQueue;
20357
+ if (!queue) return;
20358
+ this.events.emit("replay:step", { cursorTime: bar.time, remaining: queue.length });
20359
+ if (queue.length === 0) this.endReplay("finished", { restore: true, resume: true });
20360
+ }
20361
+ /** Ask for the ticks of the next hidden bar ahead of time. */
20362
+ prefetchTicks() {
20363
+ const next = this.replayQueue?.[0];
20364
+ if (next && this.replayTickSource) this.requestTicks(next);
20365
+ }
20366
+ /** The (shared) tick request of a queued bar — the source runs once per bar. */
20367
+ requestTicks(bar) {
20368
+ const known = this.replayTickFetches.get(bar.time);
20369
+ if (known) return known;
20370
+ const source = this.replayTickSource;
20371
+ this.replayTickAbort ?? (this.replayTickAbort = new AbortController());
20372
+ const signal = this.replayTickAbort.signal;
20373
+ const queue = this.replayQueue ?? [];
20374
+ const end = queue[queue.indexOf(bar) + 1]?.time ?? bar.time + this.barIntervalMs();
20375
+ const entry = { ticks: null, done: Promise.resolve() };
20376
+ const land = (ticks) => {
20377
+ entry.ticks = usableTicks(ticks);
20378
+ };
20379
+ const fail = (e) => {
20380
+ if (!signal.aborted) console.warn(`[vela] replay tick source failed \u2014 revealing the bar whole (${e instanceof Error ? e.message : String(e)})`);
20381
+ land([]);
20382
+ };
20383
+ try {
20384
+ const out = source(bar, { end, signal });
20385
+ if (Array.isArray(out)) land(out);
20386
+ else entry.done = Promise.resolve(out).then(land, fail);
20387
+ } catch (e) {
20388
+ fail(e);
20389
+ }
20390
+ this.replayTickFetches.set(bar.time, entry);
20391
+ return entry;
20392
+ }
20393
+ /** Abort the tick requests in flight and forget the landed ones. */
20394
+ dropTickFetches() {
20395
+ this.replayTickAbort?.abort();
20396
+ this.replayTickAbort = null;
20397
+ this.replayTickFetches.clear();
20398
+ }
20399
+ clearReplayTimer() {
20400
+ if (this.replayTimer != null) clearTimeout(this.replayTimer);
20401
+ this.replayTimer = null;
20402
+ this.replayPlaying = false;
20403
+ }
20404
+ /**
20405
+ * Leave replay. `restore` puts the queued bars back and restarts the consumers over the
20406
+ * full history (a market switch skips it: its load replaces everything anyway); `resume`
20407
+ * restarts live updates and heals the bars that closed while replaying. A null reason
20408
+ * leaves silently — a seek that re-enters right after.
20409
+ */
20410
+ endReplay(reason, opts) {
20411
+ const queue = this.replayQueue;
20412
+ if (!queue) return;
20413
+ this.clearReplayTimer();
20414
+ if (opts.restore) this.settleFormingBar();
20415
+ else this.replayForming = null;
20416
+ this.dropTickFetches();
20417
+ this.replayQueue = null;
20418
+ const depth = reason ? this.replayDepth : null;
20419
+ if (reason) this.replayDepth = null;
20420
+ if (depth) {
20421
+ if (depth.bars === void 0) delete this.config.market.bars;
20422
+ else this.config.market.bars = depth.bars;
20423
+ }
20424
+ if (opts.restore) {
20425
+ const keep = depth ? depth.bars ?? 500 : Infinity;
20426
+ if (queue.length > 0 || this.rawBars.length > keep) {
20427
+ const full = [...this.rawBars, ...queue];
20428
+ this.setBarSeries(full.length > keep ? full.slice(full.length - keep) : full);
20429
+ this.restartBarConsumers({ blank: false });
20430
+ } else if (this.config.live) {
20431
+ this.restartNativeIndicators();
20432
+ this.restartChartTypeEngine();
20433
+ }
20434
+ }
20435
+ if (opts.resume && this.config.live) {
20436
+ this.startLive();
20437
+ const last = this.rawBars[this.rawBars.length - 1];
20438
+ if (last && this.canHeal()) void this.healGap(last.time);
20439
+ }
20440
+ if (reason) this.events.emit("replay:end", { reason });
20441
+ }
20442
+ /**
20443
+ * The bars were replaced by a cut or a restore of the SAME market: every consumer re-runs.
20444
+ * A cut also blanks the mounted models first — they still paint the hidden bars' values,
20445
+ * which must not show until the re-runs land.
20446
+ */
20447
+ restartBarConsumers(opts) {
20448
+ if (opts.blank) this.blankIndicatorVisuals();
20449
+ this.restartNativeIndicators();
20450
+ this.restartChartTypeEngine();
20451
+ this.reexecuteIndicators();
20452
+ }
19683
20453
  /** The primary symbol as the data layer resolves it, or null if no symbol is set. */
19684
20454
  qualifiedSymbol() {
19685
20455
  const market = this.config.market;
@@ -19751,7 +20521,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19751
20521
  engine.start({
19752
20522
  symbol: this.config.market.symbol ?? "TEST",
19753
20523
  timeframe: this.config.market.timeframe ?? "60",
19754
- live: this.config.live ?? false,
20524
+ live: (this.config.live ?? false) && this.replayQueue === null,
19755
20525
  session: this.config.market.session,
19756
20526
  bars: () => this.bars,
19757
20527
  data: this.dataControl,
@@ -19877,6 +20647,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19877
20647
  record.session.notifyBars(reason);
19878
20648
  } else if (record.native) record.native.instance.onBars();
19879
20649
  }
20650
+ if (this.activeEngineStyle) this.typeEngines.get(this.activeEngineStyle)?.onBars?.();
19880
20651
  }
19881
20652
  /** Gap healing needs a ranged feed and a provider-backed series (offline `data` has no source). */
19882
20653
  canHeal() {
@@ -19899,10 +20670,11 @@ var EngineOrchestrator = class _EngineOrchestrator {
19899
20670
  try {
19900
20671
  const bars = await this.feed.loadRange(this.config.market, { from: fromMs });
19901
20672
  if (this.generation !== gen) return;
20673
+ if (this.replayQueue) return;
19902
20674
  for (const b of bars) this.applyBar(b, false);
19903
20675
  } catch {
19904
20676
  } finally {
19905
- if (this.generation === gen) {
20677
+ if (this.generation === gen && !this.replayQueue) {
19906
20678
  this.healing = false;
19907
20679
  const pending = this.healBuffer;
19908
20680
  this.healBuffer = [];
@@ -20207,6 +20979,13 @@ var EngineOrchestrator = class _EngineOrchestrator {
20207
20979
  destroy() {
20208
20980
  this.bumpGeneration();
20209
20981
  this.resolveHistoryComplete();
20982
+ this.clearReplayTimer();
20983
+ this.replayQueue = null;
20984
+ this.replayForming = null;
20985
+ this.replayCarrying = null;
20986
+ this.dropTickFetches();
20987
+ this.replayDepth = null;
20988
+ this.replayLoading = null;
20210
20989
  this.stopLive();
20211
20990
  if (this.viewportTimer != null) clearTimeout(this.viewportTimer);
20212
20991
  this.viewportUnsub?.();
@@ -20305,6 +21084,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20305
21084
  this.emitScriptRun(id, cause, first);
20306
21085
  },
20307
21086
  onAlert: (a) => {
21087
+ if (this.replayQueue || this.replayCarrying) return;
20308
21088
  const indicator = record.options?.title ?? record.prepared?.meta.title ?? record.title;
20309
21089
  this.events.emit("alert", { ...a, indicator });
20310
21090
  handle.emit("alert", { id: a.id, message: a.message, title: a.title, time: a.time });
@@ -20331,7 +21111,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20331
21111
  id,
20332
21112
  symbol: this.config.market.symbol ?? "TEST",
20333
21113
  timeframe: this.config.market.timeframe ?? "60",
20334
- live: this.config.live,
21114
+ live: this.config.live && this.replayQueue === null,
20335
21115
  session: this.config.market.session,
20336
21116
  bars: () => this.bars,
20337
21117
  data: this.dataControl,
@@ -20734,17 +21514,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
20734
21514
  }
20735
21515
  emitContextChanged(id) {
20736
21516
  if (!this.registry.get(id)?.session?.getContext) return;
20737
- const now = Date.now();
20738
- if (now - (this.contextEmitAt.get(id) ?? 0) < 1e3) return;
20739
- this.contextEmitAt.set(id, now);
21517
+ const now2 = Date.now();
21518
+ if (now2 - (this.contextEmitAt.get(id) ?? 0) < 1e3) return;
21519
+ this.contextEmitAt.set(id, now2);
20740
21520
  this.events.emit("context:changed", { id });
20741
21521
  }
20742
21522
  emitScriptRun(id, cause, first) {
20743
21523
  const record = this.registry.get(id);
20744
21524
  if (!record || record.native || !this.events.hasListeners("script:run")) return;
20745
- const now = Date.now();
20746
- if (cause === "tick" && now - (this.runEmitAt.get(id) ?? 0) < RUN_EMIT_THROTTLE_MS) return;
20747
- this.runEmitAt.set(id, now);
21525
+ const now2 = Date.now();
21526
+ if (cause === "tick" && now2 - (this.runEmitAt.get(id) ?? 0) < RUN_EMIT_THROTTLE_MS) return;
21527
+ this.runEmitAt.set(id, now2);
20748
21528
  void Promise.resolve().then(() => this.buildScriptRun(record, cause, first)).then((run) => {
20749
21529
  if (run && this.registry.get(id)) this.events.emit("script:run", run);
20750
21530
  });
@@ -20797,6 +21577,16 @@ var EngineOrchestrator = class _EngineOrchestrator {
20797
21577
  handle?.emit("error", { error });
20798
21578
  }
20799
21579
  };
21580
+ function now() {
21581
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
21582
+ }
21583
+ function usableTicks(value) {
21584
+ if (!Array.isArray(value)) return [];
21585
+ const optional = (n) => n === void 0 || Number.isFinite(n);
21586
+ return value.filter(
21587
+ (t) => t != null && Number.isFinite(t.price) && optional(t.volume) && optional(t.high) && optional(t.low) && optional(t.open)
21588
+ );
21589
+ }
20800
21590
  function yieldToPaint() {
20801
21591
  if (typeof requestAnimationFrame === "function") {
20802
21592
  return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
@@ -20997,6 +21787,12 @@ var RendererControl = class {
20997
21787
  onCrosshairMove(cb) {
20998
21788
  return this.renderer.onCrosshairMove(cb);
20999
21789
  }
21790
+ /** A click — or a touch tap — on the plot, never the end of a pan: the open time of
21791
+ * the bar under it (`null` off the bars). On touch, where a tap moves no crosshair,
21792
+ * this is how an interaction learns which bar was chosen. */
21793
+ onClick(cb) {
21794
+ return this.renderer.onClick(cb);
21795
+ }
21000
21796
  /** Touch long-press on a price or time axis strip — silent no-op without the seam. */
21001
21797
  onAxisLongPress(cb) {
21002
21798
  return this.renderer.onAxisLongPress?.(cb) ?? (() => void 0);
@@ -21495,6 +22291,71 @@ var MarksControl = class {
21495
22291
  }
21496
22292
  };
21497
22293
 
22294
+ // src/core/ReplayControl.ts
22295
+ var ReplayControl = class {
22296
+ constructor(ctrl) {
22297
+ this.ctrl = ctrl;
22298
+ }
22299
+ /**
22300
+ * Enter replay (or seek, when already replaying) at `opts.from`. Resolves once the
22301
+ * chart shows the rewound history — after any in-flight history load has finished.
22302
+ * Starts paused; call {@link play} or {@link step}.
22303
+ */
22304
+ start(opts) {
22305
+ return this.ctrl.replayStart(opts);
22306
+ }
22307
+ /** Reveal the next bar — or, mid-way through a bar played tick by tick, complete it.
22308
+ * Returns false when replay is off or nothing is left. */
22309
+ step() {
22310
+ return this.ctrl.replayStep();
22311
+ }
22312
+ /** Reveal the next UPDATE: with {@link setTicks}, the next tick of the forming bar — or
22313
+ * the next bar opened at its first tick (once its ticks are in, if still loading);
22314
+ * otherwise the next whole bar, like {@link step}. Returns false when nothing is left. */
22315
+ stepUpdate() {
22316
+ return this.ctrl.replayStepUpdate();
22317
+ }
22318
+ /** Reveal one bar (one tick, with {@link setTicks}) every `intervalMs` (default: the last
22319
+ * interval used, else 1000). Calling it again while playing changes the pace. */
22320
+ play(intervalMs2) {
22321
+ this.ctrl.replayPlay(intervalMs2);
22322
+ return this;
22323
+ }
22324
+ pause() {
22325
+ this.ctrl.replayPause();
22326
+ return this;
22327
+ }
22328
+ /** Leave replay: the full history comes back and live updates resume. Also cancels a
22329
+ * `start()` still loading older history (the chart returns to its previous depth). */
22330
+ stop() {
22331
+ this.ctrl.replayStop();
22332
+ return this;
22333
+ }
22334
+ get state() {
22335
+ return this.ctrl.replayState();
22336
+ }
22337
+ /** The loaded history a replay can walk — hidden bars included while replaying. Null
22338
+ * before any bar loaded. A start older than `first` deepens the history first. */
22339
+ get bounds() {
22340
+ return this.ctrl.replayBounds();
22341
+ }
22342
+ /**
22343
+ * Play each revealed bar as a series of intrabar updates instead of whole: while
22344
+ * playing, the forming candle opens at the first tick, then every tick moves its close
22345
+ * and stretches its high and low, exactly like a live tick — indicators, chart types
22346
+ * and the `bar` event follow — and the last one settles it on the stored bar. The play
22347
+ * interval is then the time between two TICKS: a bar lasts as many intervals as it
22348
+ * has ticks (below ~16 ms, ticks are batched per frame). The next bar's ticks are
22349
+ * requested while the current one plays; playback waits for them. `null` goes back to
22350
+ * whole bars. Takes effect from the next bar; a bar forming when the source changes
22351
+ * completes at once.
22352
+ */
22353
+ setTicks(source) {
22354
+ this.ctrl.replaySetTicks(source);
22355
+ return this;
22356
+ }
22357
+ };
22358
+
21498
22359
  // src/core/model/inputs.ts
21499
22360
  function inputVisible(when, values) {
21500
22361
  if (!when) return true;
@@ -22104,10 +22965,16 @@ var IndicatorInputsDialog = class {
22104
22965
  this.calendarPop = null;
22105
22966
  this.calendarAnchor = null;
22106
22967
  },
22107
- content: (el) => fillCalendar(el, current, (iso) => {
22108
- pop.hide();
22109
- onPick(iso);
22110
- })
22968
+ content: (el) => {
22969
+ const picker = new DatePicker({
22970
+ value: current,
22971
+ onPick: (iso) => {
22972
+ pop.hide();
22973
+ onPick(iso);
22974
+ }
22975
+ });
22976
+ el.append(picker.el);
22977
+ }
22111
22978
  });
22112
22979
  this.calendarPop = pop;
22113
22980
  this.calendarAnchor = anchor;
@@ -22232,28 +23099,6 @@ function ensureDialogStyles() {
22232
23099
  .vela-ind-combo-chevron{position:absolute;right:0;top:0;bottom:0;width:26px;border:none;background:transparent;color:inherit;opacity:0.55;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;}
22233
23100
  .vela-ind-combo-chevron:hover{opacity:0.9;}
22234
23101
  .vela-ind-cal{background:var(--vela-bg);color:var(--vela-fg);border:none;border-radius:6px;box-shadow:var(--vela-shadow);font:14px var(--vela-font);padding:10px 12px;user-select:none;}
22235
- .vela-ind-cal [hidden]{display:none !important;}
22236
- .vela-ind-cal-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
22237
- .vela-ind-cal-title{flex:1;display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;}
22238
- .vela-ind-cal-switch{border:none;background:transparent;color:var(--vela-fg-bright);font:inherit;font-weight:600;font-size:14px;padding:2px 6px;border-radius:4px;cursor:pointer;}
22239
- .vela-ind-cal-switch:not(:disabled):hover{background:var(--vela-hover);}
22240
- .vela-ind-cal-switch:disabled{cursor:default;}
22241
- .vela-ind-cal-nav{width:24px;height:24px;border:none;background:transparent;color:var(--vela-fg-muted);border-radius:4px;padding:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;}
22242
- .vela-ind-cal-nav:hover{background:var(--vela-hover);color:var(--vela-fg-bright);}
22243
- .vela-ind-cal-week,.vela-ind-cal-grid{display:grid;grid-template-columns:repeat(7,28px);gap:2px;}
22244
- .vela-ind-cal-week{margin-bottom:4px;color:var(--vela-fg-muted);font-size:11px;text-align:center;}
22245
- .vela-ind-cal-week span{line-height:20px;}
22246
- .vela-ind-cal-blank{width:28px;height:28px;}
22247
- .vela-ind-cal-day{width:28px;height:28px;border:none;background:transparent;color:inherit;border-radius:4px;padding:0;cursor:pointer;font:inherit;font-size:14px;}
22248
- .vela-ind-cal-day:hover{background:var(--vela-hover);}
22249
- .vela-ind-cal-day[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
22250
- .vela-ind-cal-day[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
22251
- .vela-ind-cal-cells{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;width:calc(7 * 28px + 6 * 2px);}
22252
- .vela-ind-cal-cell{height:36px;border:none;background:transparent;color:inherit;border-radius:4px;padding:0 4px;cursor:pointer;font:inherit;font-size:14px;}
22253
- .vela-ind-cal-cell:hover{background:var(--vela-hover);}
22254
- .vela-ind-cal-cell[data-checked]{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
22255
- .vela-ind-cal-cell[data-today]:not([data-checked]){box-shadow:inset 0 0 0 1px var(--vela-border-strong);}
22256
- .vela-ind-cal-cell[data-outside]{opacity:0.45;}
22257
23102
  ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
22258
23103
  .vela-ind-tab{font-weight:600;font-size:13px;line-height:20px;transition:color var(--vela-dur-fast) ease,border-color var(--vela-dur-fast) ease;}
22259
23104
  .vela-ind-tab:not(.vela-ind-tab-active):hover{color:var(--vela-fg-bright);}
@@ -22287,174 +23132,6 @@ var TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => {
22287
23132
  const v = `${hh}:${mm}`;
22288
23133
  return { value: v, label: v };
22289
23134
  });
22290
- var MONTH_LABELS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
22291
- var MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
22292
- var WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
22293
- function normalizeDateInput(raw) {
22294
- const t = raw.trim();
22295
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t);
22296
- if (!m) return null;
22297
- const y = Number(m[1]);
22298
- const mo = Number(m[2]);
22299
- const d = Number(m[3]);
22300
- const dt = new Date(y, mo - 1, d);
22301
- if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
22302
- return isoDate(dt);
22303
- }
22304
- function parseIsoDate(raw) {
22305
- const iso = normalizeDateInput(raw);
22306
- if (!iso) return null;
22307
- const [y, mo, d] = iso.split("-").map(Number);
22308
- return new Date(y, mo - 1, d);
22309
- }
22310
- function isoDate(d) {
22311
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
22312
- }
22313
- function fillCalendar(el, current, onPick) {
22314
- const today = /* @__PURE__ */ new Date();
22315
- const selected = parseIsoDate(current);
22316
- let year = selected?.getFullYear() ?? today.getFullYear();
22317
- let month = selected?.getMonth() ?? today.getMonth();
22318
- let mode = "date";
22319
- const prev2 = document.createElement("button");
22320
- prev2.type = "button";
22321
- prev2.className = "vela-ind-cal-nav";
22322
- prev2.innerHTML = iconAt("chevron-left", 14);
22323
- const next = document.createElement("button");
22324
- next.type = "button";
22325
- next.className = "vela-ind-cal-nav";
22326
- next.innerHTML = iconAt("chevron-right", 14);
22327
- const monthBtn = document.createElement("button");
22328
- monthBtn.type = "button";
22329
- monthBtn.className = "vela-ind-cal-switch";
22330
- monthBtn.setAttribute("aria-label", "Choose month");
22331
- const yearBtn = document.createElement("button");
22332
- yearBtn.type = "button";
22333
- yearBtn.className = "vela-ind-cal-switch";
22334
- yearBtn.setAttribute("aria-label", "Choose year");
22335
- const title = document.createElement("div");
22336
- title.className = "vela-ind-cal-title";
22337
- title.append(monthBtn, yearBtn);
22338
- const head = document.createElement("div");
22339
- head.className = "vela-ind-cal-head";
22340
- head.append(prev2, title, next);
22341
- const week = document.createElement("div");
22342
- week.className = "vela-ind-cal-week";
22343
- for (const d of WEEKDAY_LABELS) {
22344
- const cell = document.createElement("span");
22345
- cell.textContent = d;
22346
- week.appendChild(cell);
22347
- }
22348
- const grid = document.createElement("div");
22349
- const paint = () => {
22350
- const decade = Math.floor(year / 10) * 10;
22351
- monthBtn.hidden = mode !== "date";
22352
- monthBtn.textContent = MONTH_LABELS[month] ?? "";
22353
- yearBtn.textContent = mode === "year" ? `${decade}-${decade + 9}` : String(year);
22354
- yearBtn.disabled = mode === "year";
22355
- week.hidden = mode !== "date";
22356
- prev2.setAttribute("aria-label", mode === "date" ? "Previous month" : mode === "month" ? "Previous year" : "Previous decade");
22357
- next.setAttribute("aria-label", mode === "date" ? "Next month" : mode === "month" ? "Next year" : "Next decade");
22358
- grid.className = mode === "date" ? "vela-ind-cal-grid" : "vela-ind-cal-cells";
22359
- grid.replaceChildren();
22360
- if (mode === "date") {
22361
- const startPad = new Date(year, month, 1).getDay();
22362
- const days = new Date(year, month + 1, 0).getDate();
22363
- const todayIso = isoDate(today);
22364
- const selectedIso = selected ? isoDate(selected) : "";
22365
- for (let i = 0; i < startPad; i++) {
22366
- const blank = document.createElement("span");
22367
- blank.className = "vela-ind-cal-blank";
22368
- grid.appendChild(blank);
22369
- }
22370
- for (let day = 1; day <= days; day++) {
22371
- const iso = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
22372
- const b = document.createElement("button");
22373
- b.type = "button";
22374
- b.className = "vela-ind-cal-day";
22375
- b.textContent = String(day);
22376
- if (iso === selectedIso) b.dataset.checked = "1";
22377
- if (iso === todayIso) b.dataset.today = "1";
22378
- b.addEventListener("click", (e) => {
22379
- e.stopPropagation();
22380
- onPick(iso);
22381
- });
22382
- grid.appendChild(b);
22383
- }
22384
- return;
22385
- }
22386
- if (mode === "month") {
22387
- for (let m = 0; m < 12; m++) {
22388
- const b = document.createElement("button");
22389
- b.type = "button";
22390
- b.className = "vela-ind-cal-cell";
22391
- b.textContent = MONTH_SHORT[m] ?? "";
22392
- if (selected?.getFullYear() === year && selected.getMonth() === m) b.dataset.checked = "1";
22393
- if (today.getFullYear() === year && today.getMonth() === m) b.dataset.today = "1";
22394
- b.addEventListener("click", (e) => {
22395
- e.stopPropagation();
22396
- month = m;
22397
- mode = "date";
22398
- paint();
22399
- });
22400
- grid.appendChild(b);
22401
- }
22402
- return;
22403
- }
22404
- for (let y = decade - 1; y <= decade + 10; y++) {
22405
- const b = document.createElement("button");
22406
- b.type = "button";
22407
- b.className = "vela-ind-cal-cell";
22408
- b.textContent = String(y);
22409
- if (y < decade || y > decade + 9) b.dataset.outside = "1";
22410
- if (selected?.getFullYear() === y) b.dataset.checked = "1";
22411
- if (today.getFullYear() === y) b.dataset.today = "1";
22412
- b.addEventListener("click", (e) => {
22413
- e.stopPropagation();
22414
- year = y;
22415
- mode = "month";
22416
- paint();
22417
- });
22418
- grid.appendChild(b);
22419
- }
22420
- };
22421
- const step = (dir) => {
22422
- if (mode === "date") {
22423
- month += dir;
22424
- if (month < 0) {
22425
- month = 11;
22426
- year -= 1;
22427
- }
22428
- if (month > 11) {
22429
- month = 0;
22430
- year += 1;
22431
- }
22432
- } else {
22433
- year += mode === "month" ? dir : dir * 10;
22434
- }
22435
- paint();
22436
- };
22437
- prev2.addEventListener("click", (e) => {
22438
- e.stopPropagation();
22439
- step(-1);
22440
- });
22441
- next.addEventListener("click", (e) => {
22442
- e.stopPropagation();
22443
- step(1);
22444
- });
22445
- monthBtn.addEventListener("click", (e) => {
22446
- e.stopPropagation();
22447
- mode = "month";
22448
- paint();
22449
- });
22450
- yearBtn.addEventListener("click", (e) => {
22451
- e.stopPropagation();
22452
- mode = "year";
22453
- paint();
22454
- });
22455
- paint();
22456
- el.append(head, week, grid);
22457
- }
22458
23135
  function normalizeTimeInput(raw) {
22459
23136
  const t = raw.trim();
22460
23137
  const m = /^(\d{1,2}):(\d{2})$/.exec(t) ?? /^(\d{2})(\d{2})$/.exec(t);
@@ -25158,7 +25835,7 @@ var Scheduler = class {
25158
25835
 
25159
25836
  // src/renderers/native/core/Animator.ts
25160
25837
  var Animator = class {
25161
- constructor(tick, raf, cancel, now) {
25838
+ constructor(tick, raf, cancel, now2) {
25162
25839
  this.tick = tick;
25163
25840
  this.handle = null;
25164
25841
  this.last = 0;
@@ -25172,7 +25849,7 @@ var Animator = class {
25172
25849
  };
25173
25850
  this.raf = raf ?? ((cb) => requestAnimationFrame(cb));
25174
25851
  this.cancel = cancel ?? ((h) => cancelAnimationFrame(h));
25175
- this.now = now ?? (() => performance.now());
25852
+ this.now = now2 ?? (() => performance.now());
25176
25853
  }
25177
25854
  get active() {
25178
25855
  return this.handle !== null;
@@ -25304,6 +25981,12 @@ var InputController = class {
25304
25981
  this.lastTapT = 0;
25305
25982
  this.lastTapX = 0;
25306
25983
  this.lastTapY = 0;
25984
+ // Whether the last two primary presses were claimed by the drawings layer. A double-click
25985
+ // is the last press plus the one before it; a tool-driven click pair must never fall through
25986
+ // to the pane maximize toggle, even when the tool disarmed itself between the two clicks
25987
+ // (a finished placement leaves plain cursor mode for the second click).
25988
+ this.lastPressDrawing = false;
25989
+ this.prevPressDrawing = false;
25307
25990
  this.onTouchContextMenu = (e) => {
25308
25991
  if (this.touches.size === 0) return;
25309
25992
  if (!(e.target instanceof Node) || !this.el?.contains(e.target)) return;
@@ -25358,19 +26041,24 @@ var InputController = class {
25358
26041
  this.moved = false;
25359
26042
  this.startX = x;
25360
26043
  this.startY = y;
26044
+ this.prevPressDrawing = this.lastPressDrawing;
26045
+ this.lastPressDrawing = false;
25361
26046
  if (this.deps.drawingsClaim?.(x, y)) {
25362
26047
  this.region = "drawing";
26048
+ this.lastPressDrawing = true;
25363
26049
  this.deps.drawingsPointerDown?.(x, y, this.snapMode(e), e.shiftKey, e.ctrlKey || e.metaKey);
25364
26050
  this.capture(e.pointerId);
25365
26051
  return;
25366
26052
  }
25367
26053
  if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y, this.snapMode(e))) {
25368
26054
  this.region = "drawing";
26055
+ this.lastPressDrawing = true;
25369
26056
  this.capture(e.pointerId);
25370
26057
  return;
25371
26058
  }
25372
26059
  if ((e.ctrlKey || e.metaKey) && this.regionAt(x, y) === "data" && this.deps.drawingsMarqueeStart?.(x, y)) {
25373
26060
  this.region = "drawing";
26061
+ this.lastPressDrawing = true;
25374
26062
  this.capture(e.pointerId);
25375
26063
  return;
25376
26064
  }
@@ -25692,6 +26380,7 @@ var InputController = class {
25692
26380
  if (region === "price") this.deps.resetPriceScale(x, y);
25693
26381
  else if (region === "separator") this.deps.resetPaneSize(y);
25694
26382
  else if (region === "time") this.deps.resetView();
26383
+ else if (this.lastPressDrawing || this.prevPressDrawing) return;
25695
26384
  else this.deps.dataDblClick(x, y);
25696
26385
  }
25697
26386
  };
@@ -25971,6 +26660,8 @@ var SceneGraph = class {
25971
26660
  /** VPVR-layer config pushed by the VPVR native indicator (null ⇒ layer off). Ephemeral. */
25972
26661
  this.vpvrLayer = null;
25973
26662
  this.crosshair = null;
26663
+ /** Runtime override of the crosshair's lines and style (the `crosshairOverride` feature). Ephemeral. */
26664
+ this.crosshairOverride = null;
25974
26665
  /** How the base price series is drawn on the price pane (candles by default). */
25975
26666
  this.priceStyle = "candles";
25976
26667
  /** Price-series base painting for the ACTIVE style (see ChartTypeDefinition.basePainting). */
@@ -27855,6 +28546,7 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
27855
28546
  const step = pickStep(span / Math.max(1, target));
27856
28547
  const zFrom = fromMs + offsetMs;
27857
28548
  const zTo = toMs + offsetMs;
28549
+ if (step >= MONTH) return calendarTicks(zFrom, zTo, step >= YEAR ? 12 : step >= 3 * MONTH ? 3 : 1, offsetMs);
27858
28550
  const first = Math.ceil(zFrom / step) * step;
27859
28551
  const out = [];
27860
28552
  for (let zt = first; zt <= zTo; zt += step) {
@@ -27885,11 +28577,23 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
27885
28577
  }
27886
28578
  return out;
27887
28579
  }
28580
+ function calendarTicks(zFrom, zTo, months, offsetMs) {
28581
+ const d0 = new Date(zFrom);
28582
+ let idx = d0.getUTCFullYear() * 12 + d0.getUTCMonth();
28583
+ if (Date.UTC(Math.floor(idx / 12), idx % 12, 1) < zFrom) idx += 1;
28584
+ idx = Math.ceil(idx / months) * months;
28585
+ const out = [];
28586
+ for (let zt = Date.UTC(Math.floor(idx / 12), idx % 12, 1); zt <= zTo; idx += months, zt = Date.UTC(Math.floor(idx / 12), idx % 12, 1)) {
28587
+ const label = months === 12 ? String(Math.floor(idx / 12)) : MONTHS[idx % 12];
28588
+ out.push({ time: zt - offsetMs, label, major: true });
28589
+ }
28590
+ return out;
28591
+ }
27888
28592
 
27889
28593
  // src/renderers/native/chrome/countdown.ts
27890
- function countdownText(barOpen, barMs, now) {
28594
+ function countdownText(barOpen, barMs, now2) {
27891
28595
  if (!(barMs > 0)) return null;
27892
- const remaining = barOpen + barMs - now;
28596
+ const remaining = barOpen + barMs - now2;
27893
28597
  if (remaining <= 0) return null;
27894
28598
  return formatCountdown(remaining);
27895
28599
  }
@@ -28701,19 +29405,35 @@ var CrosshairRenderer = class {
28701
29405
  const dataH = coords.height;
28702
29406
  if (!ch || ch.x < 0 || ch.x > dataW || ch.y < 0 || ch.y > dataH) return;
28703
29407
  const cs = scene.style.crosshair;
29408
+ const ov = scene.crosshairOverride;
29409
+ const vertical = ov?.vertical !== false;
29410
+ const horizontal = ov?.horizontal !== false;
28704
29411
  ctx.font = `${scene.style.fontSize}px ${theme.fontFamily}`;
28705
29412
  ctx.textBaseline = "middle";
28706
29413
  const logical = Math.round(coords.xToLogical(ch.x));
28707
- const x = Math.round(coords.logicalToX(logical)) + 0.5;
28708
- ctx.strokeStyle = cs.color ?? theme.textColor;
28709
- ctx.lineWidth = cs.width;
28710
- ctx.globalAlpha = cs.opacity;
28711
- setDash3(ctx, cs.style);
29414
+ const x = crisp(coords.logicalToX(logical), ov?.width ?? cs.width);
29415
+ if (vertical && ov?.shadeRight) {
29416
+ const from = Math.max(0, Math.round(coords.logicalToX(logical + 0.5)));
29417
+ if (from < dataW) {
29418
+ ctx.fillStyle = ov.shadeRight.color;
29419
+ ctx.globalAlpha = ov.shadeRight.opacity ?? 1;
29420
+ ctx.fillRect(from, 0, dataW - from, dataH);
29421
+ ctx.globalAlpha = 1;
29422
+ }
29423
+ }
29424
+ ctx.strokeStyle = ov?.color ?? cs.color ?? theme.textColor;
29425
+ ctx.lineWidth = ov?.width ?? cs.width;
29426
+ ctx.globalAlpha = ov?.opacity ?? cs.opacity;
29427
+ setDash3(ctx, ov?.style ?? cs.style);
28712
29428
  ctx.beginPath();
28713
- ctx.moveTo(x, 0);
28714
- ctx.lineTo(x, dataH);
28715
- ctx.moveTo(0, Math.round(ch.y) + 0.5);
28716
- ctx.lineTo(dataW, Math.round(ch.y) + 0.5);
29429
+ if (vertical) {
29430
+ ctx.moveTo(x, 0);
29431
+ ctx.lineTo(x, dataH);
29432
+ }
29433
+ if (horizontal) {
29434
+ ctx.moveTo(0, Math.round(ch.y) + 0.5);
29435
+ ctx.lineTo(dataW, Math.round(ch.y) + 0.5);
29436
+ }
28717
29437
  ctx.stroke();
28718
29438
  ctx.setLineDash([]);
28719
29439
  ctx.lineWidth = 1;
@@ -28726,11 +29446,11 @@ var CrosshairRenderer = class {
28726
29446
  }
28727
29447
  }
28728
29448
  const chipBg = cs.labelBackground ?? theme.borderColor;
28729
- if (pane && pane.axisFormat !== "none") {
29449
+ if (horizontal && pane && pane.axisFormat !== "none") {
28730
29450
  const price = coords.yToPrice(ch.y, pane.scale, pane.bounds);
28731
29451
  this.chip(ctx, dataW + 1, ch.y, formatAxisValue(pane.scale, pane.bounds.height, price, percentScaleFor(scene, pane), scene.priceMintick, pane.axisFormat), chipBg, "left", false, theme.background);
28732
29452
  }
28733
- this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
29453
+ if (vertical) this.chip(ctx, x, dataH + 1, formatTimeStamp(coords.logicalToTime(logical), scene.timezone, coords.barInterval), chipBg, "center", true, theme.background);
28734
29454
  }
28735
29455
  destroy() {
28736
29456
  this.canvas = null;
@@ -28741,23 +29461,37 @@ var CrosshairRenderer = class {
28741
29461
  * along), with that bar's time chip in this chart's own timezone and — when the
28742
29462
  * level resolved — the price chip on the right axis. The snap happened upstream
28743
29463
  * (`externalCrossPx`, floor-to-containing-bar) — this method only draws. Chips
28744
- * render slightly dimmed so the ghost still reads as foreign. */
29464
+ * render slightly dimmed so the ghost still reads as foreign. A `crosshairOverride`
29465
+ * applies to the ghost as well — its line style, a dropped horizontal line, and the
29466
+ * `shadeRight` veil from the ghost bar's right edge — so a pick that spans several
29467
+ * charts reads the same on every one of them. */
28745
29468
  drawExternal(ctx, ext, scene, coords, theme) {
28746
29469
  const cs = scene.style.crosshair;
29470
+ const ov = scene.crosshairOverride;
28747
29471
  const dataW = coords.width;
28748
29472
  const dataH = coords.height;
28749
- const x = Math.round(ext.x) + 0.5;
28750
- if (x < 0 || x > dataW) return;
29473
+ const x = crisp(ext.x, ov?.width ?? cs.width);
29474
+ if (ov?.vertical === false) return;
29475
+ if (ov?.shadeRight) {
29476
+ const from = Math.max(0, Math.round(coords.logicalToX(Math.round(coords.xToLogical(ext.x)) + 0.5)));
29477
+ if (from < dataW) {
29478
+ ctx.fillStyle = ov.shadeRight.color;
29479
+ ctx.globalAlpha = ov.shadeRight.opacity ?? 1;
29480
+ ctx.fillRect(from, 0, dataW - from, dataH);
29481
+ ctx.globalAlpha = 1;
29482
+ }
29483
+ }
29484
+ if (ext.line === false || x < 0 || x > dataW) return;
28751
29485
  ctx.font = `${scene.style.fontSize}px ${theme.fontFamily}`;
28752
29486
  ctx.textBaseline = "middle";
28753
- ctx.strokeStyle = cs.color ?? theme.textColor;
28754
- ctx.lineWidth = cs.width;
28755
- ctx.globalAlpha = cs.opacity * 0.55;
28756
- setDash3(ctx, cs.style);
29487
+ ctx.strokeStyle = ov?.color ?? cs.color ?? theme.textColor;
29488
+ ctx.lineWidth = ov?.width ?? cs.width;
29489
+ ctx.globalAlpha = (ov?.opacity ?? cs.opacity) * 0.55;
29490
+ setDash3(ctx, ov?.style ?? cs.style);
28757
29491
  ctx.beginPath();
28758
29492
  ctx.moveTo(x, 0);
28759
29493
  ctx.lineTo(x, dataH);
28760
- if (ext.y != null) {
29494
+ if (ext.y != null && ov?.horizontal !== false) {
28761
29495
  ctx.moveTo(0, Math.round(ext.y) + 0.5);
28762
29496
  ctx.lineTo(dataW, Math.round(ext.y) + 0.5);
28763
29497
  }
@@ -28766,7 +29500,7 @@ var CrosshairRenderer = class {
28766
29500
  ctx.lineWidth = 1;
28767
29501
  ctx.globalAlpha = 0.8;
28768
29502
  const chipBg = cs.labelBackground ?? theme.borderColor;
28769
- if (ext.y != null && ext.price != null) {
29503
+ if (ext.y != null && ext.price != null && ov?.horizontal !== false) {
28770
29504
  let pane;
28771
29505
  for (const p of scene.panes.values()) {
28772
29506
  if (ext.y >= p.bounds.top && ext.y <= p.bounds.top + p.bounds.height) {
@@ -28806,6 +29540,9 @@ var CrosshairRenderer = class {
28806
29540
  ctx.textAlign = "start";
28807
29541
  }
28808
29542
  };
29543
+ function crisp(px, width) {
29544
+ return Math.round(px) + (Math.round(width) % 2 === 1 ? 0.5 : 0);
29545
+ }
28809
29546
  function setDash3(ctx, style) {
28810
29547
  if (style === "dashed") ctx.setLineDash([6, 4]);
28811
29548
  else if (style === "dotted") ctx.setLineDash([2, 3]);
@@ -36244,7 +36981,7 @@ var NativeRenderer = class {
36244
36981
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
36245
36982
  this.priceStyleCbs = /* @__PURE__ */ new Set();
36246
36983
  this.name = "native";
36247
- this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "animScroll", "animAutoscale", "animLiveBar", "intro", "zoomAnchor", "axisDrag", "paneResize", "candleZOrder", "candleVisible", "seriesOrder", "highlights", "sessionZones", "gridlines", "axisLabels", "scaleMode", "invertScale", "paneScales", "autoScale", "timezone", "keyboard", "historyChords", "priceStyle", "priceBaseline", "baselinePrice", "settings", "attribution", "dialogHost", "tradeMarkers", "marks", "indicatorTitles", "indicatorValues"];
36984
+ this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "animScroll", "animAutoscale", "animLiveBar", "intro", "zoomAnchor", "axisDrag", "paneResize", "candleZOrder", "candleVisible", "seriesOrder", "highlights", "sessionZones", "gridlines", "axisLabels", "scaleMode", "invertScale", "paneScales", "autoScale", "timezone", "keyboard", "historyChords", "priceStyle", "priceBaseline", "baselinePrice", "settings", "attribution", "dialogHost", "tradeMarkers", "marks", "indicatorTitles", "indicatorValues", "crosshairOverride"];
36248
36985
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
36249
36986
  * so moving onto the button doesn't count as leaving). */
36250
36987
  this.onScrollProximityMove = (e) => {
@@ -36423,6 +37160,9 @@ var NativeRenderer = class {
36423
37160
  this.scene.marks = mergeMarksState(this.scene.marks, value);
36424
37161
  this.markPopover?.close();
36425
37162
  break;
37163
+ case "crosshairOverride":
37164
+ this.scene.crosshairOverride = sanitizeCrosshairOverride(value);
37165
+ break;
36426
37166
  case "keyboard":
36427
37167
  this.setKeyboardEnabled(Boolean(value));
36428
37168
  return;
@@ -36536,6 +37276,8 @@ var NativeRenderer = class {
36536
37276
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
36537
37277
  case "marks":
36538
37278
  return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
37279
+ case "crosshairOverride":
37280
+ return this.scene.crosshairOverride ? { ...this.scene.crosshairOverride } : null;
36539
37281
  case "keyboard":
36540
37282
  return this.keyboardEnabled;
36541
37283
  case "historyChords":
@@ -37114,8 +37856,8 @@ var NativeRenderer = class {
37114
37856
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
37115
37857
  this.modelAlpha = 0;
37116
37858
  const start = performance.now();
37117
- const step = (now) => {
37118
- const p = Math.min(1, (now - start) / duration);
37859
+ const step = (now2) => {
37860
+ const p = Math.min(1, (now2 - start) / duration);
37119
37861
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
37120
37862
  this.paintData();
37121
37863
  if (p < 1) {
@@ -37131,8 +37873,8 @@ var NativeRenderer = class {
37131
37873
  fadeInModels() {
37132
37874
  const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
37133
37875
  const start = performance.now();
37134
- const step = (now) => {
37135
- this.modelAlpha = Math.min(1, (now - start) / fade);
37876
+ const step = (now2) => {
37877
+ this.modelAlpha = Math.min(1, (now2 - start) / fade);
37136
37878
  this.paintData();
37137
37879
  if (this.modelAlpha < 1) {
37138
37880
  this.introRaf = requestAnimationFrame(step);
@@ -37655,10 +38397,13 @@ var NativeRenderer = class {
37655
38397
  this.animator.start();
37656
38398
  }
37657
38399
  } else if (!last || bar.time > last.time) {
38400
+ const vp = this.coords.getViewport();
38401
+ const holdView = n > 0 && vp.rightOffset < 0 && this.scrollTargetRO === null;
37658
38402
  this.bars.push(bar);
37659
38403
  this.syncLiveEase(bar);
37660
38404
  if (this.coords.barInterval > 0) this.coords.appendBar(bar.time);
37661
38405
  else this.coords.setBars(this.bars.map((b) => b.time));
38406
+ if (holdView) this.coords.setViewport({ ...vp, rightOffset: vp.rightOffset - 1 });
37662
38407
  } else {
37663
38408
  return;
37664
38409
  }
@@ -38340,10 +39085,10 @@ var NativeRenderer = class {
38340
39085
  if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
38341
39086
  const tick = () => {
38342
39087
  this.markPulseRaf = null;
38343
- const now = frameNow();
38344
- if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
39088
+ const now2 = frameNow();
39089
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now2) this.scene.marksFlash = null;
38345
39090
  this.scheduler?.invalidate(2 /* Chrome */);
38346
- const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
39091
+ const pulsing = this.scene.marksHoverKey !== null && now2 - this.scene.marksHoverSince < MARK_PULSE_MS;
38347
39092
  if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
38348
39093
  };
38349
39094
  this.markPulseRaf = requestAnimationFrame(tick);
@@ -38904,9 +39649,10 @@ var NativeRenderer = class {
38904
39649
  const ext = this.externalCross;
38905
39650
  if (!ext || this.coords.barCount === 0) return null;
38906
39651
  const logical = Math.floor(this.coords.timeToLogical(ext.time));
38907
- if (logical < 0 || logical >= this.coords.barCount) return null;
38908
- const x = this.coords.logicalToX(logical);
38909
- if (!Number.isFinite(x) || x < 0 || x > this.coords.width) return null;
39652
+ if (!(logical < this.coords.barCount)) return null;
39653
+ const x = this.coords.logicalToX(Math.max(logical, -1));
39654
+ if (!Number.isFinite(x)) return null;
39655
+ const line = logical >= 0 && x >= 0 && x <= this.coords.width;
38910
39656
  let y = null;
38911
39657
  if (ext.price != null) {
38912
39658
  const pricePane = this.scene.orderedPanes().find((p) => p.kind === "price");
@@ -38915,7 +39661,7 @@ var NativeRenderer = class {
38915
39661
  if (!Number.isFinite(y) || y < pricePane.bounds.top || y > pricePane.bounds.top + pricePane.bounds.height) y = null;
38916
39662
  }
38917
39663
  }
38918
- return { x, y, time: this.coords.logicalToTime(logical), price: y != null ? ext.price : null };
39664
+ return { x, y, time: this.coords.logicalToTime(Math.max(logical, 0)), price: y != null ? ext.price : null, line };
38919
39665
  }
38920
39666
  /** Set the sticky magnet mode (called by the drawings toolbar's 3-state button). */
38921
39667
  setSnapMode(mode) {
@@ -43591,6 +44337,7 @@ var Vela = class {
43591
44337
  this.panesControl = new PanesControl(this.orchestrator);
43592
44338
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
43593
44339
  this.marksControl = new MarksControl(this.orchestrator.marks);
44340
+ this.replayControl = new ReplayControl(this.orchestrator);
43594
44341
  }
43595
44342
  /**
43596
44343
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -43830,6 +44577,16 @@ var Vela = class {
43830
44577
  get marks() {
43831
44578
  return this.marksControl;
43832
44579
  }
44580
+ /**
44581
+ * The chart's bar-replay control surface: rewind to a past bar and reveal the
44582
+ * following ones by hand or on a timer, from the history already loaded —
44583
+ * `await chart.replay.start({ from })`, `chart.replay.play(2000)`, `chart.replay.step()`,
44584
+ * `chart.replay.stop()`. Revealed bars behave like live bars for indicators; live
44585
+ * updates pause meanwhile. Follow it with the `replay:*` events.
44586
+ */
44587
+ get replay() {
44588
+ return this.replayControl;
44589
+ }
43833
44590
  on(event, handler) {
43834
44591
  return this.orchestrator.events.on(event, handler);
43835
44592
  }
@@ -44025,9 +44782,15 @@ var CSS21 = `
44025
44782
  }
44026
44783
  .vela-statusline .vela-sl-symbol { font-weight: 600; font-size: var(--vela-font-size-lg); }
44027
44784
  .vela-statusline .vela-sl-meta { color: var(--vela-fg-muted); font-size: var(--vela-font-size-md); font-weight: 600; }
44785
+ /* The meta opens with "\xB7 " \u2014 sit its dot one space-width after the ticker, as far as the
44786
+ * venue sits after it, not a full row gap away. */
44787
+ .vela-statusline .vela-sl-symbol + .vela-sl-meta { margin-left: calc(var(--vela-space-1) - var(--vela-space-2)); }
44028
44788
  /* Market status badge \u2014 a kit callout bubble (icon-only 16px circle, label on hover
44029
- * via the kit tooltip); the session tint is applied per status in setMarketStatus. */
44030
- .vela-statusline .vela-sl-market { align-self: center; }
44789
+ * via the kit tooltip); the session tint is applied per status in setMarketStatus. While
44790
+ * the chart replays past bars it wears the replay badge instead (the inverse chip). */
44791
+ .vela-statusline .vela-sl-market { align-self: center; display: inline-flex; }
44792
+ .vela-statusline .vela-sl-market > [hidden] { display: none !important; }
44793
+ .vela-statusline .vela-sl-replay-badge, .vela-statusline .vela-sl-replay-badge svg { display: block; width: 16px; height: 16px; }
44031
44794
  .vela-statusline .vela-sl-ohlc { display: flex; gap: var(--vela-space-1); color: var(--vela-fg-muted); }
44032
44795
  .vela-statusline .vela-sl-ohlc b { color: var(--vela-fg); font-weight: 500; }
44033
44796
  /* The change value wears the SAME ink as the OHLC values (set inline per render) \u2014
@@ -44080,6 +44843,8 @@ var MARKET_LABELS = {
44080
44843
  closed: "Market Closed",
44081
44844
  holiday: "Market Holiday"
44082
44845
  };
44846
+ var REPLAY_LABEL = "Replay Mode";
44847
+ var REPLAY_BADGE_SVG = '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="8" style="fill: var(--vela-selected-bg)"/><path d="M11.7 4.75v6.5L7.2 8zM7.2 4.75v6.5L2.7 8z" style="fill: var(--vela-selected-fg); stroke: var(--vela-selected-fg); stroke-width: 0.8; stroke-linejoin: round"/></svg>';
44083
44848
  var MARKET_INKS = {
44084
44849
  open: "var(--vela-up)",
44085
44850
  pre: SESSION_PRE,
@@ -44118,6 +44883,9 @@ var Statusline = class {
44118
44883
  this.host = host;
44119
44884
  this.iconFor = iconFor;
44120
44885
  this.parts = { logo: true, name: true, market: true, ohlc: true, change: true };
44886
+ this.marketStatus = "open";
44887
+ /** The chart replays past bars — the badge shows the replay mode (see {@link setReplaying}). */
44888
+ this.replaying = false;
44121
44889
  /** The right-click action menu — present once a host wires it via {@link attachMenu}. */
44122
44890
  this.menu = null;
44123
44891
  this.menuHooks = null;
@@ -44169,8 +44937,13 @@ var Statusline = class {
44169
44937
  label: MARKET_LABELS.open,
44170
44938
  host
44171
44939
  });
44172
- this.marketEl = this.marketBubble.el;
44173
- this.marketEl.classList.add("vela-sl-market");
44940
+ this.replayBadge = doc.createElement("span");
44941
+ this.replayBadge.className = "vela-sl-replay-badge";
44942
+ this.replayBadge.innerHTML = REPLAY_BADGE_SVG;
44943
+ this.replayBadge.hidden = true;
44944
+ this.marketEl = doc.createElement("span");
44945
+ this.marketEl.className = "vela-sl-market";
44946
+ this.marketEl.append(this.marketBubble.el, this.replayBadge);
44174
44947
  this.ohlcEl = doc.createElement("span");
44175
44948
  this.ohlcEl.className = "vela-sl-ohlc";
44176
44949
  this.changeEl = doc.createElement("span");
@@ -44292,6 +45065,25 @@ var Statusline = class {
44292
45065
  /** Dress the market badge for a session state: its icon, tinted circle, and the
44293
45066
  * hover label. Callers with no session model leave the constructor's 'open'. */
44294
45067
  setMarketStatus(status) {
45068
+ this.marketStatus = status;
45069
+ this.dressBadge();
45070
+ }
45071
+ /** The chart entered or left a bar replay: the badge shows the replay mode meanwhile
45072
+ * (the session state of a past bar says nothing about the market now). */
45073
+ setReplaying(replaying) {
45074
+ if (replaying === this.replaying) return;
45075
+ this.replaying = replaying;
45076
+ this.dressBadge();
45077
+ }
45078
+ dressBadge() {
45079
+ this.marketBubble.el.hidden = this.replaying;
45080
+ this.replayBadge.hidden = !this.replaying;
45081
+ if (this.replaying) {
45082
+ this.marketEl.dataset.status = "replay";
45083
+ this.marketTip.setContent(REPLAY_LABEL);
45084
+ return;
45085
+ }
45086
+ const status = this.marketStatus;
44295
45087
  this.marketEl.dataset.status = status;
44296
45088
  const ink = MARKET_INKS[status];
44297
45089
  this.marketBubble.set({
@@ -44364,11 +45156,23 @@ var Statusline = class {
44364
45156
  this.lastBar = b;
44365
45157
  if (!this.hoverBar) this.render();
44366
45158
  }),
45159
+ // A replay cut or restore replaces the newest bar without a `bar` event.
45160
+ chart.on("replay:start", () => {
45161
+ this.lastBar = null;
45162
+ this.setReplaying(true);
45163
+ this.render();
45164
+ }),
45165
+ chart.on("replay:end", () => {
45166
+ this.lastBar = null;
45167
+ this.setReplaying(false);
45168
+ this.render();
45169
+ }),
44367
45170
  chart.renderer.onCrosshairMove((e) => {
44368
45171
  this.hoverBar = e.ohlc;
44369
45172
  this.render();
44370
45173
  })
44371
45174
  );
45175
+ this.setReplaying(chart.replay.state.active);
44372
45176
  this.render();
44373
45177
  }
44374
45178
  destroy() {
@@ -44609,26 +45413,26 @@ function isWeekday(ms, tz) {
44609
45413
  return false;
44610
45414
  }
44611
45415
  }
44612
- function deriveMarketStatus(now, w, tz, overnight = false) {
44613
- const within = (ws) => ws.find(([s, e]) => now >= s && now < e);
45416
+ function deriveMarketStatus(now2, w, tz, overnight = false) {
45417
+ const within = (ws) => ws.find(([s, e]) => now2 >= s && now2 < e);
44614
45418
  if (within(w.regular)) return "open";
44615
45419
  const ext = within(w.extended);
44616
45420
  if (ext) {
44617
45421
  if (overnight) return "extended";
44618
- return w.regular.some(([s]) => s >= now && s < ext[1]) ? "pre" : "post";
45422
+ return w.regular.some(([s]) => s >= now2 && s < ext[1]) ? "pre" : "post";
44619
45423
  }
44620
- if (isWeekday(now, tz)) {
44621
- const today = civilDate(now, tz);
45424
+ if (isWeekday(now2, tz)) {
45425
+ const today = civilDate(now2, tz);
44622
45426
  if (today !== "" && !w.extended.some(([s]) => civilDate(s, tz) === today)) return "holiday";
44623
45427
  }
44624
45428
  return "closed";
44625
45429
  }
44626
- function nextStatusBoundary(now, w) {
45430
+ function nextStatusBoundary(now2, w) {
44627
45431
  let next = null;
44628
45432
  for (const ws of [w.regular, w.extended]) {
44629
45433
  for (const [s, e] of ws) {
44630
45434
  for (const t of [s, e]) {
44631
- if (t > now && (next == null || t < next)) next = t;
45435
+ if (t > now2 && (next == null || t < next)) next = t;
44632
45436
  }
44633
45437
  }
44634
45438
  }
@@ -44667,8 +45471,8 @@ var MarketStatusTracker = class {
44667
45471
  return;
44668
45472
  }
44669
45473
  const tz = typeof si?.timezone === "string" && si.timezone !== "" ? si.timezone : "Etc/UTC";
44670
- const now = Date.now();
44671
- const range = { from: now - FETCH_BACK_MS, to: now + FETCH_AHEAD_MS };
45474
+ const now2 = Date.now();
45475
+ const range = { from: now2 - FETCH_BACK_MS, to: now2 + FETCH_AHEAD_MS };
44672
45476
  const [regular, extended] = await Promise.all([
44673
45477
  provider.getCalendar(resolved.ticker, { ...range, session: "regular" }).catch(() => null),
44674
45478
  provider.getCalendar(resolved.ticker, { ...range, session: "extended" }).catch(() => null)
@@ -44680,9 +45484,9 @@ var MarketStatusTracker = class {
44680
45484
  }
44681
45485
  const w = { regular, extended };
44682
45486
  const overnight = parseSessionSpec(si)?.overnight === true;
44683
- this.onStatus(deriveMarketStatus(now, w, tz, overnight));
44684
- const boundary = nextStatusBoundary(now, w);
44685
- const delay = Math.min(boundary != null ? boundary - now : MAX_TIMER_MS, MAX_TIMER_MS);
45487
+ this.onStatus(deriveMarketStatus(now2, w, tz, overnight));
45488
+ const boundary = nextStatusBoundary(now2, w);
45489
+ const delay = Math.min(boundary != null ? boundary - now2 : MAX_TIMER_MS, MAX_TIMER_MS);
44686
45490
  this.arm(my, data, symbol, Math.max(delay, MIN_TIMER_MS));
44687
45491
  }
44688
45492
  arm(my, data, symbol, delay) {
@@ -44715,6 +45519,7 @@ var CSS22 = `
44715
45519
  left: var(--vela-toolbar-gutter, 0px);
44716
45520
  right: var(--vela-scale-gutter, 0px);
44717
45521
  display: flex;
45522
+ flex-direction: column;
44718
45523
  align-items: center;
44719
45524
  justify-content: center;
44720
45525
  overflow: hidden;
@@ -44728,6 +45533,14 @@ var CSS22 = `
44728
45533
  user-select: none;
44729
45534
  white-space: nowrap;
44730
45535
  }
45536
+ .vela-watermark-replay {
45537
+ display: inline-flex;
45538
+ align-items: center;
45539
+ gap: 0.25em;
45540
+ margin-top: 0.1em;
45541
+ font-size: 0.8em;
45542
+ }
45543
+ .vela-watermark [hidden] { display: none !important; }
44731
45544
  `;
44732
45545
  function watermarkFontPx(availPx, textPxAtMax) {
44733
45546
  if (textPxAtMax <= 0) return MAX_FONT_PX;
@@ -44738,6 +45551,10 @@ var Watermark = class {
44738
45551
  this.resizeObserver = null;
44739
45552
  /** The host's visibility preference (the persisted watermark toggle). */
44740
45553
  this.shown = true;
45554
+ /** The host's preference for the replay line (its own persisted toggle). */
45555
+ this.replayShown = true;
45556
+ /** The chart is replaying past bars. */
45557
+ this.replaying = false;
44741
45558
  /** A bar load is in flight with nothing painted — the loading affordance owns the
44742
45559
  * canvas, so the mark stays out of its way. Starts true: the FIRST `load:start`
44743
45560
  * fires during chart construction, before any subscriber can see it. */
@@ -44747,7 +45564,10 @@ var Watermark = class {
44747
45564
  this.el.className = "vela-watermark";
44748
45565
  this.el.dataset.velaScreenshot = "under";
44749
45566
  this.text = host.ownerDocument.createElement("span");
44750
- this.el.appendChild(this.text);
45567
+ this.replayLine = host.ownerDocument.createElement("span");
45568
+ this.replayLine.className = "vela-watermark-replay";
45569
+ this.replayLine.append(iconEl("replay", host.ownerDocument), host.ownerDocument.createTextNode("Replay"));
45570
+ this.el.append(this.text, this.replayLine);
44751
45571
  host.appendChild(this.el);
44752
45572
  if (typeof ResizeObserver !== "undefined") {
44753
45573
  this.resizeObserver = new ResizeObserver(() => this.fit());
@@ -44760,13 +45580,26 @@ var Watermark = class {
44760
45580
  this.shown = visible;
44761
45581
  this.sync();
44762
45582
  }
45583
+ /** Show/hide the replay line (the host's toggle); it only ever shows while replaying. */
45584
+ setReplayVisible(visible) {
45585
+ this.replayShown = visible;
45586
+ this.sync();
45587
+ }
45588
+ /** The chart entered or left a bar replay. */
45589
+ setReplaying(replaying) {
45590
+ this.replaying = replaying;
45591
+ this.sync();
45592
+ }
44763
45593
  /** Loading and the watermark never share the canvas — hidden while a load is up. */
44764
45594
  setLoading(loading) {
44765
45595
  this.loading = loading;
44766
45596
  this.sync();
44767
45597
  }
44768
45598
  sync() {
44769
- this.el.style.display = this.shown && !this.loading ? "" : "none";
45599
+ const replay = this.replayShown && this.replaying;
45600
+ this.text.hidden = !this.shown;
45601
+ this.replayLine.hidden = !replay;
45602
+ this.el.style.display = (this.shown || replay) && !this.loading ? "" : "none";
44770
45603
  }
44771
45604
  update(symbol, timeframe) {
44772
45605
  this.text.textContent = symbol ? `${parseSymbol(symbol).ticker} \xB7 ${timeframeLabel(timeframe)}` : "";
@@ -45355,8 +46188,12 @@ var ChartCell = class {
45355
46188
  this.indicatorValuesOn = seed.indicatorValues ?? true;
45356
46189
  if (!this.indicatorValuesOn) this.inner.renderer.set("indicatorValues", false);
45357
46190
  this.watermarkOn = seed.watermark ?? deps.watermark;
46191
+ this.replayWatermarkOn = seed.replayWatermark ?? true;
45358
46192
  this.watermark = deps.watermark ? new Watermark(this.host, symbol ?? "", seed.timeframe ?? "60") : null;
45359
46193
  if (!this.watermarkOn) this.watermark?.setVisible(false);
46194
+ if (!this.replayWatermarkOn) this.watermark?.setReplayVisible(false);
46195
+ this.inner.on("replay:start", () => this.watermark?.setReplaying(true));
46196
+ this.inner.on("replay:end", () => this.watermark?.setReplaying(false));
45360
46197
  this.statusline = deps.statusline ? new Statusline(this.host, symbol ?? "", (sym) => this.inner?.data.symbolIcon(sym)) : null;
45361
46198
  this.statusline?.setMeta(seed.timeframe ?? "60", this.state.provider ?? "");
45362
46199
  this.statusline?.onChart(this.inner);
@@ -45551,10 +46388,10 @@ var ChartCell = class {
45551
46388
  const chart = this.inner;
45552
46389
  const symbol = this.state.symbol;
45553
46390
  if (!chart || !symbol) return;
45554
- const now = Date.now();
46391
+ const now2 = Date.now();
45555
46392
  const requestedSpan = Math.max(this.state.bars ?? 1e3, this.rangeBars) * timeframeMs(this.state.timeframe ?? "60");
45556
46393
  const fallbackSpan = Number.isFinite(requestedSpan) ? Math.max(3 * 864e5, requestedSpan) : 3 * 864e5;
45557
- const range = chart.getVisibleRange() ?? { from: now - fallbackSpan, to: now };
46394
+ const range = chart.getVisibleRange() ?? { from: now2 - fallbackSpan, to: now2 };
45558
46395
  this.sessionShading.track(chart.data, symbol, { session: this.session, timeframe: this.timeframe, range });
45559
46396
  }
45560
46397
  /** The session-shade colors live in the renderer CONFIG (persisted with it, edited
@@ -45670,6 +46507,13 @@ var ChartCell = class {
45670
46507
  id: "visible",
45671
46508
  get: () => this.watermarkOn,
45672
46509
  set: (v) => this.setWatermarkVisible(v)
46510
+ },
46511
+ {
46512
+ kind: "toggle",
46513
+ label: "Replay watermark",
46514
+ id: "replay",
46515
+ get: () => this.replayWatermarkOn,
46516
+ set: (v) => this.setReplayWatermarkVisible(v)
45673
46517
  }
45674
46518
  ]
45675
46519
  };
@@ -45738,6 +46582,12 @@ var ChartCell = class {
45738
46582
  this.watermark?.setVisible(visible);
45739
46583
  this.deps.onStateDirty();
45740
46584
  }
46585
+ /** Show/hide the "Replay" line under this cell's watermark while it replays (persisted per cell). */
46586
+ setReplayWatermarkVisible(visible) {
46587
+ this.replayWatermarkOn = visible;
46588
+ this.watermark?.setReplayVisible(visible);
46589
+ this.deps.onStateDirty();
46590
+ }
45741
46591
  /** Show/hide this cell's indicator titles — the in-chart legend rows (persisted per cell). */
45742
46592
  setIndicatorTitlesVisible(visible) {
45743
46593
  this.indicatorTitlesOn = visible;
@@ -45978,7 +46828,7 @@ var ChartCell = class {
45978
46828
  * then the manifest in the host's own order. */
45979
46829
  libraryRows() {
45980
46830
  return [
45981
- ...this.supportedNatives().map((n) => ({ name: n.title, category: "Vela", native: true, nativeType: n.type, beta: n.beta })),
46831
+ ...this.supportedNatives().map((n) => ({ name: n.title, category: "Built-in", native: true, nativeType: n.type, beta: n.beta })),
45982
46832
  ...this.manifest.map((e) => ({ name: e.name, language: e.language, category: e.category }))
45983
46833
  ];
45984
46834
  }
@@ -46211,6 +47061,7 @@ var ChartCell = class {
46211
47061
  if (!this.inner || this.destroyed) return;
46212
47062
  if (cs.priceStyle && cs.priceStyle !== this.priceStyle) this.setPriceStyle(cs.priceStyle);
46213
47063
  if (cs.watermark !== void 0 && cs.watermark !== this.watermarkOn) this.setWatermarkVisible(cs.watermark);
47064
+ if (cs.replayWatermark !== void 0 && cs.replayWatermark !== this.replayWatermarkOn) this.setReplayWatermarkVisible(cs.replayWatermark);
46214
47065
  if (cs.indicatorTitles !== void 0 && cs.indicatorTitles !== this.indicatorTitlesOn) this.setIndicatorTitlesVisible(cs.indicatorTitles);
46215
47066
  if (cs.indicatorValues !== void 0 && cs.indicatorValues !== this.indicatorValuesOn) this.setIndicatorValuesVisible(cs.indicatorValues);
46216
47067
  if (cs.rendererConfig != null) this.inner.renderer.applyConfig(cs.rendererConfig);
@@ -46245,6 +47096,7 @@ var ChartCell = class {
46245
47096
  ...live ? { symbol: live.symbol, provider: live.provider, timeframe: live.timeframe } : {},
46246
47097
  priceStyle: this.priceStyle,
46247
47098
  watermark: this.watermarkOn,
47099
+ replayWatermark: this.replayWatermarkOn,
46248
47100
  indicatorTitles: this.indicatorTitlesOn,
46249
47101
  indicatorValues: this.indicatorValuesOn,
46250
47102
  rendererConfig: this.inner?.renderer.getConfig() ?? void 0,
@@ -46309,6 +47161,7 @@ function buildContext(host) {
46309
47161
  setPriceStyle: (style) => host.active()?.setPriceStyle(style),
46310
47162
  openSymbolSearch: (query) => host.openSymbolSearch(query),
46311
47163
  togglePanel: (id, open2) => host.togglePanel(id, open2),
47164
+ dockStrip: (el) => host.dockStrip(el),
46312
47165
  host: host.root,
46313
47166
  toast: (message, kind) => host.toast(message, kind),
46314
47167
  addIndicator: (entry) => host.active()?.addExternalIndicator(entry),
@@ -46320,10 +47173,303 @@ function buildContext(host) {
46320
47173
  get activeCellId() {
46321
47174
  return host.active()?.id ?? "";
46322
47175
  },
46323
- setActiveCell: (id) => host.setActiveCell(id)
47176
+ setActiveCell: (id) => host.setActiveCell(id),
47177
+ replay: host.replay
46324
47178
  };
46325
47179
  }
46326
47180
 
47181
+ // src/workspace/WorkspaceReplay.ts
47182
+ var MAX_BARS_PER_STEP = 1e5;
47183
+ var WorkspaceReplay = class {
47184
+ constructor(host) {
47185
+ this.host = host;
47186
+ this.bus = new TypedEventBus();
47187
+ /** The running session: the shared replay time (every chart shows the bars closed by it). */
47188
+ this.session = null;
47189
+ this.playing = false;
47190
+ this.intervalMs = 1e3;
47191
+ this.timer = null;
47192
+ /** Cells whose replay a symbol switch ended — restarted at the shared time once their bars land. */
47193
+ this.rejoining = /* @__PURE__ */ new Set();
47194
+ this.cellSubs = /* @__PURE__ */ new Map();
47195
+ /** Inside a group step: chart events are the controller's own doing. */
47196
+ this.stepping = false;
47197
+ /** Inside `finish`: the charts' `replay:end` events are the controller's own doing. */
47198
+ this.stopping = false;
47199
+ /** Driving a chart's own timer: its play/pause events are not the user's. */
47200
+ this.muted = 0;
47201
+ /** A chart finished during a group step — the session ends right after it. */
47202
+ this.finishedDuringStep = false;
47203
+ for (const cell of host.cells()) this.watch(cell);
47204
+ this.offHost = host.onCells((e) => {
47205
+ if (e.kind === "created") this.onCellCreated(e.id);
47206
+ else if (e.kind === "destroyed") this.onCellDestroyed(e.id);
47207
+ });
47208
+ }
47209
+ /**
47210
+ * Enter replay on every cell (or seek, when already replaying) — paused. Resolves once
47211
+ * every chart shows its rewound history (a chart whose history does not reach that far
47212
+ * loads older bars first). A chart with nothing to replay after the shared time stays
47213
+ * as it is.
47214
+ */
47215
+ async start(opts) {
47216
+ const cells = this.host.cells();
47217
+ const ref = cells.find((c) => c.id === (opts.cell ?? this.host.activeId())) ?? cells[0];
47218
+ if (!ref) return;
47219
+ if (this.playing) this.pause();
47220
+ this.rejoining.clear();
47221
+ await ref.chart.replay.start({ from: opts.from });
47222
+ const cursor = ref.chart.replay.state.cursorTime;
47223
+ if (cursor == null) return;
47224
+ const session = { clock: barClose(cursor, ref.chart.market.timeframe) };
47225
+ this.session = session;
47226
+ await Promise.all(this.host.cells().filter((c) => c.id !== ref.id).map((c) => this.startCell(c, session.clock)));
47227
+ if (this.session !== session) return;
47228
+ this.syncPlayback();
47229
+ const s = this.activeChart()?.replay.state;
47230
+ this.bus.emit("replay:start", { cursorTime: s?.cursorTime ?? cursor, remaining: s?.remaining ?? 0 });
47231
+ }
47232
+ /** Reveal the next bar(s): the clock moves to the next bar close on any chart. Returns false when nothing is left. */
47233
+ step() {
47234
+ const solo = this.solo();
47235
+ return solo ? solo.replay.step() : this.stepGroup();
47236
+ }
47237
+ /** Reveal the next update — in a single-chart layout, the chart's `stepUpdate()` (tick replay); otherwise {@link step}. */
47238
+ stepUpdate() {
47239
+ const solo = this.solo();
47240
+ return solo ? solo.replay.stepUpdate() : this.stepGroup();
47241
+ }
47242
+ /** Advance one update every `intervalMs` (default: the last pace, initially 1000). Calling it again while playing changes the pace. */
47243
+ play(intervalMs2) {
47244
+ if (!this.session) return this;
47245
+ if (intervalMs2 !== void 0) {
47246
+ if (!(Number.isFinite(intervalMs2) && intervalMs2 > 0)) {
47247
+ console.warn(`[vela] workspace.replay.play(${intervalMs2}) ignored \u2014 the interval must be a positive number of ms`);
47248
+ return this;
47249
+ }
47250
+ this.intervalMs = intervalMs2;
47251
+ }
47252
+ this.playing = true;
47253
+ this.clearTimer();
47254
+ this.syncPlayback();
47255
+ this.bus.emit("replay:play", { intervalMs: this.intervalMs });
47256
+ return this;
47257
+ }
47258
+ pause() {
47259
+ if (!this.playing) return this;
47260
+ this.playing = false;
47261
+ this.syncPlayback();
47262
+ this.bus.emit("replay:pause", void 0);
47263
+ return this;
47264
+ }
47265
+ /** Leave replay on every chart: full history back, live updates resumed. */
47266
+ stop() {
47267
+ this.finish("stopped");
47268
+ return this;
47269
+ }
47270
+ /** `active`/`playing`/`intervalMs` for the whole workspace; the cursor fields read the ACTIVE cell's chart. */
47271
+ get state() {
47272
+ const s = this.session ? this.activeChart()?.replay.state : void 0;
47273
+ return {
47274
+ active: this.session !== null,
47275
+ playing: this.playing,
47276
+ cursorTime: s?.cursorTime ?? null,
47277
+ remaining: s?.remaining ?? 0,
47278
+ nextTime: s?.nextTime ?? null,
47279
+ intervalMs: this.intervalMs
47280
+ };
47281
+ }
47282
+ /** The active cell's replayable history (`chart.replay.bounds`). */
47283
+ get bounds() {
47284
+ return this.activeChart()?.replay.bounds ?? null;
47285
+ }
47286
+ on(event, handler) {
47287
+ return this.bus.on(event, handler);
47288
+ }
47289
+ destroy() {
47290
+ this.session = null;
47291
+ this.playing = false;
47292
+ this.clearTimer();
47293
+ this.offHost();
47294
+ for (const off of this.cellSubs.values()) off();
47295
+ this.cellSubs.clear();
47296
+ this.bus.clear();
47297
+ }
47298
+ // ── internals ──
47299
+ /** The one chart of a single-chart layout — it replays on its own, tick replay included. */
47300
+ solo() {
47301
+ const cells = this.host.cells();
47302
+ return cells.length === 1 ? cells[0].chart : null;
47303
+ }
47304
+ activeChart() {
47305
+ const cells = this.host.cells();
47306
+ return (cells.find((c) => c.id === this.host.activeId()) ?? cells[0])?.chart ?? null;
47307
+ }
47308
+ /** Rewind one chart to the shared time: every bar that closed by `clock`, none after. */
47309
+ async startCell(cell, clock) {
47310
+ const replay = cell.chart.replay;
47311
+ const from = lastOpenClosedBy(clock, cell.chart.market.timeframe);
47312
+ const bounds = replay.bounds;
47313
+ if (!bounds || bounds.last <= from) return;
47314
+ await replay.start({ from });
47315
+ }
47316
+ /** Move the clock to the next bar close on any chart; each chart reveals what closed by then. */
47317
+ stepGroup() {
47318
+ const session = this.session;
47319
+ if (!session) return false;
47320
+ const cells = this.host.cells();
47321
+ let next = Infinity;
47322
+ for (const c of cells) {
47323
+ const t = c.chart.replay.state.nextTime;
47324
+ if (t != null) next = Math.min(next, barClose(t, c.chart.market.timeframe));
47325
+ }
47326
+ if (!Number.isFinite(next)) return false;
47327
+ this.stepping = true;
47328
+ try {
47329
+ for (const c of cells) {
47330
+ const tf = c.chart.market.timeframe;
47331
+ for (let n = 0; n < MAX_BARS_PER_STEP; n += 1) {
47332
+ const t = c.chart.replay.state.nextTime;
47333
+ if (t == null || barClose(t, tf) > next) break;
47334
+ if (!c.chart.replay.step()) break;
47335
+ }
47336
+ }
47337
+ } finally {
47338
+ this.stepping = false;
47339
+ }
47340
+ session.clock = next;
47341
+ if (this.finishedDuringStep) {
47342
+ this.finishedDuringStep = false;
47343
+ this.finish("finished");
47344
+ return true;
47345
+ }
47346
+ const s = this.activeChart()?.replay.state;
47347
+ if (s?.cursorTime != null) this.bus.emit("replay:step", { cursorTime: s.cursorTime, remaining: s.remaining });
47348
+ return true;
47349
+ }
47350
+ /** Put the timers in the state `playing` asks for: a single chart runs its own (it paces
47351
+ * ticks), several run on the controller's clock. */
47352
+ syncPlayback() {
47353
+ const solo = this.solo();
47354
+ const run = this.playing && this.session !== null;
47355
+ this.muted += 1;
47356
+ try {
47357
+ for (const c of this.host.cells()) {
47358
+ if (c.chart === solo && run) {
47359
+ if (!c.chart.replay.state.playing || c.chart.replay.state.intervalMs !== this.intervalMs) c.chart.replay.play(this.intervalMs);
47360
+ } else if (c.chart.replay.state.playing) {
47361
+ c.chart.replay.pause();
47362
+ }
47363
+ }
47364
+ } finally {
47365
+ this.muted -= 1;
47366
+ }
47367
+ if (!run || solo) this.clearTimer();
47368
+ else if (this.timer == null) this.schedule();
47369
+ }
47370
+ schedule() {
47371
+ this.timer = setTimeout(() => {
47372
+ this.timer = null;
47373
+ if (!this.playing || !this.session || this.solo()) return;
47374
+ if (this.stepGroup() || this.rejoining.size > 0) {
47375
+ if (this.playing && this.session && this.timer == null) this.schedule();
47376
+ } else {
47377
+ this.pause();
47378
+ }
47379
+ }, this.intervalMs);
47380
+ }
47381
+ clearTimer() {
47382
+ if (this.timer != null) clearTimeout(this.timer);
47383
+ this.timer = null;
47384
+ }
47385
+ /** End the session on every chart (idempotent). */
47386
+ finish(reason) {
47387
+ const had = this.session !== null;
47388
+ this.session = null;
47389
+ this.playing = false;
47390
+ this.clearTimer();
47391
+ this.rejoining.clear();
47392
+ this.stopping = true;
47393
+ try {
47394
+ for (const c of this.host.cells()) c.chart.replay.stop();
47395
+ } finally {
47396
+ this.stopping = false;
47397
+ }
47398
+ if (had) this.bus.emit("replay:end", { reason });
47399
+ }
47400
+ watch(cell) {
47401
+ const { id, chart } = cell;
47402
+ const isActive = () => (this.host.activeId() ?? this.host.cells()[0]?.id) === id;
47403
+ const offs = [
47404
+ chart.on("replay:end", ({ reason }) => {
47405
+ if (!this.session || this.stopping) return;
47406
+ if (reason === "market") this.rejoining.add(id);
47407
+ else if (this.stepping) this.finishedDuringStep || (this.finishedDuringStep = reason === "finished");
47408
+ else this.finish(reason);
47409
+ }),
47410
+ chart.on("market:changed", () => {
47411
+ if (!this.rejoining.delete(id) || !this.session) return;
47412
+ void this.startCell(cell, this.session.clock).then(() => this.syncPlayback());
47413
+ }),
47414
+ chart.on("replay:step", (e) => {
47415
+ if (this.session && !this.stepping && isActive()) this.bus.emit("replay:step", e);
47416
+ }),
47417
+ chart.on("replay:tick", (e) => {
47418
+ if (this.session && isActive()) this.bus.emit("replay:tick", e);
47419
+ }),
47420
+ // A single chart runs its own timer: a pause it takes on its own (a seek) is the session's.
47421
+ chart.on("replay:pause", () => {
47422
+ if (!this.session || this.muted > 0 || !this.playing || chart !== this.solo()) return;
47423
+ this.playing = false;
47424
+ this.bus.emit("replay:pause", void 0);
47425
+ })
47426
+ ];
47427
+ this.cellSubs.set(id, () => {
47428
+ for (const off of offs) off();
47429
+ });
47430
+ }
47431
+ onCellCreated(id) {
47432
+ const cell = this.host.cells().find((c) => c.id === id);
47433
+ if (!cell) return;
47434
+ this.watch(cell);
47435
+ const session = this.session;
47436
+ if (!session) return;
47437
+ this.rejoining.add(id);
47438
+ void cell.chart.ready().then(async () => {
47439
+ if (this.session !== session || !this.rejoining.delete(id)) return;
47440
+ await this.startCell(cell, session.clock);
47441
+ this.syncPlayback();
47442
+ });
47443
+ this.syncPlayback();
47444
+ }
47445
+ onCellDestroyed(id) {
47446
+ this.cellSubs.get(id)?.();
47447
+ this.cellSubs.delete(id);
47448
+ this.rejoining.delete(id);
47449
+ this.syncPlayback();
47450
+ }
47451
+ };
47452
+ function barClose(open2, timeframe) {
47453
+ const months = monthSpan(timeframe);
47454
+ if (months > 0) {
47455
+ const d = new Date(open2);
47456
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
47457
+ }
47458
+ return open2 + timeframeToMs(timeframe ?? "60");
47459
+ }
47460
+ function lastOpenClosedBy(time, timeframe) {
47461
+ const months = monthSpan(timeframe);
47462
+ if (months > 0) {
47463
+ const d = new Date(time);
47464
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - months, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
47465
+ }
47466
+ return time - timeframeToMs(timeframe ?? "60");
47467
+ }
47468
+ function monthSpan(timeframe) {
47469
+ const m = /^(\d*)M$/.exec((timeframe ?? "").trim());
47470
+ return m ? Number(m[1] || 1) : 0;
47471
+ }
47472
+
46327
47473
  // src/workspace/layouts.ts
46328
47474
  var registry5 = /* @__PURE__ */ new Map();
46329
47475
  function registerLayout(def) {
@@ -46622,6 +47768,8 @@ var CSS24 = `
46622
47768
  .vela-workspace { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; background: var(--vela-bg); }
46623
47769
  .vela-ws-main { position: relative; display: flex; flex-direction: row; flex: 1 1 auto; min-height: 0; }
46624
47770
  .vela-ws-toolbar { position: relative; flex: none; }
47771
+ .vela-ws-strips { position: relative; flex: none; display: flex; flex-direction: column; min-width: 0; }
47772
+ .vela-ws-strips:empty { display: none; }
46625
47773
  .vela-ws-grid { position: relative; flex: 1 1 auto; min-width: 0; display: grid; gap: ${GAP_PX}px; background: var(--vela-border-soft); }
46626
47774
  .vela-cell { background: var(--vela-bg); position: relative; }
46627
47775
  /* Active-cell highlight: an overlay ring ABOVE the chart's own canvas stack (a plain
@@ -46680,11 +47828,11 @@ function resolveTyping(key, open2) {
46680
47828
  if (open2.dialogs > 0) {
46681
47829
  if (key.length !== 1) return null;
46682
47830
  if (open2.symbolSearch) return { target: "symbol", text: key.toUpperCase() };
46683
- if (open2.timeframeEntry) return { target: "timeframe", text: key };
47831
+ if (open2.timeframeEntry) return { target: "timeframe", text: key.toUpperCase() };
46684
47832
  return null;
46685
47833
  }
46686
- if (/^[a-zA-Z]$/.test(key)) return { target: "symbol", text: key.toUpperCase() };
46687
- if (/^[0-9]$/.test(key)) return { target: "timeframe", text: key };
47834
+ if (/^[a-zA-Z0]$/.test(key)) return { target: "symbol", text: key.toUpperCase() };
47835
+ if (/^[1-9]$/.test(key)) return { target: "timeframe", text: key };
46688
47836
  return null;
46689
47837
  }
46690
47838
  var VelaWorkspace = class {
@@ -46793,6 +47941,20 @@ var VelaWorkspace = class {
46793
47941
  const hostEl = typeof container === "string" ? document.querySelector(container) : container;
46794
47942
  if (!hostEl) throw new Error(`VelaWorkspace: container not found: ${String(container)}`);
46795
47943
  this.opts = opts;
47944
+ this.replay = new WorkspaceReplay({
47945
+ cells: () => this.cells(),
47946
+ activeId: () => this.activeId,
47947
+ onCells: (handler) => {
47948
+ const offs = [
47949
+ this.events.on("cell:created", ({ id }) => handler({ kind: "created", id })),
47950
+ this.events.on("cell:destroyed", ({ id }) => handler({ kind: "destroyed", id })),
47951
+ this.events.on("cell:active", ({ id }) => handler({ kind: "active", id }))
47952
+ ];
47953
+ return () => {
47954
+ for (const off of offs) off();
47955
+ };
47956
+ }
47957
+ });
46796
47958
  this.persistKey = opts.persist === void 0 || opts.persist === false ? null : opts.persist === true ? "vela-workspace" : opts.persist;
46797
47959
  this.storage = opts.storage ?? localStorageAdapter();
46798
47960
  let boot = null;
@@ -46930,6 +48092,9 @@ var VelaWorkspace = class {
46930
48092
  this.dock.refresh();
46931
48093
  if (boot?.panels) this.dock.applyState(boot.panels);
46932
48094
  this.root.appendChild(main);
48095
+ this.stripsEl = doc.createElement("div");
48096
+ this.stripsEl.className = "vela-ws-strips";
48097
+ this.root.appendChild(this.stripsEl);
46933
48098
  this.toastHost = new Toast(this.gridEl);
46934
48099
  const attribution = rendererDefaults().attribution;
46935
48100
  if (attribution !== false) {
@@ -47164,9 +48329,16 @@ var VelaWorkspace = class {
47164
48329
  setActiveCell: (id) => this.setActiveCell(id),
47165
48330
  openSymbolSearch: (query) => this.symbolPicker.open(query ?? ""),
47166
48331
  togglePanel: (id, open2) => this.dock.toggle(id, open2),
48332
+ dockStrip: (el) => {
48333
+ this.stripsEl.appendChild(el);
48334
+ return () => {
48335
+ if (el.parentElement === this.stripsEl) el.remove();
48336
+ };
48337
+ },
47167
48338
  root: this.root,
47168
48339
  toast: (message, kind) => this.toastHost.show(message, kind),
47169
- stateDirty: () => this.markStateDirty()
48340
+ stateDirty: () => this.markStateDirty(),
48341
+ replay: this.replay
47170
48342
  });
47171
48343
  }
47172
48344
  /** Re-project contributed topbar actions + side panels, and mount late-registered attachments. */
@@ -47474,6 +48646,7 @@ var VelaWorkspace = class {
47474
48646
  if (this.destroyed) return;
47475
48647
  this.flushPendingState();
47476
48648
  this.destroyed = true;
48649
+ this.replay.destroy();
47477
48650
  if (this.persistKey !== null && typeof window !== "undefined") window.removeEventListener("beforeunload", this.onUnload);
47478
48651
  this.resizeObserver?.disconnect();
47479
48652
  this.splitters.destroy();
@@ -48240,14 +49413,13 @@ var VelaWorkspace = class {
48240
49413
  panels: () => has("panels") ? [...this.dock.list()] : [],
48241
49414
  onTogglePanel: (id) => this.dock.toggle(id),
48242
49415
  ...has("alerts") ? { alerts: () => this.alerts.map((a) => ({ title: `${a.source} \xB7 ${a.title}`, message: a.message, time: a.time })) } : {},
48243
- // Left-aligned actions have their own bottom-bar stop — only the rest
48244
- // lands in the drawer, or every left action would appear twice. Built-in-id
48245
- // actions are slot OVERRIDES: they reach the drawer through the slot's own
48246
- // routed button (screenshot) or stop (indicators), never as an extra row.
48247
- actions: () => {
48248
- const builtin = new Set(TOPBAR_BUILTIN_IDS);
48249
- return widgetActions("topbar", this.context()).filter((a) => a.align !== "left" && !builtin.has(a.id)).map((a) => ({ label: a.label, icon: a.icon, run: () => a.run(this.context()) }));
48250
- },
49416
+ // Actions placed on the bottom bar (left-aligned by default) have their stop
49417
+ // there — only the menu-placed ones land in the drawer, or they would appear
49418
+ // twice: left (primary) ones with the primary rows, right ones at the end.
49419
+ // Built-in-id actions are slot OVERRIDES: they reach the drawer through the
49420
+ // slot's own routed button (screenshot) or stop (indicators), never as a row.
49421
+ primaryActions: () => this.drawerActions("left"),
49422
+ actions: () => this.drawerActions("right"),
48251
49423
  // The desktop layout dropdown's whole surface — the grid canvas, the
48252
49424
  // non-canvas presets and the sync switches — relocated into the kebab
48253
49425
  // drawer (the topbar is hidden on mobile). Same reads as the topbar block;
@@ -48272,6 +49444,11 @@ var VelaWorkspace = class {
48272
49444
  }));
48273
49445
  this.moreDrawer.open();
48274
49446
  }
49447
+ /** The contributed topbar actions the mobile menu lists, from one cluster. */
49448
+ drawerActions(cluster) {
49449
+ const builtin = new Set(TOPBAR_BUILTIN_IDS);
49450
+ 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()) }));
49451
+ }
48275
49452
  openTimezoneDrawer() {
48276
49453
  this.timezoneDrawer ?? (this.timezoneDrawer = new TimezoneDrawer({
48277
49454
  host: this.root,
@@ -48393,7 +49570,7 @@ var VelaWorkspace = class {
48393
49570
  }
48394
49571
  });
48395
49572
  }
48396
- /** Bare-typing router: letters → symbol search (seeded), digits → timeframe entry. */
49573
+ /** Bare-typing router: letters and `0` → symbol search (seeded), digits 1–9 → timeframe entry. */
48397
49574
  routeTyping(ev) {
48398
49575
  if (this.destroyed) return;
48399
49576
  if (ev.ctrlKey || ev.metaKey || ev.altKey) return;