@luxalgo/vela 0.6.20 → 0.6.22

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 (41) hide show
  1. package/dist/{DataProvider-Cb-2lC5O.d.ts → DataProvider-Bu9E5Zh5.d.ts} +1 -1
  2. package/dist/{DataProvider-UtWw2CgJ.d.cts → DataProvider-Ck2qyS_9.d.cts} +1 -1
  3. package/dist/{chunk-M6T5A733.js → chunk-A347YL2P.js} +31 -8
  4. package/dist/{chunk-4Q4B5AO3.js → chunk-A4G64KVF.js} +1453 -131
  5. package/dist/{chunk-T5Z5YUCF.js → chunk-CZRNTHD6.js} +24 -2
  6. package/dist/{chunk-XCBJU674.js → chunk-UHXG5J7C.js} +1 -1
  7. package/dist/{contributions-D8HdlKmp.d.cts → contributions-BjQ7hyIx.d.ts} +108 -3
  8. package/dist/{contributions-FqIWhN4p.d.ts → contributions-Cv2Mutvn.d.cts} +108 -3
  9. package/dist/index.cjs +1476 -130
  10. package/dist/index.d.cts +76 -20
  11. package/dist/index.d.ts +76 -20
  12. package/dist/index.js +2 -2
  13. package/dist/{options-BCRmYALw.d.ts → options-ex2_gtKp.d.cts} +200 -12
  14. package/dist/{options-BCRmYALw.d.cts → options-ex2_gtKp.d.ts} +200 -12
  15. package/dist/{plugin-B4mnNAeh.d.ts → plugin-CiyhU7pi.d.ts} +3 -3
  16. package/dist/{plugin-Ccupy_BV.d.cts → plugin-absNH7Yn.d.cts} +3 -3
  17. package/dist/plugin.d.cts +4 -4
  18. package/dist/plugin.d.ts +4 -4
  19. package/dist/providers/binance.d.cts +2 -2
  20. package/dist/providers/binance.d.ts +2 -2
  21. package/dist/providers/coinbase.d.cts +2 -2
  22. package/dist/providers/coinbase.d.ts +2 -2
  23. package/dist/providers/hyperliquid.d.cts +2 -2
  24. package/dist/providers/hyperliquid.d.ts +2 -2
  25. package/dist/{statusline-model-CCP1zm_Z.d.ts → statusline-model-BXoU4Kua.d.ts} +8 -4
  26. package/dist/{statusline-model-E7CMw5pQ.d.cts → statusline-model-cRhPBLPO.d.cts} +8 -4
  27. package/dist/ui.cjs +24 -2
  28. package/dist/ui.d.cts +9 -3
  29. package/dist/ui.d.ts +9 -3
  30. package/dist/ui.js +2 -2
  31. package/dist/vela.global.js +1476 -130
  32. package/dist/vela.global.min.js +77 -53
  33. package/dist/widget.cjs +1502 -135
  34. package/dist/widget.d.cts +6 -6
  35. package/dist/widget.d.ts +6 -6
  36. package/dist/widget.js +5 -5
  37. package/dist/workspace.cjs +1502 -135
  38. package/dist/workspace.d.cts +8 -5
  39. package/dist/workspace.d.ts +8 -5
  40. package/dist/workspace.js +4 -4
  41. package/package.json +2 -1
package/dist/widget.cjs CHANGED
@@ -1908,7 +1908,7 @@ function intersectRects(a, b) {
1908
1908
  return { left, top, right, bottom, width: right - left, height: bottom - top };
1909
1909
  }
1910
1910
  function placePopover(a) {
1911
- let left = a.align === "end" ? a.trigger.right - a.pop.width : a.trigger.left;
1911
+ let left = a.align === "end" ? a.trigger.right - a.pop.width : a.align === "center" ? a.trigger.left + a.trigger.width / 2 - a.pop.width / 2 : a.trigger.left;
1912
1912
  const below = a.trigger.bottom + a.gap;
1913
1913
  const above = a.trigger.top - a.pop.height - a.gap;
1914
1914
  const fitsBelow = below + a.pop.height <= a.clamp.bottom;
@@ -1980,6 +1980,8 @@ var Popover = class {
1980
1980
  this.onKey = null;
1981
1981
  this.onReflow = null;
1982
1982
  this.shown = false;
1983
+ /** The pending removal of a fading-out shell; a show() that reuses the shell cancels it. */
1984
+ this.leaveTimer = null;
1983
1985
  const doc = opts.trigger.ownerDocument;
1984
1986
  injectStyles(POPOVER_STYLE_ID, POPOVER_CSS, doc);
1985
1987
  this.trigger = opts.trigger;
@@ -1987,6 +1989,7 @@ var Popover = class {
1987
1989
  this.ctrl = popoverController(opts);
1988
1990
  this.boundary = opts.boundary ?? "viewport";
1989
1991
  this.theme = opts.theme;
1992
+ this.fadeMs = Math.max(0, opts.fadeMs ?? 0);
1990
1993
  this.el = doc.createElement("div");
1991
1994
  this.el.className = "vela-popover vela-ui-layer" + (opts.className ? ` ${opts.className}` : "");
1992
1995
  this.el.dataset.position = this.ctrl.position;
@@ -2010,11 +2013,21 @@ var Popover = class {
2010
2013
  return;
2011
2014
  }
2012
2015
  if (open && open !== this) open.hide();
2016
+ if (this.leaveTimer !== null) {
2017
+ clearTimeout(this.leaveTimer);
2018
+ this.leaveTimer = null;
2019
+ }
2013
2020
  ensureUIHost(this.el, this.theme);
2021
+ if (this.fadeMs > 0) {
2022
+ this.el.style.transition = `opacity ${this.fadeMs}ms ease`;
2023
+ this.el.style.opacity = "0";
2024
+ this.el.style.pointerEvents = "";
2025
+ }
2014
2026
  this.host.appendChild(this.el);
2015
2027
  this.shown = true;
2016
2028
  open = this;
2017
2029
  this.place();
2030
+ if (this.fadeMs > 0) this.el.style.opacity = "1";
2018
2031
  const onOutside = (ev) => {
2019
2032
  const t = ev.target;
2020
2033
  if (this.el.contains(t) || this.trigger.contains(t)) return;
@@ -2047,7 +2060,16 @@ var Popover = class {
2047
2060
  this.onOutside = null;
2048
2061
  this.onKey = null;
2049
2062
  this.onReflow = null;
2050
- this.el.remove();
2063
+ if (this.fadeMs > 0) {
2064
+ this.el.style.opacity = "0";
2065
+ this.el.style.pointerEvents = "none";
2066
+ this.leaveTimer = setTimeout(() => {
2067
+ this.leaveTimer = null;
2068
+ this.el.remove();
2069
+ }, this.fadeMs);
2070
+ } else {
2071
+ this.el.remove();
2072
+ }
2051
2073
  this.shown = false;
2052
2074
  if (open === this) open = null;
2053
2075
  this.ctrl.onClose?.();
@@ -5200,9 +5222,12 @@ function mergeConfig(base, patch) {
5200
5222
  const gh = asObject(grid.horzLines);
5201
5223
  const cross = asObject(p.crosshair);
5202
5224
  const ps = asObject(p.priceScale);
5225
+ const anim = asObject(p.animations);
5203
5226
  const panes = asObject(p.panes);
5204
5227
  const trades = asObject(p.trades);
5205
5228
  const ts = asObject(p.timeScale);
5229
+ const marks = asObject(p.marks);
5230
+ const markGroups = asObject(marks.groups);
5206
5231
  const candles = asObject(p.candles);
5207
5232
  const bars = asObject(p.bars);
5208
5233
  const line = asObject(p.line);
@@ -5249,6 +5274,12 @@ function mergeConfig(base, patch) {
5249
5274
  countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown,
5250
5275
  animateLastPrice: isBool(ps.animateLastPrice) ? ps.animateLastPrice : base.priceScale.animateLastPrice
5251
5276
  },
5277
+ animations: {
5278
+ zoom: isBool(anim.zoom) ? anim.zoom : base.animations.zoom,
5279
+ pan: isBool(anim.pan) ? anim.pan : base.animations.pan,
5280
+ autoscale: isBool(anim.autoscale) ? anim.autoscale : base.animations.autoscale,
5281
+ intro: isBool(anim.intro) ? anim.intro : base.animations.intro
5282
+ },
5252
5283
  panes: {
5253
5284
  separatorColor: isColor(panes.separatorColor) ? panes.separatorColor : base.panes.separatorColor
5254
5285
  },
@@ -5263,6 +5294,15 @@ function mergeConfig(base, patch) {
5263
5294
  timeScale: {
5264
5295
  timezone: typeof ts.timezone === "string" && ts.timezone ? ts.timezone : base.timeScale.timezone
5265
5296
  },
5297
+ marks: {
5298
+ visible: isBool(marks.visible) ? marks.visible : base.marks.visible,
5299
+ // Additive like `stacking.series`: a patch names only the groups it carries, so a
5300
+ // choice stored for a group the host has not registered yet survives verbatim.
5301
+ groups: {
5302
+ ...base.marks.groups,
5303
+ ...Object.fromEntries(Object.entries(markGroups).filter(([, v]) => isBool(v)))
5304
+ }
5305
+ },
5266
5306
  candles: {
5267
5307
  upColor: isColor(candles.upColor) ? candles.upColor : base.candles.upColor,
5268
5308
  downColor: isColor(candles.downColor) ? candles.downColor : base.candles.downColor,
@@ -5975,6 +6015,40 @@ var Topbar = class {
5975
6015
  }
5976
6016
  };
5977
6017
 
6018
+ // src/core/util/wall-clock.ts
6019
+ var BOUNDARY_SLACK_MS = 5;
6020
+ var SecondClock = class _SecondClock {
6021
+ constructor(now = () => Date.now()) {
6022
+ this.now = now;
6023
+ this.subs = /* @__PURE__ */ new Set();
6024
+ this.timer = null;
6025
+ }
6026
+ onTick(cb) {
6027
+ this.subs.add(cb);
6028
+ if (this.timer == null) this.arm();
6029
+ return () => {
6030
+ this.subs.delete(cb);
6031
+ if (this.subs.size === 0 && this.timer != null) {
6032
+ clearTimeout(this.timer);
6033
+ this.timer = null;
6034
+ }
6035
+ };
6036
+ }
6037
+ /** Milliseconds from `now` to just past the next second boundary. */
6038
+ static delayToNextSecond(now) {
6039
+ const intoSecond = (now % 1e3 + 1e3) % 1e3;
6040
+ return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
6041
+ }
6042
+ arm() {
6043
+ this.timer = setTimeout(() => {
6044
+ this.timer = null;
6045
+ const now = this.now();
6046
+ for (const cb of this.subs) cb(now);
6047
+ if (this.subs.size > 0) this.arm();
6048
+ }, _SecondClock.delayToNextSecond(this.now()));
6049
+ }
6050
+ };
6051
+
5978
6052
  // src/core/timezones.ts
5979
6053
  var TIMEZONES = [
5980
6054
  { value: "Etc/UTC", label: "UTC" },
@@ -6154,7 +6228,6 @@ var Bottombar = class {
6154
6228
  this.rangeButtons = /* @__PURE__ */ new Map();
6155
6229
  this.sessionButtons = /* @__PURE__ */ new Map();
6156
6230
  this.sessionEl = null;
6157
- this.timer = null;
6158
6231
  this.timezone = opts.timezone;
6159
6232
  const doc = host.ownerDocument;
6160
6233
  injectStyles(STYLE_ID3, CSS4, doc);
@@ -6219,7 +6292,7 @@ var Bottombar = class {
6219
6292
  }
6220
6293
  });
6221
6294
  this.tick();
6222
- this.timer = setInterval(() => this.tick(), 1e3);
6295
+ this.unsubClock = (opts.clock ?? new SecondClock()).onTick(() => this.tick());
6223
6296
  }
6224
6297
  setTimezone(zone) {
6225
6298
  this.timezone = zone;
@@ -6248,7 +6321,7 @@ var Bottombar = class {
6248
6321
  }
6249
6322
  }
6250
6323
  destroy() {
6251
- if (this.timer !== null) clearInterval(this.timer);
6324
+ this.unsubClock();
6252
6325
  this.tzMenu.destroy();
6253
6326
  this.settingsTip?.destroy();
6254
6327
  this.el.remove();
@@ -14975,6 +15048,11 @@ function followStep(cur, target, follow = FOLLOW, epsFrac = 15e-4) {
14975
15048
  const done = Math.abs(target.from - nf) <= eps && Math.abs(target.to - nt) <= eps;
14976
15049
  return { cur: done ? { ...target } : { from: nf, to: nt }, done };
14977
15050
  }
15051
+ function zoomAnimated(chart) {
15052
+ const control = chart.renderer;
15053
+ if (!control || !control.supports("animZoom")) return true;
15054
+ return Boolean(control.get("animZoom"));
15055
+ }
14978
15056
  var Glider = class {
14979
15057
  constructor(chart) {
14980
15058
  this.chart = chart;
@@ -15002,8 +15080,14 @@ var Glider = class {
15002
15080
  const chart = this.chart();
15003
15081
  const base = this.target ?? chart?.getVisibleRange();
15004
15082
  if (!chart || !base) return;
15083
+ const target = make(base);
15084
+ if (!zoomAnimated(chart)) {
15085
+ this.stop();
15086
+ chart.setVisibleRange(target);
15087
+ return;
15088
+ }
15005
15089
  this.cur ?? (this.cur = { ...base });
15006
- this.target = make(base);
15090
+ this.target = target;
15007
15091
  if (!this.raf) this.tick();
15008
15092
  }
15009
15093
  tick() {
@@ -15123,6 +15207,7 @@ var CSS13 = `
15123
15207
  font-weight: 600;
15124
15208
  -webkit-tap-highlight-color: transparent;
15125
15209
  }
15210
+ .vela-mb-item[hidden] { display: none !important; }
15126
15211
  .vela-mb-item:active { background: var(--vela-hover); }
15127
15212
  .vela-mb-item .vela-icon { font-size: 18px; width: 18px; height: 18px; }
15128
15213
  /* A lit stop (the maximize toggle while something is isolated): the inverse
@@ -15212,6 +15297,12 @@ var MobileBar = class {
15212
15297
  this.maxEl.setAttribute("aria-label", on ? "Restore layout" : "Maximize chart");
15213
15298
  this.maxEl.replaceChildren(iconEl(on ? "restore" : "maximize", this.el.ownerDocument));
15214
15299
  }
15300
+ /** Show the maximize stop only while the grid holds more than one chart — on a
15301
+ * one-cell layout there is nothing to isolate, so the stop would be a dead press. */
15302
+ setMaximizeVisible(visible) {
15303
+ if (!this.maxEl) return;
15304
+ this.maxEl.hidden = !visible;
15305
+ }
15215
15306
  destroy() {
15216
15307
  this.el.remove();
15217
15308
  }
@@ -18272,19 +18363,48 @@ function localStorageAdapter(storageKey) {
18272
18363
 
18273
18364
  // src/core/options.ts
18274
18365
  var normalizeSession = (v) => v === "regular" || v === "extended" ? v : void 0;
18366
+ var ZOOM_EASE_DEFAULT_MS = 70;
18367
+ var PAN_INERTIA_DEFAULT_MS = 110;
18368
+ var SCROLL_EASE_DEFAULT_MS = 130;
18369
+ var AUTOSCALE_EASE_DEFAULT_MS = 80;
18275
18370
  var LIVE_BAR_EASE_DEFAULT_MS = 90;
18276
- var LIVE_BAR_EASE_MAX_MS = 1e3;
18277
- function resolveLiveBarEaseMs(value) {
18278
- if (value === true) return LIVE_BAR_EASE_DEFAULT_MS;
18371
+ var INTRO_DURATION_DEFAULT_MS = 650;
18372
+ var ANIMATION_EASE_MAX_MS = 1e3;
18373
+ var LIVE_BAR_EASE_MAX_MS = ANIMATION_EASE_MAX_MS;
18374
+ var INTRO_DURATION_MAX_MS = 5e3;
18375
+ function resolveEaseMs(value, defaultMs, maxMs = ANIMATION_EASE_MAX_MS) {
18376
+ if (value === true) return defaultMs;
18279
18377
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
18280
- return Math.min(value, LIVE_BAR_EASE_MAX_MS);
18378
+ return Math.min(value, maxMs);
18379
+ }
18380
+ function resolveLiveBarEaseMs(value) {
18381
+ return resolveEaseMs(value, LIVE_BAR_EASE_DEFAULT_MS, LIVE_BAR_EASE_MAX_MS);
18382
+ }
18383
+ function resolveIntro(value) {
18384
+ const off = { style: false, duration: 0 };
18385
+ if (value === true) return { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
18386
+ if (value === "settle" || value === "grow") return { style: value, duration: INTRO_DURATION_DEFAULT_MS };
18387
+ if (value && typeof value === "object") {
18388
+ const o = value;
18389
+ const d = o.duration;
18390
+ return {
18391
+ style: o.style === "grow" ? "grow" : "settle",
18392
+ duration: typeof d === "number" && Number.isFinite(d) && d > 0 ? Math.min(d, INTRO_DURATION_MAX_MS) : INTRO_DURATION_DEFAULT_MS
18393
+ };
18394
+ }
18395
+ return off;
18281
18396
  }
18282
18397
  function resolveAnimations(animations) {
18283
- if (typeof animations === "boolean") return { animZoom: animations, animPan: animations, animLiveBar: 0 };
18398
+ if (animations === false) return { animZoom: 0, animPan: 0, animScroll: 0, animAutoscale: 0, animLiveBar: 0, animIntro: { style: false, duration: 0 } };
18399
+ const cfg = animations === true || animations == null ? {} : animations;
18400
+ const animPan = resolveEaseMs(cfg.pan ?? true, PAN_INERTIA_DEFAULT_MS);
18284
18401
  return {
18285
- animZoom: animations?.zoom ?? true,
18286
- animPan: animations?.pan ?? true,
18287
- animLiveBar: resolveLiveBarEaseMs(animations?.liveBar)
18402
+ animZoom: resolveEaseMs(cfg.zoom ?? true, ZOOM_EASE_DEFAULT_MS),
18403
+ animPan,
18404
+ animScroll: cfg.scroll === void 0 ? animPan > 0 ? SCROLL_EASE_DEFAULT_MS : 0 : resolveEaseMs(cfg.scroll, SCROLL_EASE_DEFAULT_MS),
18405
+ animAutoscale: resolveEaseMs(cfg.autoscale ?? true, AUTOSCALE_EASE_DEFAULT_MS),
18406
+ animLiveBar: resolveLiveBarEaseMs(cfg.liveBar),
18407
+ animIntro: resolveIntro(cfg.intro ?? true)
18288
18408
  };
18289
18409
  }
18290
18410
 
@@ -18499,6 +18619,93 @@ function presetToRange(preset, bars) {
18499
18619
  return { from: Math.max(first, from), to: last };
18500
18620
  }
18501
18621
 
18622
+ // src/core/marks/MarksController.ts
18623
+ var MarksController = class {
18624
+ constructor(renderer, events) {
18625
+ this.renderer = renderer;
18626
+ /** Insertion-ordered — the order a cluster falls back to for equal times. */
18627
+ this.marks = /* @__PURE__ */ new Map();
18628
+ this.groups = /* @__PURE__ */ new Map();
18629
+ this.subs = [];
18630
+ this.enabled = !!renderer.capabilities.timelineMarks && typeof renderer.setTimelineMarks === "function";
18631
+ if (this.enabled && renderer.onMarkClick) this.subs.push(renderer.onMarkClick((e) => events.emit("mark:click", e)));
18632
+ }
18633
+ /** Whether the active renderer paints timeline marks. */
18634
+ get supported() {
18635
+ return this.enabled;
18636
+ }
18637
+ /** Add (or replace, by id) one mark. */
18638
+ add(mark) {
18639
+ this.marks.set(mark.id, validateMark(mark));
18640
+ this.sync();
18641
+ }
18642
+ /** Replace the whole set — a market switch. */
18643
+ set(marks) {
18644
+ this.marks.clear();
18645
+ for (const m of marks) this.marks.set(m.id, validateMark(m));
18646
+ this.sync();
18647
+ }
18648
+ remove(id) {
18649
+ const had = this.marks.delete(id);
18650
+ if (had) this.sync();
18651
+ return had;
18652
+ }
18653
+ clear() {
18654
+ if (this.marks.size === 0) return;
18655
+ this.marks.clear();
18656
+ this.sync();
18657
+ }
18658
+ /** Every mark, in insertion order (shallow copies — mutating one changes nothing). */
18659
+ all() {
18660
+ return [...this.marks.values()].map((m) => ({ ...m, glyph: { ...m.glyph } }));
18661
+ }
18662
+ /** Define (or replace) a group's presentation — its settings label and default visibility. */
18663
+ defineGroup(group) {
18664
+ if (!group || typeof group.id !== "string" || group.id.length === 0) throw new Error("[vela] marks.defineGroup: `id` must be a non-empty string");
18665
+ if (typeof group.label !== "string") throw new Error(`[vela] marks.defineGroup: group "${group.id}" needs a string \`label\``);
18666
+ this.groups.set(group.id, { ...group });
18667
+ this.sync();
18668
+ }
18669
+ /** The defined groups, in definition order. */
18670
+ groupDefinitions() {
18671
+ return [...this.groups.values()].map((g) => ({ ...g }));
18672
+ }
18673
+ /**
18674
+ * Show or hide one group's marks. The choice lives in the renderer's cosmetic config
18675
+ * (the `marks` feature) — what the settings dialog's Events checkboxes edit and what
18676
+ * a persisted chart restores — so it warns + no-ops on a renderer without it.
18677
+ */
18678
+ setGroupVisible(id, visible) {
18679
+ if (!this.renderer.features.includes("marks")) {
18680
+ console.warn(`[vela] renderer "${this.renderer.name}" does not paint timeline marks \u2014 setGroupVisible ignored.`);
18681
+ return;
18682
+ }
18683
+ this.renderer.applyFeature("marks", { groups: { [id]: visible } });
18684
+ }
18685
+ /** A group's effective visibility: the user's (persisted) choice, else the group's declared default, else visible. */
18686
+ isGroupVisible(id) {
18687
+ const state = this.renderer.readFeature("marks");
18688
+ const chosen = state?.groups?.[id];
18689
+ if (typeof chosen === "boolean") return chosen;
18690
+ return this.groups.get(id)?.visible !== false;
18691
+ }
18692
+ destroy() {
18693
+ for (const unsub of this.subs) unsub();
18694
+ this.subs.length = 0;
18695
+ }
18696
+ sync() {
18697
+ if (!this.enabled) return;
18698
+ this.renderer.setTimelineMarks([...this.marks.values()], [...this.groups.values()]);
18699
+ }
18700
+ };
18701
+ function validateMark(mark) {
18702
+ if (!mark || typeof mark !== "object") throw new Error("[vela] marks: a mark must be an object");
18703
+ if (typeof mark.id !== "string" || mark.id.length === 0) throw new Error("[vela] marks: `id` must be a non-empty string");
18704
+ if (typeof mark.time !== "number" || !Number.isFinite(mark.time)) throw new Error(`[vela] marks: mark "${mark.id}" needs a finite epoch-ms \`time\``);
18705
+ if (!mark.glyph || typeof mark.glyph !== "object" || typeof mark.glyph.color !== "string") throw new Error(`[vela] marks: mark "${mark.id}" needs a glyph with a \`color\``);
18706
+ return { ...mark, glyph: { ...mark.glyph } };
18707
+ }
18708
+
18502
18709
  // src/core/engine/DrawingSeriesService.ts
18503
18710
  var MAX_BARS = 5e3;
18504
18711
  var PAD_FRAC = 0.25;
@@ -18816,6 +19023,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18816
19023
  marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
18817
19024
  });
18818
19025
  this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
19026
+ this.marks = new MarksController(this.renderer, this.events);
18819
19027
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
18820
19028
  this.endLoad();
18821
19029
  this.events.emit("data:unresolved", info);
@@ -19188,6 +19396,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19188
19396
  if (!record.native) continue;
19189
19397
  record.native.instance.stop();
19190
19398
  record.native.instance = record.native.descriptor.create();
19399
+ record.native.started = false;
19191
19400
  record.pendingStructural = true;
19192
19401
  if (record.hidden) {
19193
19402
  record.native.stale = true;
@@ -19679,11 +19888,12 @@ var EngineOrchestrator = class _EngineOrchestrator {
19679
19888
  record.session = void 0;
19680
19889
  record.native?.instance.suspend();
19681
19890
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, false);
19891
+ else if (record.native) this.mountHiddenNativeRow(id, record);
19682
19892
  } else {
19683
19893
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, true);
19684
19894
  record.pendingStructural = true;
19685
19895
  if (record.native) {
19686
- if (record.native.stale) {
19896
+ if (record.native.stale || !record.native.started) {
19687
19897
  record.native.stale = false;
19688
19898
  const handle = this.handles.get(id);
19689
19899
  if (handle) void this.startNativeIndicator(id, handle);
@@ -19766,6 +19976,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19766
19976
  this.unresolvedUnsub = null;
19767
19977
  this.feed.destroy?.();
19768
19978
  this.drawings.destroy();
19979
+ this.marks.destroy();
19769
19980
  this.renderer.destroy();
19770
19981
  this.events.clear();
19771
19982
  }
@@ -19864,6 +20075,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19864
20075
  }
19865
20076
  };
19866
20077
  record.native.instance.start(ctx, record.inputValues);
20078
+ record.native.started = true;
19867
20079
  } catch (err) {
19868
20080
  this.fail(id, handle, err);
19869
20081
  }
@@ -19878,6 +20090,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19878
20090
  overlay: d.overlay,
19879
20091
  paneHint: d.paneHint,
19880
20092
  native: { type: record.native.type },
20093
+ ...d.legend === false ? { legend: false } : {},
19881
20094
  ...out.paneAxis != null ? { paneAxis: out.paneAxis } : {},
19882
20095
  series: out.series ?? [],
19883
20096
  fills: out.fills ?? [],
@@ -19900,9 +20113,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
19900
20113
  * over it in place (`pendingStructural`), clears the spinner, and only THEN fires
19901
20114
  * `indicator:added`/`ready` — so event semantics and `inspect()` (which skips
19902
20115
  * loading records) still mean "the indicator produced output".
20116
+ *
20117
+ * A HIDDEN record mounts too — dimmed, no spinner (its session never starts while
20118
+ * hidden, so nothing is computing and no model will ever arrive to mount the row
20119
+ * later). Without this an indicator ADDED hidden (a restored ledger/ext entry) had
20120
+ * no legend row at all: invisible AND unreachable — the eye that unhides it never
20121
+ * existed. The hidden mount announces immediately for the same reason: the "first
20122
+ * computed model" that normally announces cannot come until the indicator is shown,
20123
+ * and host UIs (object tree, landing watchers) must know it exists NOW.
19903
20124
  */
19904
20125
  mountLoadingPlaceholder(id, record) {
19905
- if (record.renderHandle || record.hidden || !record.prepared) return;
20126
+ if (record.renderHandle || !record.prepared) return;
19906
20127
  const meta = record.prepared.meta;
19907
20128
  const model = {
19908
20129
  id,
@@ -19926,8 +20147,34 @@ var EngineOrchestrator = class _EngineOrchestrator {
19926
20147
  this.ensurePaneFor(paneId);
19927
20148
  record.renderHandle = this.renderer.mountIndicator(model);
19928
20149
  record.pendingStructural = true;
20150
+ if (record.hidden) {
20151
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
20152
+ this.announce(record, this.handles.get(id));
20153
+ return;
20154
+ }
19929
20155
  this.setLoading(record, true);
19930
20156
  }
20157
+ /**
20158
+ * Mount the legend row for a NATIVE indicator that is being hidden BEFORE it ever
20159
+ * started (a restored-hidden ledger entry: `startNativeIndicator` bails on hidden
20160
+ * records, so no model — and therefore no row — would ever mount). The native
20161
+ * counterpart of {@link mountLoadingPlaceholder}'s hidden branch: an empty model
20162
+ * carries the title + inputs schema, the renderer marks the row hidden, and the
20163
+ * announce makes the indicator visible to host UIs. Showing later STARTS the
20164
+ * instance (the `started` flag path) and its first emit remounts over this row.
20165
+ */
20166
+ mountHiddenNativeRow(id, record) {
20167
+ if (record.renderHandle || !record.native) return;
20168
+ const model = this.buildNativeModel(record, {});
20169
+ const paneId = this.routePane(id, model, record.options ?? {});
20170
+ this.placeModel(model, id, paneId);
20171
+ record.model = model;
20172
+ this.ensurePaneFor(paneId);
20173
+ record.renderHandle = this.renderer.mountIndicator(model);
20174
+ record.pendingStructural = true;
20175
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
20176
+ this.announce(record, this.handles.get(id));
20177
+ }
19931
20178
  /** Flip the record's loading state and reflect it in the legend row (spinner on/off). */
19932
20179
  setLoading(record, loading) {
19933
20180
  record.loading = loading;
@@ -19962,6 +20209,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19962
20209
  for (const r of this.registry.all()) {
19963
20210
  const model = r.model;
19964
20211
  if (!model) continue;
20212
+ if (model.legend === false) continue;
19965
20213
  const paneId = model.paneId ?? "price";
19966
20214
  if (!byPane.has(paneId)) byPane.set(paneId, []);
19967
20215
  byPane.get(paneId).push({
@@ -20537,6 +20785,16 @@ var RendererControl = class {
20537
20785
  this.renderer.setLayoutMode?.(mode);
20538
20786
  return this;
20539
20787
  }
20788
+ /**
20789
+ * Drive the renderer's time-of-day chrome (the countdown-to-bar-close chip) from the
20790
+ * host's own second pulse, so it ticks in step with a host clock display instead of
20791
+ * on a separate timer that can read a different second. `null` hands the pulse back
20792
+ * to the renderer. Silent no-op on a renderer without time-of-day chrome.
20793
+ */
20794
+ setWallClock(clock) {
20795
+ this.renderer.setWallClock?.(clock);
20796
+ return this;
20797
+ }
20540
20798
  };
20541
20799
 
20542
20800
  // src/core/PanesControl.ts
@@ -20876,6 +21134,61 @@ var DrawingsControl = class {
20876
21134
  }
20877
21135
  };
20878
21136
 
21137
+ // src/core/MarksControl.ts
21138
+ var MarksControl = class {
21139
+ constructor(ctrl) {
21140
+ this.ctrl = ctrl;
21141
+ }
21142
+ /** Whether the active renderer paints timeline marks. */
21143
+ get supported() {
21144
+ return this.ctrl.supported;
21145
+ }
21146
+ /** Add one mark; an existing id is replaced in place. */
21147
+ add(mark) {
21148
+ this.ctrl.add(mark);
21149
+ return this;
21150
+ }
21151
+ /** Replace the whole set (a market switch). */
21152
+ set(marks) {
21153
+ this.ctrl.set(marks);
21154
+ return this;
21155
+ }
21156
+ remove(id) {
21157
+ this.ctrl.remove(id);
21158
+ return this;
21159
+ }
21160
+ clear() {
21161
+ this.ctrl.clear();
21162
+ return this;
21163
+ }
21164
+ /** Every mark, in insertion order. */
21165
+ all() {
21166
+ return this.ctrl.all();
21167
+ }
21168
+ /**
21169
+ * Define a visibility group's presentation: the label of its checkbox in chart
21170
+ * settings (the Events tab) and its default visibility. Marks may name a group
21171
+ * that was never defined — it then shows its capitalized id.
21172
+ */
21173
+ defineGroup(group) {
21174
+ this.ctrl.defineGroup(group);
21175
+ return this;
21176
+ }
21177
+ /** The defined groups, in definition order. */
21178
+ groups() {
21179
+ return this.ctrl.groupDefinitions();
21180
+ }
21181
+ /** Show or hide one group's marks — the same switch as the settings checkbox, persisted with the chart's config. */
21182
+ setGroupVisible(id, visible = true) {
21183
+ this.ctrl.setGroupVisible(id, visible);
21184
+ return this;
21185
+ }
21186
+ /** A group's effective visibility (the user's choice, else the group's declared default). */
21187
+ isGroupVisible(id) {
21188
+ return this.ctrl.isGroupVisible(id);
21189
+ }
21190
+ };
21191
+
20879
21192
  // src/core/model/inputs.ts
20880
21193
  function inputVisible(when, values) {
20881
21194
  if (!when) return true;
@@ -22924,6 +23237,8 @@ var NATIVE_CAPABILITIES = {
22924
23237
  // canvas-painted into the owning indicator's interleave slice
22925
23238
  trades: true,
22926
23239
  // strategy order-fill markers (arrows + labels + fill-price ticks)
23240
+ timelineMarks: true,
23241
+ // host events on a lane above the time axis (glyphs + detail popup)
22927
23242
  inputsUI: true
22928
23243
  // reuses the DOM InputsUI
22929
23244
  };
@@ -24491,6 +24806,31 @@ var Animator = class {
24491
24806
  }
24492
24807
  }
24493
24808
  };
24809
+ var EaseSetting = class {
24810
+ /** `defaultMs` is what the on/off switch restores when nothing else was configured;
24811
+ * `initialMs` (default: `defaultMs`) is the starting value — 0 for a motion that
24812
+ * ships off. */
24813
+ constructor(defaultMs, initialMs = defaultMs) {
24814
+ this.onMs = defaultMs;
24815
+ this.ms = initialMs;
24816
+ }
24817
+ /** The active time-constant; 0 when off. */
24818
+ get tau() {
24819
+ return this.ms;
24820
+ }
24821
+ get on() {
24822
+ return this.ms > 0;
24823
+ }
24824
+ /** Set the time-constant (0 = off). A non-zero value becomes what `toggle(true)` restores. */
24825
+ set(ms) {
24826
+ this.ms = ms;
24827
+ if (ms > 0) this.onMs = ms;
24828
+ }
24829
+ /** On/off only — the duration stays the last one configured. */
24830
+ toggle(on) {
24831
+ this.ms = on ? this.onMs : 0;
24832
+ }
24833
+ };
24494
24834
  function easeToward(current, target, dtMs, tauMs) {
24495
24835
  if (tauMs <= 0) return target;
24496
24836
  return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
@@ -25214,6 +25554,25 @@ function drawTextLines(ctx, lines, x, firstY, step, color) {
25214
25554
  for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], x, firstY + i * step);
25215
25555
  }
25216
25556
 
25557
+ // src/renderers/shared/marks-state.ts
25558
+ function defaultMarksState() {
25559
+ return { visible: true, groups: {} };
25560
+ }
25561
+ function mergeMarksState(base, patch) {
25562
+ if (typeof patch === "boolean") return { visible: patch, groups: { ...base.groups } };
25563
+ const p = patch && typeof patch === "object" ? patch : {};
25564
+ const g = p.groups && typeof p.groups === "object" ? p.groups : {};
25565
+ const groups = { ...base.groups };
25566
+ for (const [id, v] of Object.entries(g)) if (typeof v === "boolean") groups[id] = v;
25567
+ return { visible: typeof p.visible === "boolean" ? p.visible : base.visible, groups };
25568
+ }
25569
+ function markGroupVisible(state, groupId, groups) {
25570
+ if (groupId === void 0) return true;
25571
+ const chosen = state.groups[groupId];
25572
+ if (typeof chosen === "boolean") return chosen;
25573
+ return groups.find((g) => g.id === groupId)?.visible !== false;
25574
+ }
25575
+
25217
25576
  // src/renderers/native/core/SceneGraph.ts
25218
25577
  var SceneGraph = class {
25219
25578
  constructor() {
@@ -25277,6 +25636,20 @@ var SceneGraph = class {
25277
25636
  /** Strategy trade-marker display (the `tradeMarkers` feature): master toggle, the
25278
25637
  * two text lines, and the palette. Trade markers always paint on the price pane. */
25279
25638
  this.tradeMarkers = defaultTradeMarkersState();
25639
+ /** Timeline-mark display (the `marks` feature): the lane's master toggle + per-group visibility. */
25640
+ this.marks = defaultMarksState();
25641
+ /** The host's timeline marks + group definitions (`setTimelineMarks`), painted on the lane above the time axis. */
25642
+ this.timelineMarks = [];
25643
+ this.markGroups = [];
25644
+ /** The mark stack (bar index) fanned out by hover or tap, if any. */
25645
+ this.marksExpandedStack = null;
25646
+ /** The lane glyph (cluster key) under the pointer — it pulses — and when the hover began (frame-clock ms). */
25647
+ this.marksHoverKey = null;
25648
+ this.marksHoverSince = 0;
25649
+ /** The cluster whose popup is open: its glyph paints filled ("active"). */
25650
+ this.marksActiveKey = null;
25651
+ /** A content-less click's brief filled flash — the cluster key and the frame-clock time it ends. */
25652
+ this.marksFlash = null;
25280
25653
  /** Renderer-owned shaded time bands (session highlighting), behind grid + data. */
25281
25654
  this.highlights = [];
25282
25655
  /** Pre/post-market bands pushed by the host (`sessionZones` feature); null ⇒ no sessions. */
@@ -27125,6 +27498,315 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
27125
27498
  return out;
27126
27499
  }
27127
27500
 
27501
+ // src/renderers/native/chrome/countdown.ts
27502
+ function countdownText(barOpen, barMs, now) {
27503
+ if (!(barMs > 0)) return null;
27504
+ const remaining = barOpen + barMs - now;
27505
+ if (remaining <= 0) return null;
27506
+ return formatCountdown(remaining);
27507
+ }
27508
+ function formatCountdown(ms) {
27509
+ const total = Math.max(0, Math.ceil(ms / 1e3));
27510
+ const s = total % 60;
27511
+ const m = Math.floor(total / 60) % 60;
27512
+ const h = Math.floor(total / 3600);
27513
+ const pad = (v) => String(v).padStart(2, "0");
27514
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
27515
+ }
27516
+
27517
+ // src/renderers/native/chrome/contrast.ts
27518
+ function tagTextColor(bg, over) {
27519
+ const [r, g, b, a] = parseColor(bg);
27520
+ let R = r;
27521
+ let G = g;
27522
+ let B = b;
27523
+ if (a < 1) {
27524
+ const [or, og, ob] = parseColor(over);
27525
+ R = r * a + or * (1 - a);
27526
+ G = g * a + og * (1 - a);
27527
+ B = b * a + ob * (1 - a);
27528
+ }
27529
+ const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
27530
+ const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
27531
+ return L >= 0.4 ? "#000000" : "#ffffff";
27532
+ }
27533
+
27534
+ // src/renderers/native/chrome/marks/layout.ts
27535
+ var MARK_GLYPH_PX = 16;
27536
+ var MARK_CLUSTER_PX = 20;
27537
+ var MARK_LANE_INSET = 4;
27538
+ var MARK_DECK_STEP = 3;
27539
+ var MARK_FAN_GAP = 4;
27540
+ var MARK_HIT_PAD = 3;
27541
+ var MARK_FAN_HOLD = 8;
27542
+ function snapMarkBar(time, barTimes, intervalMs2) {
27543
+ const n = barTimes.length;
27544
+ if (n === 0 || !(intervalMs2 > 0) || !Number.isFinite(time)) return null;
27545
+ if (time < barTimes[0]) return null;
27546
+ const last = barTimes[n - 1];
27547
+ if (time >= last + intervalMs2) return n - 1 + Math.floor((time - last) / intervalMs2);
27548
+ let lo = 0;
27549
+ let hi = n - 1;
27550
+ while (lo < hi) {
27551
+ const mid = lo + hi + 1 >> 1;
27552
+ if (barTimes[mid] <= time) lo = mid;
27553
+ else hi = mid - 1;
27554
+ }
27555
+ if (time < barTimes[lo] + intervalMs2) return lo;
27556
+ return lo + 1;
27557
+ }
27558
+ function clusterMarks(marks, barTimes, intervalMs2, hidden) {
27559
+ const byKey = /* @__PURE__ */ new Map();
27560
+ marks.forEach((m, seq) => {
27561
+ if (m.group !== void 0 && hidden(m.group)) return;
27562
+ const bar = snapMarkBar(m.time, barTimes, intervalMs2);
27563
+ if (bar === null) return;
27564
+ const key = `${bar}|${m.group ?? ""}`;
27565
+ let c = byKey.get(key);
27566
+ if (!c) {
27567
+ c = { key, bar, group: m.group, marks: [], seq: [] };
27568
+ byKey.set(key, c);
27569
+ }
27570
+ c.marks.push(m);
27571
+ c.seq.push(seq);
27572
+ });
27573
+ const out = [];
27574
+ for (const c of byKey.values()) {
27575
+ const order = c.marks.map((m, i) => ({ m, seq: c.seq[i] })).sort((a, b) => a.m.time - b.m.time || a.seq - b.seq);
27576
+ out.push({ key: c.key, bar: c.bar, group: c.group, marks: order.map((o) => o.m) });
27577
+ }
27578
+ return out;
27579
+ }
27580
+ function groupRank(groups, clusters) {
27581
+ const rank = /* @__PURE__ */ new Map();
27582
+ groups.forEach((g, i) => rank.set(g.id, i));
27583
+ for (const c of clusters) {
27584
+ if (c.group !== void 0 && !rank.has(c.group)) rank.set(c.group, rank.size);
27585
+ }
27586
+ return (group) => group === void 0 ? Number.MAX_SAFE_INTEGER : rank.get(group) ?? Number.MAX_SAFE_INTEGER - 1;
27587
+ }
27588
+ function layoutMarkLane(input) {
27589
+ const clusters = clusterMarks(input.marks, input.barTimes, input.intervalMs, input.hidden);
27590
+ const rankOf = groupRank(input.groups, clusters);
27591
+ const byBar = /* @__PURE__ */ new Map();
27592
+ for (const c of clusters) {
27593
+ const list = byBar.get(c.bar);
27594
+ if (list) list.push(c);
27595
+ else byBar.set(c.bar, [c]);
27596
+ }
27597
+ const glyphs = [];
27598
+ const stacks = /* @__PURE__ */ new Map();
27599
+ for (const [bar, list] of byBar) {
27600
+ const x = input.xOf(bar);
27601
+ if (!Number.isFinite(x) || x < -MARK_CLUSTER_PX || x > input.dataW + MARK_CLUSTER_PX) continue;
27602
+ list.sort((a, b) => rankOf(a.group) - rankOf(b.group));
27603
+ const multi = list.length > 1;
27604
+ const expanded = multi && input.expanded === bar;
27605
+ const decked = multi && !expanded;
27606
+ const placed = [];
27607
+ const deckSize = list[0].marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
27608
+ let bottom = input.axisY - MARK_LANE_INSET;
27609
+ list.forEach((cluster, depth) => {
27610
+ const size = decked ? deckSize : cluster.marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
27611
+ let y;
27612
+ if (expanded) {
27613
+ y = bottom - size / 2;
27614
+ bottom -= size + MARK_FAN_GAP;
27615
+ } else {
27616
+ y = input.axisY - MARK_LANE_INSET - size / 2 - depth * MARK_DECK_STEP;
27617
+ }
27618
+ placed.push({ cluster, x, y, size, stack: bar, depth, decked });
27619
+ });
27620
+ for (let i = placed.length - 1; i >= 0; i--) glyphs.push(placed[i]);
27621
+ stacks.set(bar, placed);
27622
+ }
27623
+ return { glyphs, stacks };
27624
+ }
27625
+ function markGlyphAt(layout, x, y) {
27626
+ for (let i = layout.glyphs.length - 1; i >= 0; i--) {
27627
+ const g = layout.glyphs[i];
27628
+ if (g.decked && g.depth !== 0) continue;
27629
+ const r = g.size / 2 + MARK_HIT_PAD;
27630
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return g;
27631
+ }
27632
+ return null;
27633
+ }
27634
+ function markStackAt(layout, x, y) {
27635
+ for (const [bar, placed] of layout.stacks) {
27636
+ for (const g of placed) {
27637
+ const r = g.size / 2 + MARK_HIT_PAD;
27638
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return bar;
27639
+ }
27640
+ if (placed.length > 1 && !placed[0].decked) {
27641
+ const top = placed[placed.length - 1];
27642
+ const base = placed[0];
27643
+ const r = Math.max(top.size, base.size) / 2 + MARK_HIT_PAD;
27644
+ if (Math.abs(x - base.x) <= r && y >= top.y - top.size / 2 - MARK_FAN_HOLD && y <= base.y + base.size / 2 + MARK_HIT_PAD) return bar;
27645
+ }
27646
+ }
27647
+ return null;
27648
+ }
27649
+ function clusterTooltip(cluster, groups) {
27650
+ const first = cluster.marks[0];
27651
+ if (!first) return null;
27652
+ if (cluster.marks.length === 1) return first.tooltip ?? first.title ?? null;
27653
+ const label = cluster.group !== void 0 ? markGroupLabel(cluster.group, groups) : first.title ?? first.tooltip ?? "Marks";
27654
+ return `${label} \xB7 ${cluster.marks.length}`;
27655
+ }
27656
+ function markGroupLabel(groupId, groups) {
27657
+ const def = groups.find((g) => g.id === groupId);
27658
+ if (def) return def.label;
27659
+ return groupId.charAt(0).toUpperCase() + groupId.slice(1);
27660
+ }
27661
+ function effectiveMarkGroups(marks, groups) {
27662
+ const out = groups.map((g) => ({ ...g }));
27663
+ const seen = new Set(out.map((g) => g.id));
27664
+ for (const m of marks) {
27665
+ if (m.group === void 0 || seen.has(m.group)) continue;
27666
+ seen.add(m.group);
27667
+ out.push({ id: m.group, label: markGroupLabel(m.group, groups) });
27668
+ }
27669
+ return out;
27670
+ }
27671
+
27672
+ // src/renderers/native/chrome/marks/paint.ts
27673
+ var MARK_PULSE_MS = 360;
27674
+ var MARK_PULSE_AMPLITUDE = 0.1;
27675
+ var ACTIVE_INK = "#ffffff";
27676
+ function pulseScale(elapsedMs) {
27677
+ if (!(elapsedMs > 0) || elapsedMs >= MARK_PULSE_MS) return 1;
27678
+ return 1 + MARK_PULSE_AMPLITUDE * Math.sin(Math.PI * elapsedMs / MARK_PULSE_MS);
27679
+ }
27680
+ function paintMarkLane(ctx, layout, deps) {
27681
+ if (layout.glyphs.length === 0) return;
27682
+ ctx.save();
27683
+ ctx.setLineDash([]);
27684
+ ctx.textAlign = "center";
27685
+ ctx.textBaseline = "middle";
27686
+ for (const g of layout.glyphs) {
27687
+ const mark = g.cluster.marks[0];
27688
+ if (!mark) continue;
27689
+ const key = g.cluster.key;
27690
+ const color = mark.glyph.color;
27691
+ const active = key === deps.activeKey || key === deps.flashKey;
27692
+ const size = g.size * (key === deps.hoverKey ? pulseScale(deps.nowMs - deps.hoverSince) : 1);
27693
+ if (g.depth === 0) {
27694
+ const sx = Math.round(g.x) + 0.5;
27695
+ ctx.lineWidth = 1;
27696
+ ctx.strokeStyle = deps.stemColor;
27697
+ ctx.beginPath();
27698
+ ctx.moveTo(sx, g.y + g.size / 2);
27699
+ ctx.lineTo(sx, deps.axisY);
27700
+ ctx.stroke();
27701
+ }
27702
+ const shape = mark.glyph.shape ?? "circle";
27703
+ const center = traceShape(ctx, shape, g.x, g.y, size);
27704
+ ctx.lineWidth = 4;
27705
+ ctx.strokeStyle = deps.background;
27706
+ ctx.stroke();
27707
+ ctx.fillStyle = active ? color : deps.background;
27708
+ ctx.fill();
27709
+ ctx.lineWidth = 1.5;
27710
+ ctx.strokeStyle = color;
27711
+ ctx.stroke();
27712
+ const ink = active ? ACTIVE_INK : color;
27713
+ const symbolPx = Math.round(size * 0.62);
27714
+ if (mark.glyph.icon) {
27715
+ const img = deps.icons.get(mark.glyph.icon, ink, symbolPx, deps.dpr);
27716
+ if (img) ctx.drawImage(img, center.x - symbolPx / 2, center.y - symbolPx / 2, symbolPx, symbolPx);
27717
+ } else if (mark.glyph.letter) {
27718
+ ctx.fillStyle = ink;
27719
+ ctx.font = `600 ${Math.round(size * 0.58)}px ${deps.fontFamily}`;
27720
+ ctx.fillText(mark.glyph.letter.slice(0, 2), center.x, center.y + 0.5);
27721
+ }
27722
+ }
27723
+ ctx.restore();
27724
+ }
27725
+ function traceShape(ctx, shape, x, y, size) {
27726
+ const r = size / 2;
27727
+ ctx.beginPath();
27728
+ switch (shape) {
27729
+ case "square": {
27730
+ const c = Math.min(3, r / 2);
27731
+ roundedRect(ctx, x - r, y - r, size, size, c);
27732
+ return { x, y };
27733
+ }
27734
+ case "diamond":
27735
+ ctx.moveTo(x, y - r);
27736
+ ctx.lineTo(x + r, y);
27737
+ ctx.lineTo(x, y + r);
27738
+ ctx.lineTo(x - r, y);
27739
+ ctx.closePath();
27740
+ return { x, y };
27741
+ case "pin": {
27742
+ const hr = r * 0.82;
27743
+ const hy = y - r + hr;
27744
+ ctx.arc(x, hy, hr, Math.PI * 0.75, Math.PI * 0.25, false);
27745
+ ctx.lineTo(x, y + r);
27746
+ ctx.closePath();
27747
+ return { x, y: hy };
27748
+ }
27749
+ case "circle":
27750
+ default:
27751
+ ctx.arc(x, y, r, 0, Math.PI * 2);
27752
+ return { x, y };
27753
+ }
27754
+ }
27755
+ function roundedRect(ctx, x, y, w, h, radius) {
27756
+ ctx.moveTo(x + radius, y);
27757
+ ctx.lineTo(x + w - radius, y);
27758
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
27759
+ ctx.lineTo(x + w, y + h - radius);
27760
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
27761
+ ctx.lineTo(x + radius, y + h);
27762
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
27763
+ ctx.lineTo(x, y + radius);
27764
+ ctx.quadraticCurveTo(x, y, x + radius, y);
27765
+ ctx.closePath();
27766
+ }
27767
+ var MarkIconRaster = class {
27768
+ constructor(onReady) {
27769
+ this.onReady = onReady;
27770
+ this.cache = /* @__PURE__ */ new Map();
27771
+ }
27772
+ get(icon2, ink, px, dpr) {
27773
+ const key = `${icon2}|${ink}|${px}|${dpr}`;
27774
+ if (this.cache.has(key)) {
27775
+ const img2 = this.cache.get(key);
27776
+ return img2 && img2.complete && img2.naturalWidth > 0 ? img2 : null;
27777
+ }
27778
+ const markup = iconMarkup(icon2);
27779
+ if (!markup || typeof document === "undefined" || typeof Image === "undefined" || typeof XMLSerializer === "undefined") {
27780
+ this.cache.set(key, null);
27781
+ return null;
27782
+ }
27783
+ const svg = standaloneSvg(markup, ink, Math.max(1, Math.ceil(px * dpr)));
27784
+ if (!svg) {
27785
+ this.cache.set(key, null);
27786
+ return null;
27787
+ }
27788
+ const img = new Image();
27789
+ img.onload = () => this.onReady();
27790
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
27791
+ this.cache.set(key, img);
27792
+ return null;
27793
+ }
27794
+ clear() {
27795
+ this.cache.clear();
27796
+ }
27797
+ };
27798
+ function standaloneSvg(markup, ink, px) {
27799
+ const tpl = document.createElement("template");
27800
+ tpl.innerHTML = markup;
27801
+ const svg = tpl.content.firstElementChild;
27802
+ if (!svg || svg.tagName.toLowerCase() !== "svg") return null;
27803
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
27804
+ svg.setAttribute("width", String(px));
27805
+ svg.setAttribute("height", String(px));
27806
+ svg.setAttribute("color", ink);
27807
+ return new XMLSerializer().serializeToString(svg);
27808
+ }
27809
+
27128
27810
  // src/renderers/native/chrome/ChromeRenderer.ts
27129
27811
  var ChromeRenderer = class {
27130
27812
  constructor() {
@@ -27134,11 +27816,40 @@ var ChromeRenderer = class {
27134
27816
  this.axisTextColor = DARK_THEME.textColor;
27135
27817
  // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
27136
27818
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
27819
+ /** The timeline-mark lane as laid out by the last frame — what hover/click hit-test against. */
27820
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27821
+ /** Registry icons rasterized for the lane; the owner is asked for a chrome repaint when one lands. */
27822
+ this.markIcons = new MarkIconRaster(() => this.onMarkIconReady?.());
27823
+ this.onMarkIconReady = null;
27824
+ /** Bar open times of the current series, rebuilt only when the array or its length changes (a live tick keeps both). */
27825
+ this.barTimesSrc = null;
27826
+ this.barTimesCache = [];
27137
27827
  }
27138
27828
  mount(canvas) {
27139
27829
  this.canvas = canvas;
27140
27830
  this.ctx = canvas.getContext("2d");
27141
27831
  }
27832
+ /** Where to ask for a chrome repaint when a lane icon finishes rasterizing. */
27833
+ setMarkIconReady(cb) {
27834
+ this.onMarkIconReady = cb;
27835
+ }
27836
+ /** The interactive mark glyph under a plot point (last frame's layout), or null. */
27837
+ markGlyphAt(x, y) {
27838
+ return markGlyphAt(this.markLayout, x, y);
27839
+ }
27840
+ /** The mark stack (bar index) whose glyphs — or the gaps of its fan — cover a plot point. */
27841
+ markStackAt(x, y) {
27842
+ return markStackAt(this.markLayout, x, y);
27843
+ }
27844
+ /** A glyph of the last frame by its cluster key — how an open popup follows its anchor. */
27845
+ markGlyphByKey(key) {
27846
+ return this.markLayout.glyphs.find((g) => g.cluster.key === key) ?? null;
27847
+ }
27848
+ /** Hover text of the mark glyph under a plot point, or null. */
27849
+ markTooltipAt(x, y, groups) {
27850
+ const g = this.markGlyphAt(x, y);
27851
+ return g ? clusterTooltip(g.cluster, groups) : null;
27852
+ }
27142
27853
  /** Wire the drawing coordinate resolvers + theme (call once per frame before use). */
27143
27854
  prepare(scene, coords, theme) {
27144
27855
  this.drawScene.setDeps({
@@ -27187,6 +27898,7 @@ var ChromeRenderer = class {
27187
27898
  const panes = scene.orderedPanes();
27188
27899
  if (coords.barCount === 0) {
27189
27900
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
27901
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27190
27902
  return;
27191
27903
  }
27192
27904
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
@@ -27200,6 +27912,46 @@ var ChromeRenderer = class {
27200
27912
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
27201
27913
  this.drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane);
27202
27914
  this.drawTimeAxis(ctx, scene, coords, theme, dataW, dataH, fullH);
27915
+ this.drawMarkLane(ctx, scene, coords, theme, dataW, dataH);
27916
+ }
27917
+ /** The timeline-mark lane — after the axis, so the tokens read over the plot's bottom edge. */
27918
+ drawMarkLane(ctx, scene, coords, theme, dataW, dataH) {
27919
+ if (!scene.marks.visible || scene.timelineMarks.length === 0) {
27920
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27921
+ return;
27922
+ }
27923
+ this.markLayout = layoutMarkLane({
27924
+ marks: scene.timelineMarks,
27925
+ groups: scene.markGroups,
27926
+ hidden: (groupId) => !markGroupVisible(scene.marks, groupId, scene.markGroups),
27927
+ barTimes: this.barTimes(scene),
27928
+ intervalMs: coords.barInterval,
27929
+ xOf: (bar) => coords.logicalToX(bar),
27930
+ axisY: dataH,
27931
+ dataW,
27932
+ expanded: scene.marksExpandedStack
27933
+ });
27934
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
27935
+ paintMarkLane(ctx, this.markLayout, {
27936
+ axisY: dataH,
27937
+ background: theme.background,
27938
+ stemColor: scene.style.borderColor ?? theme.borderColor,
27939
+ fontFamily: theme.fontFamily,
27940
+ dpr: coords.dpr,
27941
+ icons: this.markIcons,
27942
+ hoverKey: scene.marksHoverKey,
27943
+ hoverSince: scene.marksHoverSince,
27944
+ activeKey: scene.marksActiveKey,
27945
+ flashKey: scene.marksFlash && scene.marksFlash.until > nowMs ? scene.marksFlash.key : null,
27946
+ nowMs
27947
+ });
27948
+ }
27949
+ barTimes(scene) {
27950
+ if (this.barTimesSrc !== scene.bars || this.barTimesCache.length !== scene.bars.length) {
27951
+ this.barTimesSrc = scene.bars;
27952
+ this.barTimesCache = scene.bars.map((b) => b.time);
27953
+ }
27954
+ return this.barTimesCache;
27203
27955
  }
27204
27956
  destroy() {
27205
27957
  this.canvas = null;
@@ -27315,7 +28067,8 @@ var ChromeRenderer = class {
27315
28067
  * - the countdown-to-bar-close chip (`showCountdown`).
27316
28068
  * When the label and countdown are both on they merge into one stacked block (countdown
27317
28069
  * under the label, text flushed left); a lone label or countdown is centered on the
27318
- * price level with centered text. The countdown ticks once per second (repaint scheduled).
28070
+ * price level with centered text. The countdown repaints on the renderer's second pulse
28071
+ * and disappears once the bar has closed, until the next bar arrives.
27319
28072
  */
27320
28073
  drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane) {
27321
28074
  const n = scene.bars.length;
@@ -27335,17 +28088,16 @@ var ChromeRenderer = class {
27335
28088
  ctx.stroke();
27336
28089
  setDash2(ctx, "solid");
27337
28090
  }
27338
- const interval = coords.barInterval;
27339
- const showCountdown = scene.showCountdown && interval > 0;
28091
+ const cdText = scene.showCountdown ? countdownText(last.time, coords.barInterval, Date.now()) : null;
28092
+ const showCountdown = cdText !== null;
27340
28093
  const showLabel = scene.showPriceLabel;
27341
28094
  if (!showLabel && !showCountdown) return;
27342
28095
  const priceText = formatAxisValue(pricePane.scale, pricePane.bounds.height, last.close, percentScaleFor(scene, pricePane), scene.priceMintick);
27343
- const cdText = showCountdown ? formatCountdown(last.time + interval - Date.now()) : "";
27344
28096
  const PAD = 8;
27345
28097
  const x = dataW + 1;
27346
28098
  const textColor = tagTextColor(color, theme.background);
27347
28099
  ctx.textBaseline = "middle";
27348
- if (showLabel && showCountdown) {
28100
+ if (showLabel && cdText !== null) {
27349
28101
  const w2 = Math.max(ctx.measureText(priceText).width, ctx.measureText(cdText).width) + PAD;
27350
28102
  const top = y - 8;
27351
28103
  const tx = x + PAD / 2;
@@ -27358,7 +28110,7 @@ var ChromeRenderer = class {
27358
28110
  ctx.textAlign = "start";
27359
28111
  return;
27360
28112
  }
27361
- const text = showLabel ? priceText : cdText;
28113
+ const text = showLabel ? priceText : cdText ?? "";
27362
28114
  const w = ctx.measureText(text).width + PAD;
27363
28115
  ctx.fillStyle = color;
27364
28116
  ctx.fillRect(x, y - 8, w, 16);
@@ -27429,29 +28181,6 @@ function setDash2(ctx, style) {
27429
28181
  else if (style === "dotted") ctx.setLineDash([2, 3]);
27430
28182
  else ctx.setLineDash([]);
27431
28183
  }
27432
- function tagTextColor(bg, over) {
27433
- const [r, g, b, a] = parseColor(bg);
27434
- let R = r;
27435
- let G = g;
27436
- let B = b;
27437
- if (a < 1) {
27438
- const [or, og, ob] = parseColor(over);
27439
- R = r * a + or * (1 - a);
27440
- G = g * a + og * (1 - a);
27441
- B = b * a + ob * (1 - a);
27442
- }
27443
- const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
27444
- const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
27445
- return L >= 0.4 ? "#000000" : "#ffffff";
27446
- }
27447
- function formatCountdown(ms) {
27448
- const total = Math.max(0, Math.floor(ms / 1e3));
27449
- const s = total % 60;
27450
- const m = Math.floor(total / 60) % 60;
27451
- const h = Math.floor(total / 3600);
27452
- const pad = (v) => String(v).padStart(2, "0");
27453
- return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
27454
- }
27455
28184
 
27456
28185
  // src/renderers/native/chrome/LabelTooltip.ts
27457
28186
  var HOVER_DELAY_MS = 350;
@@ -27657,6 +28386,11 @@ function setDash3(ctx, style) {
27657
28386
  function settingsIdSlug(label) {
27658
28387
  return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27659
28388
  }
28389
+ var MARKS_SETTINGS_ID = "events";
28390
+ var MARKS_GROUPS_SETTINGS_ID = "events.groups";
28391
+ function markGroupSettingsId(groupId) {
28392
+ return `${MARKS_GROUPS_SETTINGS_ID}.${settingsIdSlug(groupId)}`;
28393
+ }
27660
28394
  function settingsIdHidden(id, hidden) {
27661
28395
  if (hidden.size === 0) return false;
27662
28396
  let path = id;
@@ -27750,7 +28484,11 @@ var BUILTIN_SETTINGS_IDS = [
27750
28484
  "symbol.style.baseline.base-level",
27751
28485
  "symbol.style.baseline.width",
27752
28486
  "symbol.animation",
28487
+ "symbol.animation.zoom",
28488
+ "symbol.animation.pan",
28489
+ "symbol.animation.autoscale",
27753
28490
  "symbol.animation.price-changes",
28491
+ "symbol.animation.intro",
27754
28492
  "symbol.timezone",
27755
28493
  "scales",
27756
28494
  "scales.price-scale",
@@ -27776,8 +28514,13 @@ var BUILTIN_SETTINGS_IDS = [
27776
28514
  "canvas.grid.horizontal",
27777
28515
  "canvas.theme"
27778
28516
  ];
27779
- function settingsIdCatalog(hostSections) {
28517
+ function settingsIdCatalog(hostSections, markGroups = []) {
27780
28518
  const ids = new Set(BUILTIN_SETTINGS_IDS);
28519
+ if (markGroups.length > 0) {
28520
+ ids.add(MARKS_SETTINGS_ID);
28521
+ ids.add(MARKS_GROUPS_SETTINGS_ID);
28522
+ for (const g of markGroups) ids.add(markGroupSettingsId(g.id));
28523
+ }
27781
28524
  for (const def of chartTypes()) {
27782
28525
  if (hasOwnCandlePaint(def.id)) {
27783
28526
  const style = `symbol.style.${def.id}`;
@@ -27817,7 +28560,7 @@ function styleLabel(id) {
27817
28560
  return chartType(id)?.label ?? BUILTIN_STYLE_LABELS2[id] ?? id;
27818
28561
  }
27819
28562
  var SD_STYLE_ID = "vela-settings-controls";
27820
- var SD_STYLE_REV = "5";
28563
+ var SD_STYLE_REV = "6";
27821
28564
  var SETTINGS_BORDER = "var(--vela-border)";
27822
28565
  function ensureControlStyles() {
27823
28566
  if (typeof document === "undefined") return;
@@ -27893,7 +28636,15 @@ ${overlayScrollbarCss(".vela-sd-pane")}
27893
28636
  .vela-sd-mobile .vela-select-trigger,.vela-sd-mobile .vela-num input,.vela-sd-mobile .vela-width-field{height:34px;}
27894
28637
  .vela-sd-mobile .vela-sd-close{width:40px;height:40px;}
27895
28638
  .vela-sd-mobile .vela-sd-btn{height:38px;}
27896
- .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}`;
28639
+ .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}
28640
+ /* The wrap rule above is for ROW LABELS only: a select's closed value must keep its
28641
+ single-line ellipsis, or a long option wraps to several lines inside the 34px
28642
+ trigger and spills over the rows around it. Three classes so it outranks the
28643
+ two-classes-plus-element selector above. The kit's fixed 100px column is a desktop
28644
+ alignment device; on mobile the trigger hugs its value instead (the grid's control
28645
+ column is max-content), capped so a long option still ellipsizes before the label. */
28646
+ .vela-sd-mobile .vela-select-trigger .vela-select-label{white-space:nowrap !important;}
28647
+ .vela-sd-mobile .vela-select:not([data-fill]){width:auto;min-width:100px;max-width:min(220px,55vw);}`;
27897
28648
  if (!existing) document.head.appendChild(st);
27898
28649
  }
27899
28650
  var SettingsDialog = class {
@@ -27908,6 +28659,9 @@ var SettingsDialog = class {
27908
28659
  this.config = null;
27909
28660
  this.syncTypeTabs = null;
27910
28661
  this.hostSections = [];
28662
+ /** The timeline-mark groups (defined + named by marks) — one checkbox each on the Events tab. */
28663
+ this.markGroups = [];
28664
+ this.markGroupVisible = () => true;
27911
28665
  /** The Canvas → Theme row: current app theme + where a pick is raised. The row is a
27912
28666
  * host callback, NOT a config patch — the app theme stays out of the persisted
27913
28667
  * `ChartConfig`, so exported templates never carry it. */
@@ -27932,6 +28686,11 @@ var SettingsDialog = class {
27932
28686
  setHostSections(sections) {
27933
28687
  this.hostSections = sections;
27934
28688
  }
28689
+ /** The timeline-mark groups and their current visibility — the Events tab's rows on next open. */
28690
+ setMarkGroups(groups, visible) {
28691
+ this.markGroups = groups;
28692
+ this.markGroupVisible = visible;
28693
+ }
27935
28694
  /** Replace the visibility policy — an open dialog rebuilds in place to honor it. */
27936
28695
  setHiddenSettings(ids) {
27937
28696
  const next = new Set(ids);
@@ -28105,12 +28864,36 @@ var SettingsDialog = class {
28105
28864
  }
28106
28865
  showActive(config.series.style);
28107
28866
  body.append(sid(this.sectionTitle("Animation"), "symbol.animation"));
28867
+ body.append(sid(this.boolRow(
28868
+ "Animate zoom",
28869
+ config.animations.zoom,
28870
+ (v) => this.emit({ animations: { zoom: v } }),
28871
+ this.hint("Glide the chart to each zoom step instead of jumping.")
28872
+ ), "symbol.animation.zoom"));
28873
+ body.append(sid(this.boolRow(
28874
+ "Pan momentum",
28875
+ config.animations.pan,
28876
+ (v) => this.emit({ animations: { pan: v } }),
28877
+ this.hint("Keep gliding briefly after a drag release, and ease scroll-to-latest and keyboard pans.")
28878
+ ), "symbol.animation.pan"));
28879
+ body.append(sid(this.boolRow(
28880
+ "Animate price scale",
28881
+ config.animations.autoscale,
28882
+ (v) => this.emit({ animations: { autoscale: v } }),
28883
+ this.hint("Glide the price scale to its new range while zooming or panning.")
28884
+ ), "symbol.animation.autoscale"));
28108
28885
  body.append(sid(this.boolRow(
28109
28886
  "Animate price changes",
28110
28887
  config.priceScale.animateLastPrice,
28111
28888
  (v) => this.emit({ priceScale: { animateLastPrice: v } }),
28112
28889
  this.hint("Glide the live bar to each new price instead of snapping.")
28113
28890
  ), "symbol.animation.price-changes"));
28891
+ body.append(sid(this.boolRow(
28892
+ "Reveal on load",
28893
+ config.animations.intro,
28894
+ (v) => this.emit({ animations: { intro: v } }),
28895
+ this.hint("Draw the candles in when a chart first loads. Takes effect on the next load.")
28896
+ ), "symbol.animation.intro"));
28114
28897
  body.append(sid(this.sectionTitle("Time zone"), "symbol.timezone"));
28115
28898
  body.append(sid(this.selectRowLabeled("Time zone", normalizeTimezone(config.timeScale.timezone), timezoneOptions(config.timeScale.timezone), (v) => this.emit({ timeScale: { timezone: v } })), "symbol.timezone"));
28116
28899
  const renderHostSections = (placement) => {
@@ -28180,6 +28963,13 @@ var SettingsDialog = class {
28180
28963
  body.append(sid(this.sectionTitle("Theme"), "canvas.theme"));
28181
28964
  body.append(sid(this.selectRow("Color theme", tc.current === "dark" ? "Dark" : "Light", ["Dark", "Light"], (v) => tc.onSelect(v === "Dark" ? "dark" : "light")), "canvas.theme"));
28182
28965
  }
28966
+ if (this.markGroups.length > 0) {
28967
+ body.append(sid(this.section("Events"), MARKS_SETTINGS_ID));
28968
+ body.append(sid(this.sectionTitle("Visible events"), MARKS_GROUPS_SETTINGS_ID));
28969
+ for (const g of this.markGroups) {
28970
+ body.append(sid(this.boolRow(g.label, this.markGroupVisible(g.id), (v) => this.emit({ marks: { groups: { [g.id]: v } } })), markGroupSettingsId(g.id)));
28971
+ }
28972
+ }
28183
28973
  renderChartTypeSections("end");
28184
28974
  renderHostSections("end");
28185
28975
  if (this.hiddenSettings.size > 0) {
@@ -33739,6 +34529,383 @@ function expandScaleByPixels(scale, heightPx, abovePx, belowPx) {
33739
34529
  return { ...scale, min: scale.min - belowPx * perPx, max: scale.max + abovePx * perPx };
33740
34530
  }
33741
34531
 
34532
+ // src/ui/sanitize-html.ts
34533
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
34534
+ "a",
34535
+ "abbr",
34536
+ "b",
34537
+ "blockquote",
34538
+ "br",
34539
+ "code",
34540
+ "dd",
34541
+ "del",
34542
+ "div",
34543
+ "dl",
34544
+ "dt",
34545
+ "em",
34546
+ "h1",
34547
+ "h2",
34548
+ "h3",
34549
+ "h4",
34550
+ "h5",
34551
+ "h6",
34552
+ "hr",
34553
+ "i",
34554
+ "img",
34555
+ "ins",
34556
+ "kbd",
34557
+ "li",
34558
+ "mark",
34559
+ "ol",
34560
+ "p",
34561
+ "pre",
34562
+ "q",
34563
+ "s",
34564
+ "small",
34565
+ "span",
34566
+ "strong",
34567
+ "sub",
34568
+ "sup",
34569
+ "table",
34570
+ "tbody",
34571
+ "td",
34572
+ "tfoot",
34573
+ "th",
34574
+ "thead",
34575
+ "tr",
34576
+ "u",
34577
+ "ul"
34578
+ ]);
34579
+ var DROPPED_TAGS = /* @__PURE__ */ new Set([
34580
+ "script",
34581
+ "style",
34582
+ "iframe",
34583
+ "frame",
34584
+ "frameset",
34585
+ "object",
34586
+ "embed",
34587
+ "applet",
34588
+ "form",
34589
+ "input",
34590
+ "textarea",
34591
+ "button",
34592
+ "select",
34593
+ "option",
34594
+ "link",
34595
+ "meta",
34596
+ "base",
34597
+ "svg",
34598
+ "math",
34599
+ "template",
34600
+ "noscript",
34601
+ "audio",
34602
+ "video",
34603
+ "canvas",
34604
+ "dialog",
34605
+ "head",
34606
+ "title"
34607
+ ]);
34608
+ var ALLOWED_ATTRS = {
34609
+ a: /* @__PURE__ */ new Set(["href"]),
34610
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height"]),
34611
+ td: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
34612
+ th: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
34613
+ ol: /* @__PURE__ */ new Set(["start"])
34614
+ };
34615
+ var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "vbscript", "data", "file", "blob"]);
34616
+ function tagDisposition(tag) {
34617
+ const t = tag.toLowerCase();
34618
+ if (DROPPED_TAGS.has(t)) return "drop";
34619
+ return ALLOWED_TAGS.has(t) ? "keep" : "unwrap";
34620
+ }
34621
+ function attributeAllowed(tag, name) {
34622
+ const n = name.toLowerCase();
34623
+ if (n.startsWith("on") || n === "style") return false;
34624
+ if (n === "title") return true;
34625
+ return ALLOWED_ATTRS[tag.toLowerCase()]?.has(n) ?? false;
34626
+ }
34627
+ function safeUrl(value, absoluteOnly = false) {
34628
+ let url = "";
34629
+ for (const ch of value) if (ch.charCodeAt(0) > 32) url += ch;
34630
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(url);
34631
+ const scheme = m ? m[1].toLowerCase() : null;
34632
+ if (scheme !== null && BLOCKED_SCHEMES.has(scheme)) return null;
34633
+ if (absoluteOnly && scheme !== "http" && scheme !== "https") return null;
34634
+ return url;
34635
+ }
34636
+ var ELEMENT_NODE = 1;
34637
+ var TEXT_NODE = 3;
34638
+ function sanitizeHtml(html, doc) {
34639
+ const tpl = doc.createElement("template");
34640
+ tpl.innerHTML = html;
34641
+ const out = doc.createDocumentFragment();
34642
+ copyChildren(tpl.content, out, doc);
34643
+ return out;
34644
+ }
34645
+ function copyChildren(from, to, doc) {
34646
+ for (const child of Array.from(from.childNodes)) {
34647
+ if (child.nodeType === TEXT_NODE) {
34648
+ to.appendChild(doc.createTextNode(child.textContent ?? ""));
34649
+ continue;
34650
+ }
34651
+ if (child.nodeType !== ELEMENT_NODE) continue;
34652
+ const el = child;
34653
+ const tag = el.tagName.toLowerCase();
34654
+ const disposition = tagDisposition(tag);
34655
+ if (disposition === "drop") continue;
34656
+ if (disposition === "unwrap") {
34657
+ copyChildren(el, to, doc);
34658
+ continue;
34659
+ }
34660
+ const clean = doc.createElement(tag);
34661
+ for (const attr of Array.from(el.attributes)) {
34662
+ const name = attr.name.toLowerCase();
34663
+ if (!attributeAllowed(tag, name)) continue;
34664
+ let value = attr.value;
34665
+ if (name === "href" || name === "src") {
34666
+ const safe = safeUrl(value, name === "src");
34667
+ if (safe === null) continue;
34668
+ value = safe;
34669
+ }
34670
+ clean.setAttribute(name, value);
34671
+ }
34672
+ if (tag === "a") {
34673
+ clean.setAttribute("target", "_blank");
34674
+ clean.setAttribute("rel", "noopener noreferrer");
34675
+ }
34676
+ copyChildren(el, clean, doc);
34677
+ to.appendChild(clean);
34678
+ }
34679
+ }
34680
+
34681
+ // src/renderers/native/chrome/marks/MarkPopover.ts
34682
+ var MARKS_STYLE_ID = "vela-marks-popover";
34683
+ var MARKS_CSS = `
34684
+ .vela-marks-panel { padding: 0; gap: 0; min-width: 220px; max-width: 320px; max-height: 320px; overflow-y: auto; overscroll-behavior: contain; }
34685
+ .vela-marks-section { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; }
34686
+ .vela-marks-section + .vela-marks-section { border-top: 1px solid var(--vela-border); }
34687
+ .vela-marks-field { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
34688
+ .vela-marks-field-label { color: var(--vela-fg-muted); }
34689
+ .vela-marks-field-value { color: var(--vela-fg-bright); text-align: right; font-variant-numeric: tabular-nums; }
34690
+ .vela-marks-html { color: var(--vela-fg); line-height: 1.45; overflow-wrap: anywhere; }
34691
+ .vela-marks-html p { margin: 0 0 6px; }
34692
+ .vela-marks-html p:last-child { margin-bottom: 0; }
34693
+ .vela-marks-html a { color: var(--vela-accent); }
34694
+ .vela-marks-html img { max-width: 100%; height: auto; }
34695
+ .vela-marks-html table { border-collapse: collapse; }
34696
+ .vela-marks-html td, .vela-marks-html th { padding: 2px 6px; border: 1px solid var(--vela-border); }
34697
+ .vela-marks-html pre, .vela-marks-html code { font-family: var(--vela-font-mono, monospace); font-size: 0.92em; }
34698
+ .vela-marks-loading, .vela-marks-error { color: var(--vela-fg-muted); font-style: italic; }
34699
+ `;
34700
+ var MarkPopover = class {
34701
+ constructor(deps) {
34702
+ this.deps = deps;
34703
+ this.pop = null;
34704
+ this.openKey = null;
34705
+ /** Bumped per open/close — a lazy content resolving after its popup went away is dropped. */
34706
+ this.generation = 0;
34707
+ const doc = deps.plot.ownerDocument;
34708
+ injectStyles(CALLOUT_STYLE_ID, CALLOUT_CSS, doc);
34709
+ injectStyles(MARKS_STYLE_ID, MARKS_CSS, doc);
34710
+ this.anchor = doc.createElement("div");
34711
+ this.anchor.className = "vela-marks-anchor";
34712
+ Object.assign(this.anchor.style, { position: "absolute", pointerEvents: "none", left: "0", top: "0", width: "0", height: "0" });
34713
+ deps.plot.appendChild(this.anchor);
34714
+ }
34715
+ /** The cluster key the open popup belongs to, or null. */
34716
+ get key() {
34717
+ return this.openKey;
34718
+ }
34719
+ open(cluster, rect) {
34720
+ this.close();
34721
+ this.place(rect);
34722
+ const gen = ++this.generation;
34723
+ this.openKey = cluster.key;
34724
+ this.pop = new Popover({
34725
+ trigger: this.anchor,
34726
+ host: this.deps.host(),
34727
+ theme: this.deps.theme(),
34728
+ gap: 8,
34729
+ align: "center",
34730
+ // centered on the glyph
34731
+ fadeMs: 120,
34732
+ // a short, discreet fade in and out
34733
+ className: "vela-marks-pop",
34734
+ content: (body) => this.build(body, cluster, gen),
34735
+ onClose: () => {
34736
+ if (this.generation === gen) {
34737
+ this.openKey = null;
34738
+ this.pop = null;
34739
+ this.deps.onOpenChange?.(null);
34740
+ }
34741
+ }
34742
+ });
34743
+ this.pop.show();
34744
+ this.deps.onOpenChange?.(cluster.key);
34745
+ }
34746
+ /** Follow the anchor glyph after a repaint; `null` (glyph gone — hidden, scrolled off, marks replaced) closes. */
34747
+ track(rect) {
34748
+ if (!this.pop) return;
34749
+ if (!rect) {
34750
+ this.close();
34751
+ return;
34752
+ }
34753
+ this.place(rect);
34754
+ this.pop.reposition();
34755
+ }
34756
+ close() {
34757
+ const pop = this.pop;
34758
+ const wasOpen = this.openKey !== null;
34759
+ this.pop = null;
34760
+ this.openKey = null;
34761
+ this.generation++;
34762
+ pop?.destroy();
34763
+ if (wasOpen) this.deps.onOpenChange?.(null);
34764
+ }
34765
+ destroy() {
34766
+ this.close();
34767
+ this.anchor.remove();
34768
+ }
34769
+ place(rect) {
34770
+ Object.assign(this.anchor.style, {
34771
+ left: `${rect.x - rect.size / 2}px`,
34772
+ top: `${rect.y - rect.size / 2}px`,
34773
+ width: `${rect.size}px`,
34774
+ height: `${rect.size}px`
34775
+ });
34776
+ }
34777
+ build(body, cluster, gen) {
34778
+ const doc = body.ownerDocument;
34779
+ const root = doc.createElement("div");
34780
+ root.className = "vela-callout-panel vela-marks-panel";
34781
+ const pending = [];
34782
+ for (const mark of cluster.marks) {
34783
+ const section = doc.createElement("section");
34784
+ section.className = "vela-marks-section";
34785
+ if (mark.title) {
34786
+ const title = doc.createElement("div");
34787
+ title.className = "vela-callout-title";
34788
+ title.textContent = mark.title;
34789
+ section.appendChild(title);
34790
+ }
34791
+ const content = mark.content;
34792
+ if (typeof content === "function") {
34793
+ const slot = doc.createElement("div");
34794
+ slot.className = "vela-marks-loading";
34795
+ slot.textContent = "Loading\u2026";
34796
+ section.appendChild(slot);
34797
+ pending.push({ el: section, resolve: () => this.resolveLazy(content, slot, gen) });
34798
+ } else if (content !== void 0) {
34799
+ this.renderContent(section, content);
34800
+ }
34801
+ if (section.childElementCount > 0) root.appendChild(section);
34802
+ }
34803
+ body.appendChild(root);
34804
+ if (pending.length > 0) scheduleLazy(root, pending);
34805
+ }
34806
+ resolveLazy(source, slot, gen) {
34807
+ let result;
34808
+ try {
34809
+ result = Promise.resolve(source());
34810
+ } catch (err) {
34811
+ result = Promise.reject(err instanceof Error ? err : new Error(String(err)));
34812
+ }
34813
+ void result.then(
34814
+ (content) => {
34815
+ if (gen !== this.generation) return;
34816
+ const section = slot.parentElement;
34817
+ if (!section) return;
34818
+ slot.remove();
34819
+ this.renderContent(section, content);
34820
+ this.pop?.reposition();
34821
+ },
34822
+ () => {
34823
+ if (gen !== this.generation) return;
34824
+ slot.className = "vela-marks-error";
34825
+ slot.textContent = "Couldn\u2019t load this entry.";
34826
+ }
34827
+ );
34828
+ }
34829
+ renderContent(section, content) {
34830
+ const doc = section.ownerDocument;
34831
+ if (!content || typeof content !== "object") return;
34832
+ if ("text" in content) {
34833
+ const text = doc.createElement("div");
34834
+ text.className = "vela-callout-text";
34835
+ text.textContent = String(content.text);
34836
+ section.appendChild(text);
34837
+ return;
34838
+ }
34839
+ if ("html" in content) {
34840
+ const html = doc.createElement("div");
34841
+ html.className = "vela-marks-html";
34842
+ html.appendChild(sanitizeHtml(String(content.html), doc));
34843
+ section.appendChild(html);
34844
+ return;
34845
+ }
34846
+ if ("panel" in content && content.panel && Array.isArray(content.panel.items)) {
34847
+ let actions = null;
34848
+ for (const item of content.panel.items) {
34849
+ if (!item || typeof item !== "object") continue;
34850
+ if (item.type === "button") {
34851
+ if (!actions) {
34852
+ actions = doc.createElement("div");
34853
+ actions.className = "vela-callout-actions";
34854
+ section.appendChild(actions);
34855
+ }
34856
+ const btn2 = doc.createElement("button");
34857
+ btn2.type = "button";
34858
+ btn2.className = "vela-callout-btn" + (item.primary ? " vela-callout-btn-primary" : "");
34859
+ btn2.textContent = item.label;
34860
+ btn2.addEventListener("click", () => {
34861
+ item.run();
34862
+ if (item.close !== false) this.close();
34863
+ });
34864
+ actions.appendChild(btn2);
34865
+ continue;
34866
+ }
34867
+ actions = null;
34868
+ if (item.type === "text") {
34869
+ const text = doc.createElement("div");
34870
+ text.className = "vela-callout-text";
34871
+ text.textContent = item.text;
34872
+ section.appendChild(text);
34873
+ } else if (item.type === "field") {
34874
+ const row = doc.createElement("div");
34875
+ row.className = "vela-marks-field";
34876
+ const label = doc.createElement("span");
34877
+ label.className = "vela-marks-field-label";
34878
+ label.textContent = item.label;
34879
+ const value = doc.createElement("span");
34880
+ value.className = "vela-marks-field-value";
34881
+ value.textContent = item.value;
34882
+ row.append(label, value);
34883
+ section.appendChild(row);
34884
+ }
34885
+ }
34886
+ }
34887
+ }
34888
+ };
34889
+ function scheduleLazy(scroller, pending) {
34890
+ if (typeof IntersectionObserver === "undefined") {
34891
+ for (const p of pending) p.resolve();
34892
+ return;
34893
+ }
34894
+ const io = new IntersectionObserver(
34895
+ (entries) => {
34896
+ for (const e of entries) {
34897
+ if (!e.isIntersecting) continue;
34898
+ const p = pending.find((q) => q.el === e.target);
34899
+ if (!p) continue;
34900
+ io.unobserve(e.target);
34901
+ p.resolve();
34902
+ }
34903
+ },
34904
+ { root: scroller }
34905
+ );
34906
+ for (const p of pending) io.observe(p.el);
34907
+ }
34908
+
33742
34909
  // src/renderers/native/core/manualScale.ts
33743
34910
  function rescaleAround(start, factor) {
33744
34911
  if (start.log && start.min > 0 && start.max > start.min) {
@@ -34331,11 +35498,12 @@ var SCROLL_BTN_BOTTOM = TIME_AXIS_H + 14;
34331
35498
  var SCROLL_BTN_PROXIMITY_PX = 120;
34332
35499
  var MIN_VISIBLE_BARS = 2;
34333
35500
  var ZOOM_OUT_MARGIN_BARS = 6;
34334
- var ZOOM_TAU_MS = 70;
34335
- var SCALE_TAU_MS = 80;
34336
- var FLING_TAU_MS = 110;
34337
- var SCROLL_TO_TAU_MS = 130;
35501
+ var INTRO_MODEL_FADE_MS = 350;
34338
35502
  var FLING_STOP_PX = 0.02;
35503
+ var MARK_FLASH_MS = 220;
35504
+ function frameNow() {
35505
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
35506
+ }
34339
35507
  var PRICE_SCALE_K = 4e-3;
34340
35508
  var KEY_ZOOM_STEP = 0.2;
34341
35509
  var SEPARATOR_HIT_PX = 4;
@@ -34384,9 +35552,20 @@ var NativeRenderer = class {
34384
35552
  this.indicatorSlices = new IndicatorDrawingSlices();
34385
35553
  /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
34386
35554
  this.labelTooltip = null;
35555
+ /** The timeline-mark popup (a kit Popover anchored on a lane glyph); null before mount. */
35556
+ this.markPopover = null;
35557
+ /** How the fanned mark stack was opened: a hover folds when the pointer leaves, a tap only on a tap elsewhere. */
35558
+ this.marksExpandedBy = "hover";
35559
+ /** The rAF loop keeping the chrome repainting while a lane glyph pulses (hover) or flashes (click); null when idle. */
35560
+ this.markPulseRaf = null;
35561
+ this.markClickCbs = /* @__PURE__ */ new Set();
34387
35562
  this.crosshairLayer = new CrosshairRenderer();
34388
- /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
34389
- this.countdownTimer = null;
35563
+ /** The second pulse the countdown-to-bar-close chip ticks on: the host's (`setWallClock`)
35564
+ * when one is wired, else the renderer's own second-aligned clock. */
35565
+ this.hostClock = null;
35566
+ this.ownClock = null;
35567
+ /** Live subscription to the pulse while the countdown is on; null when off. */
35568
+ this.countdownUnsub = null;
34390
35569
  this.symbolPicker = null;
34391
35570
  /** Indicator titles (the legend rows) shown — held here so a remount re-applies it. */
34392
35571
  this.indicatorTitlesOn = true;
@@ -34404,19 +35583,25 @@ var NativeRenderer = class {
34404
35583
  /** Drawings layer self-serves Ctrl+Z/Y (see the `historyChords` feature). */
34405
35584
  this.historyChordsEnabled = true;
34406
35585
  this.liveRegion = null;
34407
- // ── animation state (eased zoom + inertial pan + live-bar glide) ──
34408
- this.animZoom = true;
34409
- this.animPan = true;
34410
- this.animLiveBarMs = 0;
34411
- // forming-bar OHLC glide time-constant; 0 = each tick snaps
34412
- this.animLiveBarOnMs = LIVE_BAR_EASE_DEFAULT_MS;
34413
- // the duration the settings-dialog on/off toggle restores (last non-zero value configured)
35586
+ // ── animation state: one ease time-constant per motion (0 = off), each remembering the
35587
+ // host's duration so the config's on/off switches restore it (see EaseSetting) ──
35588
+ this.animZoom = new EaseSetting(ZOOM_EASE_DEFAULT_MS);
35589
+ // wheel-zoom glide
35590
+ this.animPan = new EaseSetting(PAN_INERTIA_DEFAULT_MS);
35591
+ // inertial-pan velocity decay
35592
+ this.animScroll = new EaseSetting(SCROLL_EASE_DEFAULT_MS);
35593
+ // scroll-to-latest / panBy glide
35594
+ this.animAutoscale = new EaseSetting(AUTOSCALE_EASE_DEFAULT_MS);
35595
+ // autoscale glide during zoom/fling
35596
+ this.animLiveBar = new EaseSetting(LIVE_BAR_EASE_DEFAULT_MS, 0);
35597
+ // forming-bar OHLC glide; ships off
34414
35598
  // Brand default candles.
34415
35599
  this.candleUp = BULLISH;
34416
35600
  this.candleDown = BEARISH;
34417
35601
  // ── intro reveal (plays once when candles first appear) ──
34418
- this.introStyle = "settle";
34419
- // 'grow' | 'settle' | '' (off)
35602
+ this.intro = { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
35603
+ this.introOnStyle = "settle";
35604
+ // the style the config's on/off switch restores
34420
35605
  this.introPlayed = false;
34421
35606
  this.introRaf = null;
34422
35607
  /** The load affordance (three pulsing dots) — up while the host reports a bar load in
@@ -34535,7 +35720,7 @@ var NativeRenderer = class {
34535
35720
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
34536
35721
  this.priceStyleCbs = /* @__PURE__ */ new Set();
34537
35722
  this.name = "native";
34538
- this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "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", "indicatorTitles", "indicatorValues"];
35723
+ 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"];
34539
35724
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
34540
35725
  * so moving onto the button doesn't count as leaving). */
34541
35726
  this.onScrollProximityMove = (e) => {
@@ -34567,9 +35752,12 @@ var NativeRenderer = class {
34567
35752
  this.scene.showPriceLine = opts.currentPriceLine;
34568
35753
  this.scene.logScale = opts.logScale;
34569
35754
  this.backendMode = opts.nativeBackend;
34570
- this.animZoom = opts.animZoom;
34571
- this.animPan = opts.animPan;
34572
- this.setLiveBarEase(resolveLiveBarEaseMs(opts.animLiveBar));
35755
+ this.animZoom.set(opts.animZoom);
35756
+ this.animPan.set(opts.animPan);
35757
+ this.animScroll.set(opts.animScroll);
35758
+ this.animAutoscale.set(opts.animAutoscale);
35759
+ this.animLiveBar.set(opts.animLiveBar);
35760
+ this.setIntro(opts.animIntro);
34573
35761
  this.glowAmount = opts.glow;
34574
35762
  this.candleUp = opts.upColor;
34575
35763
  this.candleDown = opts.downColor;
@@ -34613,20 +35801,29 @@ var NativeRenderer = class {
34613
35801
  if (this.backend && "glow" in this.backend) this.backend.glow = this.glowAmount;
34614
35802
  break;
34615
35803
  case "animZoom":
34616
- this.animZoom = Boolean(value);
35804
+ this.animZoom.set(resolveEaseMs(value, ZOOM_EASE_DEFAULT_MS));
34617
35805
  return;
34618
35806
  // affects the next interaction only — nothing to repaint
34619
- case "animPan":
34620
- this.animPan = Boolean(value);
35807
+ case "animPan": {
35808
+ const ms = resolveEaseMs(value, PAN_INERTIA_DEFAULT_MS);
35809
+ this.animPan.set(ms);
35810
+ this.animScroll.toggle(ms > 0);
35811
+ return;
35812
+ }
35813
+ case "animScroll":
35814
+ this.animScroll.set(resolveEaseMs(value, SCROLL_EASE_DEFAULT_MS));
34621
35815
  return;
35816
+ case "animAutoscale":
35817
+ this.animAutoscale.set(resolveEaseMs(value, AUTOSCALE_EASE_DEFAULT_MS));
35818
+ return;
35819
+ // a glide in flight finishes at the new rate (or snaps at 0)
34622
35820
  case "animLiveBar":
34623
- this.setLiveBarEase(resolveLiveBarEaseMs(value));
35821
+ this.animLiveBar.set(resolveLiveBarEaseMs(value));
34624
35822
  return;
34625
35823
  // affects the next tick only; a glide in flight finishes at the new rate (or snaps at 0)
34626
35824
  case "intro": {
34627
- const s = value === false || value === "none" || value === "off" || value == null ? "" : String(value);
34628
- this.introStyle = s;
34629
- if (s) this.playIntro(s);
35825
+ this.setIntro(resolveIntro(value));
35826
+ if (this.intro.style) this.playIntro();
34630
35827
  return;
34631
35828
  }
34632
35829
  case "zoomAnchor":
@@ -34698,6 +35895,10 @@ var NativeRenderer = class {
34698
35895
  case "tradeMarkers":
34699
35896
  this.scene.tradeMarkers = mergeTradeMarkersState(this.scene.tradeMarkers, value);
34700
35897
  break;
35898
+ case "marks":
35899
+ this.scene.marks = mergeMarksState(this.scene.marks, value);
35900
+ this.markPopover?.close();
35901
+ break;
34701
35902
  case "keyboard":
34702
35903
  this.setKeyboardEnabled(Boolean(value));
34703
35904
  return;
@@ -34756,13 +35957,17 @@ var NativeRenderer = class {
34756
35957
  case "glow":
34757
35958
  return this.glowAmount;
34758
35959
  case "animZoom":
34759
- return this.animZoom;
35960
+ return this.animZoom.tau;
34760
35961
  case "animPan":
34761
- return this.animPan;
35962
+ return this.animPan.tau;
35963
+ case "animScroll":
35964
+ return this.animScroll.tau;
35965
+ case "animAutoscale":
35966
+ return this.animAutoscale.tau;
34762
35967
  case "animLiveBar":
34763
- return this.animLiveBarMs;
35968
+ return this.animLiveBar.tau;
34764
35969
  case "intro":
34765
- return this.introStyle;
35970
+ return this.intro.style;
34766
35971
  case "zoomAnchor":
34767
35972
  return this.zoomAnchorMode;
34768
35973
  case "axisDrag":
@@ -34805,6 +36010,8 @@ var NativeRenderer = class {
34805
36010
  }
34806
36011
  case "tradeMarkers":
34807
36012
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
36013
+ case "marks":
36014
+ return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
34808
36015
  case "keyboard":
34809
36016
  return this.keyboardEnabled;
34810
36017
  case "historyChords":
@@ -34930,7 +36137,13 @@ var NativeRenderer = class {
34930
36137
  currentPriceLine: this.scene.showPriceLine,
34931
36138
  priceLabel: this.scene.showPriceLabel,
34932
36139
  countdown: this.scene.showCountdown,
34933
- animateLastPrice: this.animLiveBarMs > 0
36140
+ animateLastPrice: this.animLiveBar.on
36141
+ },
36142
+ animations: {
36143
+ zoom: this.animZoom.on,
36144
+ pan: this.animPan.on,
36145
+ autoscale: this.animAutoscale.on,
36146
+ intro: this.intro.style !== false
34934
36147
  },
34935
36148
  panes: { separatorColor: s.separatorColor ?? t.borderColor },
34936
36149
  trades: {
@@ -34942,6 +36155,7 @@ var NativeRenderer = class {
34942
36155
  exitColor: this.scene.tradeMarkers.colors.exit
34943
36156
  },
34944
36157
  timeScale: { timezone: this.scene.timezone },
36158
+ marks: { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } },
34945
36159
  candles: {
34946
36160
  upColor: this.candleUp,
34947
36161
  downColor: this.candleDown,
@@ -35037,7 +36251,12 @@ var NativeRenderer = class {
35037
36251
  this.scene.showPriceLabel = next.priceScale.priceLabel;
35038
36252
  this.scene.showCountdown = next.priceScale.countdown;
35039
36253
  this.syncCountdownTimer();
35040
- this.animLiveBarMs = next.priceScale.animateLastPrice ? this.animLiveBarOnMs : 0;
36254
+ this.animLiveBar.toggle(next.priceScale.animateLastPrice);
36255
+ this.animZoom.toggle(next.animations.zoom);
36256
+ this.animPan.toggle(next.animations.pan);
36257
+ this.animScroll.toggle(next.animations.pan);
36258
+ this.animAutoscale.toggle(next.animations.autoscale);
36259
+ this.intro = { style: next.animations.intro ? this.introOnStyle : false, duration: this.intro.duration || INTRO_DURATION_DEFAULT_MS };
35041
36260
  s.separatorColor = keepInherit(s.separatorColor, next.panes.separatorColor, prevTheme.borderColor);
35042
36261
  this.scene.tradeMarkers = {
35043
36262
  visible: next.trades.visible,
@@ -35046,6 +36265,7 @@ var NativeRenderer = class {
35046
36265
  colors: { long: next.trades.longColor, short: next.trades.shortColor, exit: next.trades.exitColor }
35047
36266
  };
35048
36267
  this.scene.timezone = next.timeScale.timezone;
36268
+ this.scene.marks = { visible: next.marks.visible, groups: { ...next.marks.groups } };
35049
36269
  this.candleUp = next.candles.upColor;
35050
36270
  this.candleDown = next.candles.downColor;
35051
36271
  s.candle = {
@@ -35178,6 +36398,7 @@ var NativeRenderer = class {
35178
36398
  }
35179
36399
  this.settingsDialog.setTheme(this.theme);
35180
36400
  this.settingsDialog.setHostSections(this.hostSettingsSections);
36401
+ this.settingsDialog.setMarkGroups(this.markGroupsInUse(), (id) => markGroupVisible(this.scene.marks, id, this.scene.markGroups));
35181
36402
  this.settingsDialog.setHiddenSettings(this.hiddenSettings);
35182
36403
  this.syncThemeControl();
35183
36404
  this.settingsDialog.toggle(
@@ -35268,10 +36489,10 @@ var NativeRenderer = class {
35268
36489
  this.glideRightOffset(ZOOM_OUT_MARGIN_BARS);
35269
36490
  }
35270
36491
  /** Ease rightOffset to `target` at constant zoom (see animTick's scroll glide);
35271
- * instant when pan animation is off. Shared by scroll-to-latest and panBy. */
36492
+ * instant when the scroll glide is off. Shared by scroll-to-latest and panBy. */
35272
36493
  glideRightOffset(target) {
35273
36494
  const vp = this.coords.getViewport();
35274
- if (!this.animPan) {
36495
+ if (!this.animScroll.on) {
35275
36496
  this.applyViewport({ barSpacing: vp.barSpacing, rightOffset: target });
35276
36497
  return;
35277
36498
  }
@@ -35336,20 +36557,21 @@ var NativeRenderer = class {
35336
36557
  * full size, eased, with a left→right stagger so the chart draws itself; `settle`
35337
36558
  * adds an ease-out-back overshoot. Autoscale stays on the real bars so the frame
35338
36559
  * never moves. Re-callable, so styles can be compared live from the console.
36560
+ * Style and sweep duration come from the resolved `intro` setting.
35339
36561
  */
35340
- playIntro(style) {
36562
+ playIntro() {
35341
36563
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
35342
36564
  this.introRaf = null;
36565
+ const { style, duration } = this.intro;
35343
36566
  const real = this.bars;
35344
36567
  const n = real.length;
35345
- if (n === 0) return;
36568
+ if (n === 0 || !style) return;
35346
36569
  this.computeScales();
35347
36570
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
35348
36571
  this.modelAlpha = 0;
35349
- const DURATION = 650;
35350
36572
  const start = performance.now();
35351
36573
  const step = (now) => {
35352
- const p = Math.min(1, (now - start) / DURATION);
36574
+ const p = Math.min(1, (now - start) / duration);
35353
36575
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
35354
36576
  this.paintData();
35355
36577
  if (p < 1) {
@@ -35363,10 +36585,10 @@ var NativeRenderer = class {
35363
36585
  }
35364
36586
  /** After the candle reveal, fade the indicator models (series/fills/…) from hidden to full. */
35365
36587
  fadeInModels() {
35366
- const FADE = 350;
36588
+ const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
35367
36589
  const start = performance.now();
35368
36590
  const step = (now) => {
35369
- this.modelAlpha = Math.min(1, (now - start) / FADE);
36591
+ this.modelAlpha = Math.min(1, (now - start) / fade);
35370
36592
  this.paintData();
35371
36593
  if (this.modelAlpha < 1) {
35372
36594
  this.introRaf = requestAnimationFrame(step);
@@ -35481,7 +36703,8 @@ var NativeRenderer = class {
35481
36703
  zoomTo: (target, anchorLogical, anchorX) => this.zoomTo(target, anchorLogical, anchorX),
35482
36704
  fling: (v) => this.fling(v),
35483
36705
  onPointerMove: (x, y) => this.handlePointerMove(x, y),
35484
- onClick: (x) => {
36706
+ onClick: (x, y) => {
36707
+ if (this.handleMarkClick(x, y)) return;
35485
36708
  this.userDrawings?.deselect();
35486
36709
  this.handleClick(x);
35487
36710
  },
@@ -35510,7 +36733,7 @@ var NativeRenderer = class {
35510
36733
  drawingsPointerDown: (x, y, snap, shift2, mod) => this.userDrawings?.pointerDown(x, y, snap, shift2, mod),
35511
36734
  drawingsPointerMove: (x, y, snap, shift2, mod) => this.userDrawings?.pointerMove(x, y, snap, shift2, mod),
35512
36735
  drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
35513
- drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
36736
+ drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? (this.chrome.markGlyphAt(x, y) ? "pointer" : null),
35514
36737
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
35515
36738
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
35516
36739
  });
@@ -35526,8 +36749,18 @@ var NativeRenderer = class {
35526
36749
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
35527
36750
  this.labelTooltip = new LabelTooltip(this.plot, {
35528
36751
  theme: () => this.chromeTheme(),
35529
- lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
36752
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y) ?? this.chrome.markTooltipAt(x, y, this.markGroupsInUse())
36753
+ });
36754
+ this.markPopover = new MarkPopover({
36755
+ plot: this.plot,
36756
+ host: () => this.dialogHost ?? this.plot,
36757
+ theme: () => this.chromeTheme(),
36758
+ onOpenChange: (key) => {
36759
+ this.scene.marksActiveKey = key;
36760
+ this.scheduler?.invalidate(2 /* Chrome */);
36761
+ }
35530
36762
  });
36763
+ this.chrome.setMarkIconReady(() => this.scheduler?.invalidate(2 /* Chrome */));
35531
36764
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
35532
36765
  projector: () => this.drawingProjector(),
35533
36766
  dpr: () => this.coords.dpr,
@@ -35742,29 +36975,40 @@ var NativeRenderer = class {
35742
36975
  resize() {
35743
36976
  this.syncSize();
35744
36977
  }
35745
- /** Run a 1 Hz repaint pump while the countdown chip is on (so it ticks); stop it otherwise.
35746
- * Chrome tier: only the chip's wall-clock text moves — an idle chart must not recompute
36978
+ /** Drive the countdown chip from the host's second pulse (`null` the renderer's own). */
36979
+ setWallClock(clock) {
36980
+ if (clock === this.hostClock) return;
36981
+ this.hostClock = clock;
36982
+ if (this.countdownUnsub != null) {
36983
+ this.countdownUnsub();
36984
+ this.countdownUnsub = null;
36985
+ }
36986
+ this.syncCountdownTimer();
36987
+ }
36988
+ /** Subscribe to the second pulse while the countdown chip is on (so it ticks); unsubscribe
36989
+ * otherwise. Chrome tier: only the chip's text moves — an idle chart must not recompute
35747
36990
  * scales or repaint the geometry/volume/VPVR/SDK layers once a second (that cost
35748
36991
  * multiplies by the cell count in a multi-chart workspace). */
35749
36992
  syncCountdownTimer() {
35750
36993
  if (this.scene.showCountdown) {
35751
- if (this.countdownTimer == null) {
35752
- this.countdownTimer = setInterval(() => {
36994
+ if (this.countdownUnsub == null) {
36995
+ const clock = this.hostClock ?? (this.ownClock ?? (this.ownClock = new SecondClock()));
36996
+ this.countdownUnsub = clock.onTick(() => {
35753
36997
  if (this.scene.showCountdown && this.scene.bars.length > 0) this.scheduler?.invalidate(2 /* Chrome */);
35754
- }, 1e3);
36998
+ });
35755
36999
  }
35756
- } else if (this.countdownTimer != null) {
35757
- clearInterval(this.countdownTimer);
35758
- this.countdownTimer = null;
37000
+ } else if (this.countdownUnsub != null) {
37001
+ this.countdownUnsub();
37002
+ this.countdownUnsub = null;
35759
37003
  }
35760
37004
  }
35761
37005
  destroy() {
35762
37006
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
35763
37007
  this.loadingEl?.remove();
35764
37008
  this.loadingEl = null;
35765
- if (this.countdownTimer != null) {
35766
- clearInterval(this.countdownTimer);
35767
- this.countdownTimer = null;
37009
+ if (this.countdownUnsub != null) {
37010
+ this.countdownUnsub();
37011
+ this.countdownUnsub = null;
35768
37012
  }
35769
37013
  this.scheduler?.destroy();
35770
37014
  this.animator?.stop();
@@ -35789,6 +37033,11 @@ var NativeRenderer = class {
35789
37033
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
35790
37034
  this.labelTooltip?.destroy();
35791
37035
  this.labelTooltip = null;
37036
+ this.markPopover?.destroy();
37037
+ this.markPopover = null;
37038
+ this.chrome.setMarkIconReady(null);
37039
+ if (this.markPulseRaf !== null) cancelAnimationFrame(this.markPulseRaf);
37040
+ this.markPulseRaf = null;
35792
37041
  this.scrollButton?.remove();
35793
37042
  this.scrollButton = null;
35794
37043
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -35847,8 +37096,8 @@ var NativeRenderer = class {
35847
37096
  }
35848
37097
  if (!this.introPlayed && this.bars.length > 0) {
35849
37098
  this.introPlayed = true;
35850
- if (this.introStyle) {
35851
- this.playIntro(this.introStyle);
37099
+ if (this.intro.style) {
37100
+ this.playIntro();
35852
37101
  return;
35853
37102
  }
35854
37103
  }
@@ -35859,7 +37108,7 @@ var NativeRenderer = class {
35859
37108
  const last = this.bars[n - 1];
35860
37109
  if (last && bar.time === last.time) {
35861
37110
  this.bars[n - 1] = bar;
35862
- if (this.animLiveBarMs <= 0 || this.liveEaseTime !== bar.time) {
37111
+ if (!this.animLiveBar.on || this.liveEaseTime !== bar.time) {
35863
37112
  this.syncLiveEase(bar);
35864
37113
  } else {
35865
37114
  this.animator.start();
@@ -35875,11 +37124,11 @@ var NativeRenderer = class {
35875
37124
  this.scene.bars = this.bars;
35876
37125
  this.scheduler.invalidate(4 /* Full */);
35877
37126
  }
35878
- /** Set the live-bar glide duration (0 = off). A non-zero value is also remembered as
35879
- * what the config's on/off toggle (`priceScale.animateLastPrice`) switches back on to. */
35880
- setLiveBarEase(ms) {
35881
- this.animLiveBarMs = ms;
35882
- if (ms > 0) this.animLiveBarOnMs = ms;
37127
+ /** Set the reveal (style + duration). A non-off style is also remembered as what the
37128
+ * config's on/off toggle (`animations.intro`) switches back on to. */
37129
+ setIntro(next) {
37130
+ this.intro = next;
37131
+ if (next.style) this.introOnStyle = next.style;
35883
37132
  }
35884
37133
  /** Snap the eased forming-bar state to `bar` — no glide (a fresh bar or the first tick of one). */
35885
37134
  syncLiveEase(bar) {
@@ -35893,7 +37142,7 @@ var NativeRenderer = class {
35893
37142
  const target = this.bars[this.bars.length - 1];
35894
37143
  if (!target || this.liveEaseTime !== target.time) return false;
35895
37144
  const eps = Math.max(1e-9, Math.abs(target.close) * 1e-6);
35896
- const tau = this.animLiveBarMs;
37145
+ const tau = this.animLiveBar.tau;
35897
37146
  const nh = easeToward(this.liveEaseHigh, target.high, dtMs, tau);
35898
37147
  const nl = easeToward(this.liveEaseLow, target.low, dtMs, tau);
35899
37148
  const nc = easeToward(this.liveEaseClose, target.close, dtMs, tau);
@@ -36033,10 +37282,12 @@ var NativeRenderer = class {
36033
37282
  this.refreshAnchorOffset(model);
36034
37283
  if (model.native && this.extLayers.some((l) => l.def.id === model.native.type)) this.scene.assignIndicatorZTop(model.id);
36035
37284
  else this.scene.assignIndicatorZ(model.id);
36036
- this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
36037
- native: !!model.native,
36038
- ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
36039
- });
37285
+ if (model.legend !== false) {
37286
+ this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
37287
+ native: !!model.native,
37288
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
37289
+ });
37290
+ }
36040
37291
  if (model.native?.type === "volume") {
36041
37292
  this.volumeActive = true;
36042
37293
  this.volumeHidden = false;
@@ -36183,7 +37434,7 @@ var NativeRenderer = class {
36183
37434
  this.settingsDialog?.setHiddenSettings(this.hiddenSettings);
36184
37435
  }
36185
37436
  listSettingsIds() {
36186
- return settingsIdCatalog(this.hostSettingsSections);
37437
+ return settingsIdCatalog(this.hostSettingsSections, this.markGroupsInUse());
36187
37438
  }
36188
37439
  onChartTypeSettingsChange(cb) {
36189
37440
  this.chartTypeSettingsCbs.add(cb);
@@ -36209,6 +37460,22 @@ var NativeRenderer = class {
36209
37460
  this.axisLongPressCbs.add(cb);
36210
37461
  return () => this.axisLongPressCbs.delete(cb);
36211
37462
  }
37463
+ // ── timeline marks (the `chart.marks` model; see the port) ──
37464
+ setTimelineMarks(marks, groups) {
37465
+ this.scene.timelineMarks = marks;
37466
+ this.scene.markGroups = groups;
37467
+ this.scene.marksExpandedStack = null;
37468
+ this.markPopover?.close();
37469
+ this.scheduler?.invalidate(2 /* Chrome */);
37470
+ }
37471
+ onMarkClick(cb) {
37472
+ this.markClickCbs.add(cb);
37473
+ return () => this.markClickCbs.delete(cb);
37474
+ }
37475
+ /** Every group the lane knows: the defined ones, then those marks name without a definition. */
37476
+ markGroupsInUse() {
37477
+ return effectiveMarkGroups(this.scene.timelineMarks, this.scene.markGroups);
37478
+ }
36212
37479
  onViewportChange(cb) {
36213
37480
  this.viewportCbs.add(cb);
36214
37481
  return () => this.viewportCbs.delete(cb);
@@ -36262,7 +37529,7 @@ var NativeRenderer = class {
36262
37529
  this.zoomAnchorX = anchorX;
36263
37530
  this.panVelocity = 0;
36264
37531
  this.scrollTargetRO = null;
36265
- if (!this.animZoom) {
37532
+ if (!this.animZoom.on) {
36266
37533
  const v = this.clampViewport(barSpacing, this.anchoredRightOffset(barSpacing));
36267
37534
  this.coords.setViewport(v);
36268
37535
  this.targetBarSpacing = v.barSpacing;
@@ -36275,7 +37542,7 @@ var NativeRenderer = class {
36275
37542
  }
36276
37543
  /** Inertial pan: continue with a rightOffset velocity (logical units / ms) that decays. */
36277
37544
  fling(velocity) {
36278
- if (!this.animPan) return;
37545
+ if (!this.animPan.on) return;
36279
37546
  this.scrollTargetRO = null;
36280
37547
  this.panVelocity = velocity;
36281
37548
  this.animator.start();
@@ -36315,7 +37582,7 @@ var NativeRenderer = class {
36315
37582
  let active = false;
36316
37583
  const tbs = this.targetBarSpacing;
36317
37584
  if (Math.abs(barSpacing - tbs) > tbs * 1e-3) {
36318
- barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, ZOOM_TAU_MS));
37585
+ barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, this.animZoom.tau));
36319
37586
  rightOffset = this.anchoredRightOffset(barSpacing);
36320
37587
  active = true;
36321
37588
  } else if (barSpacing !== tbs) {
@@ -36325,13 +37592,14 @@ var NativeRenderer = class {
36325
37592
  const stopVel = FLING_STOP_PX / Math.max(1e-6, barSpacing * this.coords.spacingScale);
36326
37593
  if (Math.abs(this.panVelocity) > stopVel) {
36327
37594
  rightOffset += this.panVelocity * dtMs;
36328
- this.panVelocity *= Math.exp(-dtMs / FLING_TAU_MS);
37595
+ const tau = this.animPan.tau;
37596
+ this.panVelocity = tau > 0 ? this.panVelocity * Math.exp(-dtMs / tau) : 0;
36329
37597
  if (Math.abs(this.panVelocity) <= stopVel) this.panVelocity = 0;
36330
37598
  else active = true;
36331
37599
  }
36332
37600
  if (this.scrollTargetRO != null) {
36333
37601
  const target = this.scrollTargetRO;
36334
- const next = easeToward(rightOffset, target, dtMs, SCROLL_TO_TAU_MS);
37602
+ const next = easeToward(rightOffset, target, dtMs, this.animScroll.tau);
36335
37603
  if (Math.abs(next - target) < 1e-3) {
36336
37604
  rightOffset = target;
36337
37605
  this.scrollTargetRO = null;
@@ -36359,6 +37627,7 @@ var NativeRenderer = class {
36359
37627
  * through Math.log), not a non-linear jump. */
36360
37628
  easeScales(dtMs) {
36361
37629
  let moving = false;
37630
+ const tau = this.animAutoscale.tau;
36362
37631
  for (const pane of this.scene.panes.values()) {
36363
37632
  const t = pane.scaleTarget;
36364
37633
  const s = pane.scale;
@@ -36366,8 +37635,8 @@ var NativeRenderer = class {
36366
37635
  const lt0 = Math.log(t.min);
36367
37636
  const lt1 = Math.log(t.max);
36368
37637
  const lspan = Math.max(1e-9, Math.abs(lt1 - lt0));
36369
- const n0 = easeToward(Math.log(s.min), lt0, dtMs, SCALE_TAU_MS);
36370
- const n1 = easeToward(Math.log(s.max), lt1, dtMs, SCALE_TAU_MS);
37638
+ const n0 = easeToward(Math.log(s.min), lt0, dtMs, tau);
37639
+ const n1 = easeToward(Math.log(s.max), lt1, dtMs, tau);
36371
37640
  if (Math.abs(n0 - lt0) <= lspan * 1e-3 && Math.abs(n1 - lt1) <= lspan * 1e-3) {
36372
37641
  pane.scale = { min: t.min, max: t.max, log: true };
36373
37642
  } else {
@@ -36377,8 +37646,8 @@ var NativeRenderer = class {
36377
37646
  continue;
36378
37647
  }
36379
37648
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
36380
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
36381
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
37649
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
37650
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
36382
37651
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
36383
37652
  nmin = t.min;
36384
37653
  nmax = t.max;
@@ -36391,8 +37660,8 @@ var NativeRenderer = class {
36391
37660
  const t = sl.scaleTarget;
36392
37661
  const s = sl.scale;
36393
37662
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
36394
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
36395
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
37663
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
37664
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
36396
37665
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
36397
37666
  nmin = t.min;
36398
37667
  nmax = t.max;
@@ -36413,6 +37682,8 @@ var NativeRenderer = class {
36413
37682
  this.scene.crosshair = null;
36414
37683
  this.hoverSeparatorY = null;
36415
37684
  this.lastPointer = null;
37685
+ if (this.marksExpandedBy === "hover") this.setMarksExpanded(null);
37686
+ this.setMarkHover(null);
36416
37687
  this.scheduler.invalidate(1 /* Cursor */);
36417
37688
  this.hoverLogical = null;
36418
37689
  const empty = { time: null, price: null, paneKind: null, values: /* @__PURE__ */ new Map(), ohlc: null };
@@ -36424,6 +37695,8 @@ var NativeRenderer = class {
36424
37695
  this.lastPointer = inData ? { x, y } : null;
36425
37696
  this.hoverSeparatorY = x >= 0 && y >= 0 && y <= this.coords.height ? this.separatorHoverY(y) : null;
36426
37697
  this.scheduler.invalidate(1 /* Cursor */);
37698
+ this.setMarksExpanded(inData ? this.chrome.markStackAt(x, y) : null);
37699
+ this.setMarkHover(inData ? this.chrome.markGlyphAt(x, y)?.cluster.key ?? null : null);
36427
37700
  const logical = Math.round(this.coords.xToLogical(x));
36428
37701
  const onBar = logical >= 0 && logical < this.coords.barCount;
36429
37702
  const time = onBar ? this.coords.logicalToTime(logical) : null;
@@ -36453,6 +37726,76 @@ var NativeRenderer = class {
36453
37726
  const onBar = logical >= 0 && logical < this.coords.barCount;
36454
37727
  for (const cb of this.clickCbs) cb({ time: onBar ? this.coords.logicalToTime(logical) : null, price: null });
36455
37728
  }
37729
+ /**
37730
+ * Fan out (or collapse, with null) a multi-group mark stack; repaints the chrome tier when
37731
+ * it changes. A stack whose glyph holds the open popup stays fanned — the pointer leaving
37732
+ * the plot for the popup must not bury the glyph under the deck (which would close it).
37733
+ */
37734
+ setMarksExpanded(stack, by = "hover") {
37735
+ if (this.scene.marksExpandedStack === stack) return;
37736
+ if (stack === null && this.markPopover?.key) {
37737
+ const open2 = this.chrome.markGlyphByKey(this.markPopover.key);
37738
+ if (open2 && open2.stack === this.scene.marksExpandedStack) return;
37739
+ }
37740
+ this.scene.marksExpandedStack = stack;
37741
+ this.marksExpandedBy = by;
37742
+ this.scheduler?.invalidate(2 /* Chrome */);
37743
+ }
37744
+ /**
37745
+ * A click on the mark lane: a collapsed deck fans out (the touch path — a mouse already
37746
+ * fanned it by hovering), a glyph reports its cluster (`onMarkClick`) and opens the popup
37747
+ * when any of its marks carries content. True when the click landed on the lane.
37748
+ */
37749
+ handleMarkClick(x, y) {
37750
+ const glyph = this.chrome.markGlyphAt(x, y);
37751
+ if (!glyph) {
37752
+ if (this.scene.marksExpandedStack !== null && this.chrome.markStackAt(x, y) === null) this.setMarksExpanded(null);
37753
+ return false;
37754
+ }
37755
+ if (glyph.decked) {
37756
+ this.setMarksExpanded(glyph.stack, "tap");
37757
+ return true;
37758
+ }
37759
+ const marks = glyph.cluster.marks;
37760
+ const first = marks[0];
37761
+ const event = { id: first.id, ids: marks.map((m) => m.id), time: first.time, ...glyph.cluster.group !== void 0 ? { group: glyph.cluster.group } : {} };
37762
+ for (const cb of this.markClickCbs) cb(event);
37763
+ if (marks.some((m) => m.content !== void 0)) {
37764
+ this.markPopover?.open(glyph.cluster, { x: glyph.x, y: glyph.y, size: glyph.size });
37765
+ } else {
37766
+ this.scene.marksFlash = { key: glyph.cluster.key, until: frameNow() + MARK_FLASH_MS };
37767
+ this.syncMarkPulse();
37768
+ }
37769
+ return true;
37770
+ }
37771
+ /** After a chrome frame: keep the open mark popup on its glyph, or close it once the glyph is gone. */
37772
+ trackMarkPopover() {
37773
+ const key = this.markPopover?.key;
37774
+ if (!key) return;
37775
+ const g = this.chrome.markGlyphByKey(key);
37776
+ this.markPopover.track(g && !(g.decked && g.depth !== 0) ? { x: g.x, y: g.y, size: g.size } : null);
37777
+ }
37778
+ /** The lane glyph under the pointer — it swells once as the pointer lands (a rAF-driven chrome repaint for the pulse's duration). */
37779
+ setMarkHover(key) {
37780
+ if (this.scene.marksHoverKey === key) return;
37781
+ this.scene.marksHoverKey = key;
37782
+ this.scene.marksHoverSince = frameNow();
37783
+ this.scheduler?.invalidate(2 /* Chrome */);
37784
+ this.syncMarkPulse();
37785
+ }
37786
+ /** Run a chrome-tier repaint loop while a glyph's hover pulse or click flash plays; it stops itself once both are over. */
37787
+ syncMarkPulse() {
37788
+ if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
37789
+ const tick = () => {
37790
+ this.markPulseRaf = null;
37791
+ const now = frameNow();
37792
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
37793
+ this.scheduler?.invalidate(2 /* Chrome */);
37794
+ const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
37795
+ if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
37796
+ };
37797
+ this.markPulseRaf = requestAnimationFrame(tick);
37798
+ }
36456
37799
  paneAtY(y) {
36457
37800
  return this.paneNodeAtY(y);
36458
37801
  }
@@ -36844,6 +38187,7 @@ var NativeRenderer = class {
36844
38187
  } else if (repaintsChrome(level) && this.paintedData) {
36845
38188
  this.chrome.prepare(this.scene, this.coords, this.theme);
36846
38189
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
38190
+ this.trackMarkPopover();
36847
38191
  }
36848
38192
  this.crosshairLayer.render(this.scene, this.coords, this.theme, this.hoverSeparatorY, this.externalCrossPx());
36849
38193
  if (!repaintsData(level) && this.paintedData) this.repaintCursorLayers();
@@ -36859,7 +38203,7 @@ var NativeRenderer = class {
36859
38203
  const lp = this.layerPane(l.def.id) ?? pane;
36860
38204
  if (lp.collapsed) continue;
36861
38205
  l.instance.render(this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs));
36862
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
38206
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
36863
38207
  }
36864
38208
  }
36865
38209
  /** Blank one SDK layer canvas (a collapsed host pane suppresses the layer's painting). */
@@ -36924,7 +38268,7 @@ var NativeRenderer = class {
36924
38268
  const args = this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs);
36925
38269
  l.instance.render(args);
36926
38270
  if (lp === pane) folded = foldBaseModulation(folded, l.instance.modulateBase?.(args) ?? null);
36927
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
38271
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
36928
38272
  }
36929
38273
  if (folded) {
36930
38274
  if (folded.candleBodyScale != null) this.backend.candleBodyScale = clamp012(folded.candleBodyScale) || 0.01;
@@ -36943,6 +38287,7 @@ var NativeRenderer = class {
36943
38287
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
36944
38288
  this.backend.render(this.scene, this.coords, this.theme);
36945
38289
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
38290
+ this.trackMarkPopover();
36946
38291
  this.userDrawings?.render();
36947
38292
  if (easeLive && liveActual) this.bars[li] = liveActual;
36948
38293
  this.paintedData = true;
@@ -37455,7 +38800,7 @@ var NativeRenderer = class {
37455
38800
  const map2 = /* @__PURE__ */ new Map();
37456
38801
  for (const pane of this.scene.panes.values()) {
37457
38802
  if (!pane.collapsed) continue;
37458
- const models = this.scene.orderedIndicatorsForPane(pane.id);
38803
+ const models = this.scene.orderedIndicatorsForPane(pane.id).filter((m) => m.legend !== false);
37459
38804
  const merged = new Set(this.scene.ownScaleIndicatorsForPane(pane.id).map((m) => m.id));
37460
38805
  const master = models.find((m) => !merged.has(m.id)) ?? models[0];
37461
38806
  map2.set(pane.id, master?.id ?? null);
@@ -37704,6 +39049,8 @@ function volumeLayerData(inputs) {
37704
39049
  }
37705
39050
  var VolumeIndicator = class {
37706
39051
  constructor() {
39052
+ /** Null until start() — pre-start setInputs/resume must record without pushing. */
39053
+ this.ctx = null;
37707
39054
  this.inputs = {};
37708
39055
  }
37709
39056
  start(ctx, inputs) {
@@ -37720,13 +39067,13 @@ var VolumeIndicator = class {
37720
39067
  }
37721
39068
  setInputs(inputs) {
37722
39069
  this.inputs = inputs;
37723
- this.ctx.pushData(volumeLayerData(inputs));
39070
+ this.ctx?.pushData(volumeLayerData(inputs));
37724
39071
  }
37725
39072
  /** Hiding is a renderer-layer flag (set via `setIndicatorVisible`); no resources to free. */
37726
39073
  suspend() {
37727
39074
  }
37728
39075
  resume() {
37729
- this.ctx.pushData(volumeLayerData(this.inputs));
39076
+ this.ctx?.pushData(volumeLayerData(this.inputs));
37730
39077
  }
37731
39078
  stop() {
37732
39079
  }
@@ -37772,6 +39119,8 @@ function vpvrLayerData(inputs) {
37772
39119
  }
37773
39120
  var VpvrIndicator = class {
37774
39121
  constructor() {
39122
+ /** Null until start() — pre-start setInputs/resume must record without pushing. */
39123
+ this.ctx = null;
37775
39124
  this.inputs = {};
37776
39125
  }
37777
39126
  start(ctx, inputs) {
@@ -37788,13 +39137,13 @@ var VpvrIndicator = class {
37788
39137
  }
37789
39138
  setInputs(inputs) {
37790
39139
  this.inputs = inputs;
37791
- this.ctx.pushData(vpvrLayerData(inputs));
39140
+ this.ctx?.pushData(vpvrLayerData(inputs));
37792
39141
  }
37793
39142
  /** Hiding is a renderer-layer flag (set via `setIndicatorVisible`); no resources to free. */
37794
39143
  suspend() {
37795
39144
  }
37796
39145
  resume() {
37797
- this.ctx.pushData(vpvrLayerData(this.inputs));
39146
+ this.ctx?.pushData(vpvrLayerData(this.inputs));
37798
39147
  }
37799
39148
  stop() {
37800
39149
  }
@@ -40246,6 +41595,7 @@ var Vela = class {
40246
41595
  if (Object.keys(defaults2).length > 0) this.rendererControl.set(defaults2);
40247
41596
  this.panesControl = new PanesControl(this.orchestrator);
40248
41597
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
41598
+ this.marksControl = new MarksControl(this.orchestrator.marks);
40249
41599
  }
40250
41600
  /**
40251
41601
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -40464,6 +41814,17 @@ var Vela = class {
40464
41814
  get drawings() {
40465
41815
  return this.drawingsControl;
40466
41816
  }
41817
+ /**
41818
+ * The chart's timeline-marks control surface: host events pinned to a bar and shown
41819
+ * as glyphs on a lane above the time axis, each opening a popup on click —
41820
+ * `chart.marks.add({ id, time, glyph, title, content })`, `chart.marks.set(list)`,
41821
+ * `chart.marks.defineGroup({ id, label })`. Marks are data, not user state: re-supply
41822
+ * them on `market:changed`. On a renderer without the `timelineMarks` capability the
41823
+ * model still fills but nothing paints (`chart.marks.supported`).
41824
+ */
41825
+ get marks() {
41826
+ return this.marksControl;
41827
+ }
40467
41828
  on(event, handler) {
40468
41829
  return this.orchestrator.events.on(event, handler);
40469
41830
  }
@@ -41915,7 +43276,7 @@ var ChartCell = class {
41915
43276
  // A RESTORED ledger is authoritative for the auto-added volume too: a
41916
43277
  // slot persisted without it must come back without it (fresh slots
41917
43278
  // keep the workspace default).
41918
- volume: seed.indicators ? seed.indicators.natives.includes("volume") : deps.volume,
43279
+ volume: seed.indicators ? seed.indicators.natives.some((e) => ledgerNativeType(e) === "volume") : deps.volume,
41919
43280
  nativeBackend: deps.nativeBackend,
41920
43281
  // The user's drawings option minus its toolbar: one SHARED bar serves
41921
43282
  // the whole workspace (per-cell bars would cost a 44px gutter each).
@@ -43249,6 +44610,9 @@ var VelaWorkspace = class {
43249
44610
  this.events = new TypedEventBus();
43250
44611
  this.feed = new MultiProviderFeed();
43251
44612
  this.cellsById = /* @__PURE__ */ new Map();
44613
+ /** The one second pulse behind every time display: the bottom-bar clock and each
44614
+ * cell's countdown chip subscribe to it, so they can never read different seconds. */
44615
+ this.clock = new SecondClock();
43252
44616
  this.pool = /* @__PURE__ */ new Map();
43253
44617
  this.trackSizes = /* @__PURE__ */ new Map();
43254
44618
  this.resizeObserver = null;
@@ -43526,6 +44890,7 @@ var VelaWorkspace = class {
43526
44890
  this.drawToolbar?.setDrawingsSyncMode(!!this.syncOpts.drawings);
43527
44891
  this.bottombar = opts.bottombar !== false ? new Bottombar(this.root, {
43528
44892
  timezone: this.timezone,
44893
+ clock: this.clock,
43529
44894
  onRange: (preset) => {
43530
44895
  this.active.applyRange(preset);
43531
44896
  this.bottombar?.setActiveRange(preset.id);
@@ -43969,6 +45334,7 @@ var VelaWorkspace = class {
43969
45334
  const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
43970
45335
  const paneMax = cell ? cell.chart.panes.list().some((p) => p.maximized) : false;
43971
45336
  this.mobileBar.setMaximizeActive(this.maximizedId != null || paneMax);
45337
+ this.mobileBar.setMaximizeVisible(this.def.cells.length > 1);
43972
45338
  }
43973
45339
  /**
43974
45340
  * Trade the SLOTS of two live cells — the grid arrangement changes, the cells
@@ -44269,6 +45635,7 @@ var VelaWorkspace = class {
44269
45635
  if (id === this.activeId) cell.host.dataset.active = "1";
44270
45636
  this.wireCell(cell);
44271
45637
  cell.chart.renderer.setLayoutMode(this.layoutCtl.current);
45638
+ cell.chart.renderer.setWallClock(this.clock);
44272
45639
  cell.setControlsSuspended(this.layoutCtl.current === "mobile");
44273
45640
  if (this.favs.length > 0) cell.chart.drawings.setFavorites(this.favs);
44274
45641
  cell.setManifest(this.manifest, pooled?.indicators == null);