@luxalgo/vela 0.6.21 → 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-BRPFYKTT.js → chunk-A347YL2P.js} +30 -7
  4. package/dist/{chunk-TRZQQUTR.js → chunk-A4G64KVF.js} +1442 -126
  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 +1465 -125
  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.cts → options-ex2_gtKp.d.cts} +200 -12
  14. package/dist/{options-BCRmYALw.d.ts → 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 +1465 -125
  32. package/dist/vela.global.min.js +77 -53
  33. package/dist/widget.cjs +1490 -129
  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 +1490 -129
  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 +1 -1
@@ -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?.();
@@ -5146,9 +5168,12 @@ function mergeConfig(base, patch) {
5146
5168
  const gh = asObject(grid.horzLines);
5147
5169
  const cross = asObject(p.crosshair);
5148
5170
  const ps = asObject(p.priceScale);
5171
+ const anim = asObject(p.animations);
5149
5172
  const panes = asObject(p.panes);
5150
5173
  const trades = asObject(p.trades);
5151
5174
  const ts = asObject(p.timeScale);
5175
+ const marks = asObject(p.marks);
5176
+ const markGroups = asObject(marks.groups);
5152
5177
  const candles = asObject(p.candles);
5153
5178
  const bars = asObject(p.bars);
5154
5179
  const line = asObject(p.line);
@@ -5195,6 +5220,12 @@ function mergeConfig(base, patch) {
5195
5220
  countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown,
5196
5221
  animateLastPrice: isBool(ps.animateLastPrice) ? ps.animateLastPrice : base.priceScale.animateLastPrice
5197
5222
  },
5223
+ animations: {
5224
+ zoom: isBool(anim.zoom) ? anim.zoom : base.animations.zoom,
5225
+ pan: isBool(anim.pan) ? anim.pan : base.animations.pan,
5226
+ autoscale: isBool(anim.autoscale) ? anim.autoscale : base.animations.autoscale,
5227
+ intro: isBool(anim.intro) ? anim.intro : base.animations.intro
5228
+ },
5198
5229
  panes: {
5199
5230
  separatorColor: isColor(panes.separatorColor) ? panes.separatorColor : base.panes.separatorColor
5200
5231
  },
@@ -5209,6 +5240,15 @@ function mergeConfig(base, patch) {
5209
5240
  timeScale: {
5210
5241
  timezone: typeof ts.timezone === "string" && ts.timezone ? ts.timezone : base.timeScale.timezone
5211
5242
  },
5243
+ marks: {
5244
+ visible: isBool(marks.visible) ? marks.visible : base.marks.visible,
5245
+ // Additive like `stacking.series`: a patch names only the groups it carries, so a
5246
+ // choice stored for a group the host has not registered yet survives verbatim.
5247
+ groups: {
5248
+ ...base.marks.groups,
5249
+ ...Object.fromEntries(Object.entries(markGroups).filter(([, v]) => isBool(v)))
5250
+ }
5251
+ },
5212
5252
  candles: {
5213
5253
  upColor: isColor(candles.upColor) ? candles.upColor : base.candles.upColor,
5214
5254
  downColor: isColor(candles.downColor) ? candles.downColor : base.candles.downColor,
@@ -5921,6 +5961,40 @@ var Topbar = class {
5921
5961
  }
5922
5962
  };
5923
5963
 
5964
+ // src/core/util/wall-clock.ts
5965
+ var BOUNDARY_SLACK_MS = 5;
5966
+ var SecondClock = class _SecondClock {
5967
+ constructor(now = () => Date.now()) {
5968
+ this.now = now;
5969
+ this.subs = /* @__PURE__ */ new Set();
5970
+ this.timer = null;
5971
+ }
5972
+ onTick(cb) {
5973
+ this.subs.add(cb);
5974
+ if (this.timer == null) this.arm();
5975
+ return () => {
5976
+ this.subs.delete(cb);
5977
+ if (this.subs.size === 0 && this.timer != null) {
5978
+ clearTimeout(this.timer);
5979
+ this.timer = null;
5980
+ }
5981
+ };
5982
+ }
5983
+ /** Milliseconds from `now` to just past the next second boundary. */
5984
+ static delayToNextSecond(now) {
5985
+ const intoSecond = (now % 1e3 + 1e3) % 1e3;
5986
+ return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
5987
+ }
5988
+ arm() {
5989
+ this.timer = setTimeout(() => {
5990
+ this.timer = null;
5991
+ const now = this.now();
5992
+ for (const cb of this.subs) cb(now);
5993
+ if (this.subs.size > 0) this.arm();
5994
+ }, _SecondClock.delayToNextSecond(this.now()));
5995
+ }
5996
+ };
5997
+
5924
5998
  // src/core/timezones.ts
5925
5999
  var TIMEZONES = [
5926
6000
  { value: "Etc/UTC", label: "UTC" },
@@ -6100,7 +6174,6 @@ var Bottombar = class {
6100
6174
  this.rangeButtons = /* @__PURE__ */ new Map();
6101
6175
  this.sessionButtons = /* @__PURE__ */ new Map();
6102
6176
  this.sessionEl = null;
6103
- this.timer = null;
6104
6177
  this.timezone = opts.timezone;
6105
6178
  const doc = host.ownerDocument;
6106
6179
  injectStyles(STYLE_ID3, CSS4, doc);
@@ -6165,7 +6238,7 @@ var Bottombar = class {
6165
6238
  }
6166
6239
  });
6167
6240
  this.tick();
6168
- this.timer = setInterval(() => this.tick(), 1e3);
6241
+ this.unsubClock = (opts.clock ?? new SecondClock()).onTick(() => this.tick());
6169
6242
  }
6170
6243
  setTimezone(zone) {
6171
6244
  this.timezone = zone;
@@ -6194,7 +6267,7 @@ var Bottombar = class {
6194
6267
  }
6195
6268
  }
6196
6269
  destroy() {
6197
- if (this.timer !== null) clearInterval(this.timer);
6270
+ this.unsubClock();
6198
6271
  this.tzMenu.destroy();
6199
6272
  this.settingsTip?.destroy();
6200
6273
  this.el.remove();
@@ -14893,6 +14966,11 @@ function followStep(cur, target, follow = FOLLOW, epsFrac = 15e-4) {
14893
14966
  const done = Math.abs(target.from - nf) <= eps && Math.abs(target.to - nt) <= eps;
14894
14967
  return { cur: done ? { ...target } : { from: nf, to: nt }, done };
14895
14968
  }
14969
+ function zoomAnimated(chart) {
14970
+ const control = chart.renderer;
14971
+ if (!control || !control.supports("animZoom")) return true;
14972
+ return Boolean(control.get("animZoom"));
14973
+ }
14896
14974
  var Glider = class {
14897
14975
  constructor(chart) {
14898
14976
  this.chart = chart;
@@ -14920,8 +14998,14 @@ var Glider = class {
14920
14998
  const chart = this.chart();
14921
14999
  const base = this.target ?? chart?.getVisibleRange();
14922
15000
  if (!chart || !base) return;
15001
+ const target = make(base);
15002
+ if (!zoomAnimated(chart)) {
15003
+ this.stop();
15004
+ chart.setVisibleRange(target);
15005
+ return;
15006
+ }
14923
15007
  this.cur ?? (this.cur = { ...base });
14924
- this.target = make(base);
15008
+ this.target = target;
14925
15009
  if (!this.raf) this.tick();
14926
15010
  }
14927
15011
  tick() {
@@ -15041,6 +15125,7 @@ var CSS13 = `
15041
15125
  font-weight: 600;
15042
15126
  -webkit-tap-highlight-color: transparent;
15043
15127
  }
15128
+ .vela-mb-item[hidden] { display: none !important; }
15044
15129
  .vela-mb-item:active { background: var(--vela-hover); }
15045
15130
  .vela-mb-item .vela-icon { font-size: 18px; width: 18px; height: 18px; }
15046
15131
  /* A lit stop (the maximize toggle while something is isolated): the inverse
@@ -15130,6 +15215,12 @@ var MobileBar = class {
15130
15215
  this.maxEl.setAttribute("aria-label", on ? "Restore layout" : "Maximize chart");
15131
15216
  this.maxEl.replaceChildren(iconEl(on ? "restore" : "maximize", this.el.ownerDocument));
15132
15217
  }
15218
+ /** Show the maximize stop only while the grid holds more than one chart — on a
15219
+ * one-cell layout there is nothing to isolate, so the stop would be a dead press. */
15220
+ setMaximizeVisible(visible) {
15221
+ if (!this.maxEl) return;
15222
+ this.maxEl.hidden = !visible;
15223
+ }
15133
15224
  destroy() {
15134
15225
  this.el.remove();
15135
15226
  }
@@ -18204,19 +18295,48 @@ function localStorageAdapter(storageKey) {
18204
18295
 
18205
18296
  // src/core/options.ts
18206
18297
  var normalizeSession = (v) => v === "regular" || v === "extended" ? v : void 0;
18298
+ var ZOOM_EASE_DEFAULT_MS = 70;
18299
+ var PAN_INERTIA_DEFAULT_MS = 110;
18300
+ var SCROLL_EASE_DEFAULT_MS = 130;
18301
+ var AUTOSCALE_EASE_DEFAULT_MS = 80;
18207
18302
  var LIVE_BAR_EASE_DEFAULT_MS = 90;
18208
- var LIVE_BAR_EASE_MAX_MS = 1e3;
18209
- function resolveLiveBarEaseMs(value) {
18210
- if (value === true) return LIVE_BAR_EASE_DEFAULT_MS;
18303
+ var INTRO_DURATION_DEFAULT_MS = 650;
18304
+ var ANIMATION_EASE_MAX_MS = 1e3;
18305
+ var LIVE_BAR_EASE_MAX_MS = ANIMATION_EASE_MAX_MS;
18306
+ var INTRO_DURATION_MAX_MS = 5e3;
18307
+ function resolveEaseMs(value, defaultMs, maxMs = ANIMATION_EASE_MAX_MS) {
18308
+ if (value === true) return defaultMs;
18211
18309
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
18212
- return Math.min(value, LIVE_BAR_EASE_MAX_MS);
18310
+ return Math.min(value, maxMs);
18311
+ }
18312
+ function resolveLiveBarEaseMs(value) {
18313
+ return resolveEaseMs(value, LIVE_BAR_EASE_DEFAULT_MS, LIVE_BAR_EASE_MAX_MS);
18314
+ }
18315
+ function resolveIntro(value) {
18316
+ const off = { style: false, duration: 0 };
18317
+ if (value === true) return { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
18318
+ if (value === "settle" || value === "grow") return { style: value, duration: INTRO_DURATION_DEFAULT_MS };
18319
+ if (value && typeof value === "object") {
18320
+ const o = value;
18321
+ const d = o.duration;
18322
+ return {
18323
+ style: o.style === "grow" ? "grow" : "settle",
18324
+ duration: typeof d === "number" && Number.isFinite(d) && d > 0 ? Math.min(d, INTRO_DURATION_MAX_MS) : INTRO_DURATION_DEFAULT_MS
18325
+ };
18326
+ }
18327
+ return off;
18213
18328
  }
18214
18329
  function resolveAnimations(animations) {
18215
- if (typeof animations === "boolean") return { animZoom: animations, animPan: animations, animLiveBar: 0 };
18330
+ if (animations === false) return { animZoom: 0, animPan: 0, animScroll: 0, animAutoscale: 0, animLiveBar: 0, animIntro: { style: false, duration: 0 } };
18331
+ const cfg = animations === true || animations == null ? {} : animations;
18332
+ const animPan = resolveEaseMs(cfg.pan ?? true, PAN_INERTIA_DEFAULT_MS);
18216
18333
  return {
18217
- animZoom: animations?.zoom ?? true,
18218
- animPan: animations?.pan ?? true,
18219
- animLiveBar: resolveLiveBarEaseMs(animations?.liveBar)
18334
+ animZoom: resolveEaseMs(cfg.zoom ?? true, ZOOM_EASE_DEFAULT_MS),
18335
+ animPan,
18336
+ animScroll: cfg.scroll === void 0 ? animPan > 0 ? SCROLL_EASE_DEFAULT_MS : 0 : resolveEaseMs(cfg.scroll, SCROLL_EASE_DEFAULT_MS),
18337
+ animAutoscale: resolveEaseMs(cfg.autoscale ?? true, AUTOSCALE_EASE_DEFAULT_MS),
18338
+ animLiveBar: resolveLiveBarEaseMs(cfg.liveBar),
18339
+ animIntro: resolveIntro(cfg.intro ?? true)
18220
18340
  };
18221
18341
  }
18222
18342
 
@@ -18431,6 +18551,93 @@ function presetToRange(preset, bars) {
18431
18551
  return { from: Math.max(first, from), to: last };
18432
18552
  }
18433
18553
 
18554
+ // src/core/marks/MarksController.ts
18555
+ var MarksController = class {
18556
+ constructor(renderer, events) {
18557
+ this.renderer = renderer;
18558
+ /** Insertion-ordered — the order a cluster falls back to for equal times. */
18559
+ this.marks = /* @__PURE__ */ new Map();
18560
+ this.groups = /* @__PURE__ */ new Map();
18561
+ this.subs = [];
18562
+ this.enabled = !!renderer.capabilities.timelineMarks && typeof renderer.setTimelineMarks === "function";
18563
+ if (this.enabled && renderer.onMarkClick) this.subs.push(renderer.onMarkClick((e) => events.emit("mark:click", e)));
18564
+ }
18565
+ /** Whether the active renderer paints timeline marks. */
18566
+ get supported() {
18567
+ return this.enabled;
18568
+ }
18569
+ /** Add (or replace, by id) one mark. */
18570
+ add(mark) {
18571
+ this.marks.set(mark.id, validateMark(mark));
18572
+ this.sync();
18573
+ }
18574
+ /** Replace the whole set — a market switch. */
18575
+ set(marks) {
18576
+ this.marks.clear();
18577
+ for (const m of marks) this.marks.set(m.id, validateMark(m));
18578
+ this.sync();
18579
+ }
18580
+ remove(id) {
18581
+ const had = this.marks.delete(id);
18582
+ if (had) this.sync();
18583
+ return had;
18584
+ }
18585
+ clear() {
18586
+ if (this.marks.size === 0) return;
18587
+ this.marks.clear();
18588
+ this.sync();
18589
+ }
18590
+ /** Every mark, in insertion order (shallow copies — mutating one changes nothing). */
18591
+ all() {
18592
+ return [...this.marks.values()].map((m) => ({ ...m, glyph: { ...m.glyph } }));
18593
+ }
18594
+ /** Define (or replace) a group's presentation — its settings label and default visibility. */
18595
+ defineGroup(group) {
18596
+ if (!group || typeof group.id !== "string" || group.id.length === 0) throw new Error("[vela] marks.defineGroup: `id` must be a non-empty string");
18597
+ if (typeof group.label !== "string") throw new Error(`[vela] marks.defineGroup: group "${group.id}" needs a string \`label\``);
18598
+ this.groups.set(group.id, { ...group });
18599
+ this.sync();
18600
+ }
18601
+ /** The defined groups, in definition order. */
18602
+ groupDefinitions() {
18603
+ return [...this.groups.values()].map((g) => ({ ...g }));
18604
+ }
18605
+ /**
18606
+ * Show or hide one group's marks. The choice lives in the renderer's cosmetic config
18607
+ * (the `marks` feature) — what the settings dialog's Events checkboxes edit and what
18608
+ * a persisted chart restores — so it warns + no-ops on a renderer without it.
18609
+ */
18610
+ setGroupVisible(id, visible) {
18611
+ if (!this.renderer.features.includes("marks")) {
18612
+ console.warn(`[vela] renderer "${this.renderer.name}" does not paint timeline marks \u2014 setGroupVisible ignored.`);
18613
+ return;
18614
+ }
18615
+ this.renderer.applyFeature("marks", { groups: { [id]: visible } });
18616
+ }
18617
+ /** A group's effective visibility: the user's (persisted) choice, else the group's declared default, else visible. */
18618
+ isGroupVisible(id) {
18619
+ const state = this.renderer.readFeature("marks");
18620
+ const chosen = state?.groups?.[id];
18621
+ if (typeof chosen === "boolean") return chosen;
18622
+ return this.groups.get(id)?.visible !== false;
18623
+ }
18624
+ destroy() {
18625
+ for (const unsub of this.subs) unsub();
18626
+ this.subs.length = 0;
18627
+ }
18628
+ sync() {
18629
+ if (!this.enabled) return;
18630
+ this.renderer.setTimelineMarks([...this.marks.values()], [...this.groups.values()]);
18631
+ }
18632
+ };
18633
+ function validateMark(mark) {
18634
+ if (!mark || typeof mark !== "object") throw new Error("[vela] marks: a mark must be an object");
18635
+ if (typeof mark.id !== "string" || mark.id.length === 0) throw new Error("[vela] marks: `id` must be a non-empty string");
18636
+ if (typeof mark.time !== "number" || !Number.isFinite(mark.time)) throw new Error(`[vela] marks: mark "${mark.id}" needs a finite epoch-ms \`time\``);
18637
+ 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\``);
18638
+ return { ...mark, glyph: { ...mark.glyph } };
18639
+ }
18640
+
18434
18641
  // src/core/engine/DrawingSeriesService.ts
18435
18642
  var MAX_BARS = 5e3;
18436
18643
  var PAD_FRAC = 0.25;
@@ -18748,6 +18955,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
18748
18955
  marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
18749
18956
  });
18750
18957
  this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
18958
+ this.marks = new MarksController(this.renderer, this.events);
18751
18959
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
18752
18960
  this.endLoad();
18753
18961
  this.events.emit("data:unresolved", info);
@@ -19612,6 +19820,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19612
19820
  record.session = void 0;
19613
19821
  record.native?.instance.suspend();
19614
19822
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, false);
19823
+ else if (record.native) this.mountHiddenNativeRow(id, record);
19615
19824
  } else {
19616
19825
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, true);
19617
19826
  record.pendingStructural = true;
@@ -19699,6 +19908,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19699
19908
  this.unresolvedUnsub = null;
19700
19909
  this.feed.destroy?.();
19701
19910
  this.drawings.destroy();
19911
+ this.marks.destroy();
19702
19912
  this.renderer.destroy();
19703
19913
  this.events.clear();
19704
19914
  }
@@ -19812,6 +20022,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19812
20022
  overlay: d.overlay,
19813
20023
  paneHint: d.paneHint,
19814
20024
  native: { type: record.native.type },
20025
+ ...d.legend === false ? { legend: false } : {},
19815
20026
  ...out.paneAxis != null ? { paneAxis: out.paneAxis } : {},
19816
20027
  series: out.series ?? [],
19817
20028
  fills: out.fills ?? [],
@@ -19834,9 +20045,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
19834
20045
  * over it in place (`pendingStructural`), clears the spinner, and only THEN fires
19835
20046
  * `indicator:added`/`ready` — so event semantics and `inspect()` (which skips
19836
20047
  * loading records) still mean "the indicator produced output".
20048
+ *
20049
+ * A HIDDEN record mounts too — dimmed, no spinner (its session never starts while
20050
+ * hidden, so nothing is computing and no model will ever arrive to mount the row
20051
+ * later). Without this an indicator ADDED hidden (a restored ledger/ext entry) had
20052
+ * no legend row at all: invisible AND unreachable — the eye that unhides it never
20053
+ * existed. The hidden mount announces immediately for the same reason: the "first
20054
+ * computed model" that normally announces cannot come until the indicator is shown,
20055
+ * and host UIs (object tree, landing watchers) must know it exists NOW.
19837
20056
  */
19838
20057
  mountLoadingPlaceholder(id, record) {
19839
- if (record.renderHandle || record.hidden || !record.prepared) return;
20058
+ if (record.renderHandle || !record.prepared) return;
19840
20059
  const meta = record.prepared.meta;
19841
20060
  const model = {
19842
20061
  id,
@@ -19860,8 +20079,34 @@ var EngineOrchestrator = class _EngineOrchestrator {
19860
20079
  this.ensurePaneFor(paneId);
19861
20080
  record.renderHandle = this.renderer.mountIndicator(model);
19862
20081
  record.pendingStructural = true;
20082
+ if (record.hidden) {
20083
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
20084
+ this.announce(record, this.handles.get(id));
20085
+ return;
20086
+ }
19863
20087
  this.setLoading(record, true);
19864
20088
  }
20089
+ /**
20090
+ * Mount the legend row for a NATIVE indicator that is being hidden BEFORE it ever
20091
+ * started (a restored-hidden ledger entry: `startNativeIndicator` bails on hidden
20092
+ * records, so no model — and therefore no row — would ever mount). The native
20093
+ * counterpart of {@link mountLoadingPlaceholder}'s hidden branch: an empty model
20094
+ * carries the title + inputs schema, the renderer marks the row hidden, and the
20095
+ * announce makes the indicator visible to host UIs. Showing later STARTS the
20096
+ * instance (the `started` flag path) and its first emit remounts over this row.
20097
+ */
20098
+ mountHiddenNativeRow(id, record) {
20099
+ if (record.renderHandle || !record.native) return;
20100
+ const model = this.buildNativeModel(record, {});
20101
+ const paneId = this.routePane(id, model, record.options ?? {});
20102
+ this.placeModel(model, id, paneId);
20103
+ record.model = model;
20104
+ this.ensurePaneFor(paneId);
20105
+ record.renderHandle = this.renderer.mountIndicator(model);
20106
+ record.pendingStructural = true;
20107
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
20108
+ this.announce(record, this.handles.get(id));
20109
+ }
19865
20110
  /** Flip the record's loading state and reflect it in the legend row (spinner on/off). */
19866
20111
  setLoading(record, loading) {
19867
20112
  record.loading = loading;
@@ -19896,6 +20141,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19896
20141
  for (const r of this.registry.all()) {
19897
20142
  const model = r.model;
19898
20143
  if (!model) continue;
20144
+ if (model.legend === false) continue;
19899
20145
  const paneId = model.paneId ?? "price";
19900
20146
  if (!byPane.has(paneId)) byPane.set(paneId, []);
19901
20147
  byPane.get(paneId).push({
@@ -20471,6 +20717,16 @@ var RendererControl = class {
20471
20717
  this.renderer.setLayoutMode?.(mode);
20472
20718
  return this;
20473
20719
  }
20720
+ /**
20721
+ * Drive the renderer's time-of-day chrome (the countdown-to-bar-close chip) from the
20722
+ * host's own second pulse, so it ticks in step with a host clock display instead of
20723
+ * on a separate timer that can read a different second. `null` hands the pulse back
20724
+ * to the renderer. Silent no-op on a renderer without time-of-day chrome.
20725
+ */
20726
+ setWallClock(clock) {
20727
+ this.renderer.setWallClock?.(clock);
20728
+ return this;
20729
+ }
20474
20730
  };
20475
20731
 
20476
20732
  // src/core/PanesControl.ts
@@ -20810,6 +21066,61 @@ var DrawingsControl = class {
20810
21066
  }
20811
21067
  };
20812
21068
 
21069
+ // src/core/MarksControl.ts
21070
+ var MarksControl = class {
21071
+ constructor(ctrl) {
21072
+ this.ctrl = ctrl;
21073
+ }
21074
+ /** Whether the active renderer paints timeline marks. */
21075
+ get supported() {
21076
+ return this.ctrl.supported;
21077
+ }
21078
+ /** Add one mark; an existing id is replaced in place. */
21079
+ add(mark) {
21080
+ this.ctrl.add(mark);
21081
+ return this;
21082
+ }
21083
+ /** Replace the whole set (a market switch). */
21084
+ set(marks) {
21085
+ this.ctrl.set(marks);
21086
+ return this;
21087
+ }
21088
+ remove(id) {
21089
+ this.ctrl.remove(id);
21090
+ return this;
21091
+ }
21092
+ clear() {
21093
+ this.ctrl.clear();
21094
+ return this;
21095
+ }
21096
+ /** Every mark, in insertion order. */
21097
+ all() {
21098
+ return this.ctrl.all();
21099
+ }
21100
+ /**
21101
+ * Define a visibility group's presentation: the label of its checkbox in chart
21102
+ * settings (the Events tab) and its default visibility. Marks may name a group
21103
+ * that was never defined — it then shows its capitalized id.
21104
+ */
21105
+ defineGroup(group) {
21106
+ this.ctrl.defineGroup(group);
21107
+ return this;
21108
+ }
21109
+ /** The defined groups, in definition order. */
21110
+ groups() {
21111
+ return this.ctrl.groupDefinitions();
21112
+ }
21113
+ /** Show or hide one group's marks — the same switch as the settings checkbox, persisted with the chart's config. */
21114
+ setGroupVisible(id, visible = true) {
21115
+ this.ctrl.setGroupVisible(id, visible);
21116
+ return this;
21117
+ }
21118
+ /** A group's effective visibility (the user's choice, else the group's declared default). */
21119
+ isGroupVisible(id) {
21120
+ return this.ctrl.isGroupVisible(id);
21121
+ }
21122
+ };
21123
+
20813
21124
  // src/core/model/inputs.ts
20814
21125
  function inputVisible(when, values) {
20815
21126
  if (!when) return true;
@@ -22858,6 +23169,8 @@ var NATIVE_CAPABILITIES = {
22858
23169
  // canvas-painted into the owning indicator's interleave slice
22859
23170
  trades: true,
22860
23171
  // strategy order-fill markers (arrows + labels + fill-price ticks)
23172
+ timelineMarks: true,
23173
+ // host events on a lane above the time axis (glyphs + detail popup)
22861
23174
  inputsUI: true
22862
23175
  // reuses the DOM InputsUI
22863
23176
  };
@@ -24425,6 +24738,31 @@ var Animator = class {
24425
24738
  }
24426
24739
  }
24427
24740
  };
24741
+ var EaseSetting = class {
24742
+ /** `defaultMs` is what the on/off switch restores when nothing else was configured;
24743
+ * `initialMs` (default: `defaultMs`) is the starting value — 0 for a motion that
24744
+ * ships off. */
24745
+ constructor(defaultMs, initialMs = defaultMs) {
24746
+ this.onMs = defaultMs;
24747
+ this.ms = initialMs;
24748
+ }
24749
+ /** The active time-constant; 0 when off. */
24750
+ get tau() {
24751
+ return this.ms;
24752
+ }
24753
+ get on() {
24754
+ return this.ms > 0;
24755
+ }
24756
+ /** Set the time-constant (0 = off). A non-zero value becomes what `toggle(true)` restores. */
24757
+ set(ms) {
24758
+ this.ms = ms;
24759
+ if (ms > 0) this.onMs = ms;
24760
+ }
24761
+ /** On/off only — the duration stays the last one configured. */
24762
+ toggle(on) {
24763
+ this.ms = on ? this.onMs : 0;
24764
+ }
24765
+ };
24428
24766
  function easeToward(current, target, dtMs, tauMs) {
24429
24767
  if (tauMs <= 0) return target;
24430
24768
  return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
@@ -25148,6 +25486,25 @@ function drawTextLines(ctx, lines, x, firstY, step, color) {
25148
25486
  for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], x, firstY + i * step);
25149
25487
  }
25150
25488
 
25489
+ // src/renderers/shared/marks-state.ts
25490
+ function defaultMarksState() {
25491
+ return { visible: true, groups: {} };
25492
+ }
25493
+ function mergeMarksState(base, patch) {
25494
+ if (typeof patch === "boolean") return { visible: patch, groups: { ...base.groups } };
25495
+ const p = patch && typeof patch === "object" ? patch : {};
25496
+ const g = p.groups && typeof p.groups === "object" ? p.groups : {};
25497
+ const groups = { ...base.groups };
25498
+ for (const [id, v] of Object.entries(g)) if (typeof v === "boolean") groups[id] = v;
25499
+ return { visible: typeof p.visible === "boolean" ? p.visible : base.visible, groups };
25500
+ }
25501
+ function markGroupVisible(state, groupId, groups) {
25502
+ if (groupId === void 0) return true;
25503
+ const chosen = state.groups[groupId];
25504
+ if (typeof chosen === "boolean") return chosen;
25505
+ return groups.find((g) => g.id === groupId)?.visible !== false;
25506
+ }
25507
+
25151
25508
  // src/renderers/native/core/SceneGraph.ts
25152
25509
  var SceneGraph = class {
25153
25510
  constructor() {
@@ -25211,6 +25568,20 @@ var SceneGraph = class {
25211
25568
  /** Strategy trade-marker display (the `tradeMarkers` feature): master toggle, the
25212
25569
  * two text lines, and the palette. Trade markers always paint on the price pane. */
25213
25570
  this.tradeMarkers = defaultTradeMarkersState();
25571
+ /** Timeline-mark display (the `marks` feature): the lane's master toggle + per-group visibility. */
25572
+ this.marks = defaultMarksState();
25573
+ /** The host's timeline marks + group definitions (`setTimelineMarks`), painted on the lane above the time axis. */
25574
+ this.timelineMarks = [];
25575
+ this.markGroups = [];
25576
+ /** The mark stack (bar index) fanned out by hover or tap, if any. */
25577
+ this.marksExpandedStack = null;
25578
+ /** The lane glyph (cluster key) under the pointer — it pulses — and when the hover began (frame-clock ms). */
25579
+ this.marksHoverKey = null;
25580
+ this.marksHoverSince = 0;
25581
+ /** The cluster whose popup is open: its glyph paints filled ("active"). */
25582
+ this.marksActiveKey = null;
25583
+ /** A content-less click's brief filled flash — the cluster key and the frame-clock time it ends. */
25584
+ this.marksFlash = null;
25214
25585
  /** Renderer-owned shaded time bands (session highlighting), behind grid + data. */
25215
25586
  this.highlights = [];
25216
25587
  /** Pre/post-market bands pushed by the host (`sessionZones` feature); null ⇒ no sessions. */
@@ -27059,6 +27430,315 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
27059
27430
  return out;
27060
27431
  }
27061
27432
 
27433
+ // src/renderers/native/chrome/countdown.ts
27434
+ function countdownText(barOpen, barMs, now) {
27435
+ if (!(barMs > 0)) return null;
27436
+ const remaining = barOpen + barMs - now;
27437
+ if (remaining <= 0) return null;
27438
+ return formatCountdown(remaining);
27439
+ }
27440
+ function formatCountdown(ms) {
27441
+ const total = Math.max(0, Math.ceil(ms / 1e3));
27442
+ const s = total % 60;
27443
+ const m = Math.floor(total / 60) % 60;
27444
+ const h = Math.floor(total / 3600);
27445
+ const pad = (v) => String(v).padStart(2, "0");
27446
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
27447
+ }
27448
+
27449
+ // src/renderers/native/chrome/contrast.ts
27450
+ function tagTextColor(bg, over) {
27451
+ const [r, g, b, a] = parseColor(bg);
27452
+ let R = r;
27453
+ let G = g;
27454
+ let B = b;
27455
+ if (a < 1) {
27456
+ const [or, og, ob] = parseColor(over);
27457
+ R = r * a + or * (1 - a);
27458
+ G = g * a + og * (1 - a);
27459
+ B = b * a + ob * (1 - a);
27460
+ }
27461
+ const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
27462
+ const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
27463
+ return L >= 0.4 ? "#000000" : "#ffffff";
27464
+ }
27465
+
27466
+ // src/renderers/native/chrome/marks/layout.ts
27467
+ var MARK_GLYPH_PX = 16;
27468
+ var MARK_CLUSTER_PX = 20;
27469
+ var MARK_LANE_INSET = 4;
27470
+ var MARK_DECK_STEP = 3;
27471
+ var MARK_FAN_GAP = 4;
27472
+ var MARK_HIT_PAD = 3;
27473
+ var MARK_FAN_HOLD = 8;
27474
+ function snapMarkBar(time, barTimes, intervalMs2) {
27475
+ const n = barTimes.length;
27476
+ if (n === 0 || !(intervalMs2 > 0) || !Number.isFinite(time)) return null;
27477
+ if (time < barTimes[0]) return null;
27478
+ const last = barTimes[n - 1];
27479
+ if (time >= last + intervalMs2) return n - 1 + Math.floor((time - last) / intervalMs2);
27480
+ let lo = 0;
27481
+ let hi = n - 1;
27482
+ while (lo < hi) {
27483
+ const mid = lo + hi + 1 >> 1;
27484
+ if (barTimes[mid] <= time) lo = mid;
27485
+ else hi = mid - 1;
27486
+ }
27487
+ if (time < barTimes[lo] + intervalMs2) return lo;
27488
+ return lo + 1;
27489
+ }
27490
+ function clusterMarks(marks, barTimes, intervalMs2, hidden) {
27491
+ const byKey = /* @__PURE__ */ new Map();
27492
+ marks.forEach((m, seq) => {
27493
+ if (m.group !== void 0 && hidden(m.group)) return;
27494
+ const bar = snapMarkBar(m.time, barTimes, intervalMs2);
27495
+ if (bar === null) return;
27496
+ const key = `${bar}|${m.group ?? ""}`;
27497
+ let c = byKey.get(key);
27498
+ if (!c) {
27499
+ c = { key, bar, group: m.group, marks: [], seq: [] };
27500
+ byKey.set(key, c);
27501
+ }
27502
+ c.marks.push(m);
27503
+ c.seq.push(seq);
27504
+ });
27505
+ const out = [];
27506
+ for (const c of byKey.values()) {
27507
+ 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);
27508
+ out.push({ key: c.key, bar: c.bar, group: c.group, marks: order.map((o) => o.m) });
27509
+ }
27510
+ return out;
27511
+ }
27512
+ function groupRank(groups, clusters) {
27513
+ const rank = /* @__PURE__ */ new Map();
27514
+ groups.forEach((g, i) => rank.set(g.id, i));
27515
+ for (const c of clusters) {
27516
+ if (c.group !== void 0 && !rank.has(c.group)) rank.set(c.group, rank.size);
27517
+ }
27518
+ return (group) => group === void 0 ? Number.MAX_SAFE_INTEGER : rank.get(group) ?? Number.MAX_SAFE_INTEGER - 1;
27519
+ }
27520
+ function layoutMarkLane(input) {
27521
+ const clusters = clusterMarks(input.marks, input.barTimes, input.intervalMs, input.hidden);
27522
+ const rankOf = groupRank(input.groups, clusters);
27523
+ const byBar = /* @__PURE__ */ new Map();
27524
+ for (const c of clusters) {
27525
+ const list = byBar.get(c.bar);
27526
+ if (list) list.push(c);
27527
+ else byBar.set(c.bar, [c]);
27528
+ }
27529
+ const glyphs = [];
27530
+ const stacks = /* @__PURE__ */ new Map();
27531
+ for (const [bar, list] of byBar) {
27532
+ const x = input.xOf(bar);
27533
+ if (!Number.isFinite(x) || x < -MARK_CLUSTER_PX || x > input.dataW + MARK_CLUSTER_PX) continue;
27534
+ list.sort((a, b) => rankOf(a.group) - rankOf(b.group));
27535
+ const multi = list.length > 1;
27536
+ const expanded = multi && input.expanded === bar;
27537
+ const decked = multi && !expanded;
27538
+ const placed = [];
27539
+ const deckSize = list[0].marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
27540
+ let bottom = input.axisY - MARK_LANE_INSET;
27541
+ list.forEach((cluster, depth) => {
27542
+ const size = decked ? deckSize : cluster.marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
27543
+ let y;
27544
+ if (expanded) {
27545
+ y = bottom - size / 2;
27546
+ bottom -= size + MARK_FAN_GAP;
27547
+ } else {
27548
+ y = input.axisY - MARK_LANE_INSET - size / 2 - depth * MARK_DECK_STEP;
27549
+ }
27550
+ placed.push({ cluster, x, y, size, stack: bar, depth, decked });
27551
+ });
27552
+ for (let i = placed.length - 1; i >= 0; i--) glyphs.push(placed[i]);
27553
+ stacks.set(bar, placed);
27554
+ }
27555
+ return { glyphs, stacks };
27556
+ }
27557
+ function markGlyphAt(layout, x, y) {
27558
+ for (let i = layout.glyphs.length - 1; i >= 0; i--) {
27559
+ const g = layout.glyphs[i];
27560
+ if (g.decked && g.depth !== 0) continue;
27561
+ const r = g.size / 2 + MARK_HIT_PAD;
27562
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return g;
27563
+ }
27564
+ return null;
27565
+ }
27566
+ function markStackAt(layout, x, y) {
27567
+ for (const [bar, placed] of layout.stacks) {
27568
+ for (const g of placed) {
27569
+ const r = g.size / 2 + MARK_HIT_PAD;
27570
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return bar;
27571
+ }
27572
+ if (placed.length > 1 && !placed[0].decked) {
27573
+ const top = placed[placed.length - 1];
27574
+ const base = placed[0];
27575
+ const r = Math.max(top.size, base.size) / 2 + MARK_HIT_PAD;
27576
+ 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;
27577
+ }
27578
+ }
27579
+ return null;
27580
+ }
27581
+ function clusterTooltip(cluster, groups) {
27582
+ const first = cluster.marks[0];
27583
+ if (!first) return null;
27584
+ if (cluster.marks.length === 1) return first.tooltip ?? first.title ?? null;
27585
+ const label = cluster.group !== void 0 ? markGroupLabel(cluster.group, groups) : first.title ?? first.tooltip ?? "Marks";
27586
+ return `${label} \xB7 ${cluster.marks.length}`;
27587
+ }
27588
+ function markGroupLabel(groupId, groups) {
27589
+ const def = groups.find((g) => g.id === groupId);
27590
+ if (def) return def.label;
27591
+ return groupId.charAt(0).toUpperCase() + groupId.slice(1);
27592
+ }
27593
+ function effectiveMarkGroups(marks, groups) {
27594
+ const out = groups.map((g) => ({ ...g }));
27595
+ const seen = new Set(out.map((g) => g.id));
27596
+ for (const m of marks) {
27597
+ if (m.group === void 0 || seen.has(m.group)) continue;
27598
+ seen.add(m.group);
27599
+ out.push({ id: m.group, label: markGroupLabel(m.group, groups) });
27600
+ }
27601
+ return out;
27602
+ }
27603
+
27604
+ // src/renderers/native/chrome/marks/paint.ts
27605
+ var MARK_PULSE_MS = 360;
27606
+ var MARK_PULSE_AMPLITUDE = 0.1;
27607
+ var ACTIVE_INK = "#ffffff";
27608
+ function pulseScale(elapsedMs) {
27609
+ if (!(elapsedMs > 0) || elapsedMs >= MARK_PULSE_MS) return 1;
27610
+ return 1 + MARK_PULSE_AMPLITUDE * Math.sin(Math.PI * elapsedMs / MARK_PULSE_MS);
27611
+ }
27612
+ function paintMarkLane(ctx, layout, deps) {
27613
+ if (layout.glyphs.length === 0) return;
27614
+ ctx.save();
27615
+ ctx.setLineDash([]);
27616
+ ctx.textAlign = "center";
27617
+ ctx.textBaseline = "middle";
27618
+ for (const g of layout.glyphs) {
27619
+ const mark = g.cluster.marks[0];
27620
+ if (!mark) continue;
27621
+ const key = g.cluster.key;
27622
+ const color = mark.glyph.color;
27623
+ const active = key === deps.activeKey || key === deps.flashKey;
27624
+ const size = g.size * (key === deps.hoverKey ? pulseScale(deps.nowMs - deps.hoverSince) : 1);
27625
+ if (g.depth === 0) {
27626
+ const sx = Math.round(g.x) + 0.5;
27627
+ ctx.lineWidth = 1;
27628
+ ctx.strokeStyle = deps.stemColor;
27629
+ ctx.beginPath();
27630
+ ctx.moveTo(sx, g.y + g.size / 2);
27631
+ ctx.lineTo(sx, deps.axisY);
27632
+ ctx.stroke();
27633
+ }
27634
+ const shape = mark.glyph.shape ?? "circle";
27635
+ const center = traceShape(ctx, shape, g.x, g.y, size);
27636
+ ctx.lineWidth = 4;
27637
+ ctx.strokeStyle = deps.background;
27638
+ ctx.stroke();
27639
+ ctx.fillStyle = active ? color : deps.background;
27640
+ ctx.fill();
27641
+ ctx.lineWidth = 1.5;
27642
+ ctx.strokeStyle = color;
27643
+ ctx.stroke();
27644
+ const ink = active ? ACTIVE_INK : color;
27645
+ const symbolPx = Math.round(size * 0.62);
27646
+ if (mark.glyph.icon) {
27647
+ const img = deps.icons.get(mark.glyph.icon, ink, symbolPx, deps.dpr);
27648
+ if (img) ctx.drawImage(img, center.x - symbolPx / 2, center.y - symbolPx / 2, symbolPx, symbolPx);
27649
+ } else if (mark.glyph.letter) {
27650
+ ctx.fillStyle = ink;
27651
+ ctx.font = `600 ${Math.round(size * 0.58)}px ${deps.fontFamily}`;
27652
+ ctx.fillText(mark.glyph.letter.slice(0, 2), center.x, center.y + 0.5);
27653
+ }
27654
+ }
27655
+ ctx.restore();
27656
+ }
27657
+ function traceShape(ctx, shape, x, y, size) {
27658
+ const r = size / 2;
27659
+ ctx.beginPath();
27660
+ switch (shape) {
27661
+ case "square": {
27662
+ const c = Math.min(3, r / 2);
27663
+ roundedRect(ctx, x - r, y - r, size, size, c);
27664
+ return { x, y };
27665
+ }
27666
+ case "diamond":
27667
+ ctx.moveTo(x, y - r);
27668
+ ctx.lineTo(x + r, y);
27669
+ ctx.lineTo(x, y + r);
27670
+ ctx.lineTo(x - r, y);
27671
+ ctx.closePath();
27672
+ return { x, y };
27673
+ case "pin": {
27674
+ const hr = r * 0.82;
27675
+ const hy = y - r + hr;
27676
+ ctx.arc(x, hy, hr, Math.PI * 0.75, Math.PI * 0.25, false);
27677
+ ctx.lineTo(x, y + r);
27678
+ ctx.closePath();
27679
+ return { x, y: hy };
27680
+ }
27681
+ case "circle":
27682
+ default:
27683
+ ctx.arc(x, y, r, 0, Math.PI * 2);
27684
+ return { x, y };
27685
+ }
27686
+ }
27687
+ function roundedRect(ctx, x, y, w, h, radius) {
27688
+ ctx.moveTo(x + radius, y);
27689
+ ctx.lineTo(x + w - radius, y);
27690
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
27691
+ ctx.lineTo(x + w, y + h - radius);
27692
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
27693
+ ctx.lineTo(x + radius, y + h);
27694
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
27695
+ ctx.lineTo(x, y + radius);
27696
+ ctx.quadraticCurveTo(x, y, x + radius, y);
27697
+ ctx.closePath();
27698
+ }
27699
+ var MarkIconRaster = class {
27700
+ constructor(onReady) {
27701
+ this.onReady = onReady;
27702
+ this.cache = /* @__PURE__ */ new Map();
27703
+ }
27704
+ get(icon2, ink, px, dpr) {
27705
+ const key = `${icon2}|${ink}|${px}|${dpr}`;
27706
+ if (this.cache.has(key)) {
27707
+ const img2 = this.cache.get(key);
27708
+ return img2 && img2.complete && img2.naturalWidth > 0 ? img2 : null;
27709
+ }
27710
+ const markup = iconMarkup(icon2);
27711
+ if (!markup || typeof document === "undefined" || typeof Image === "undefined" || typeof XMLSerializer === "undefined") {
27712
+ this.cache.set(key, null);
27713
+ return null;
27714
+ }
27715
+ const svg = standaloneSvg(markup, ink, Math.max(1, Math.ceil(px * dpr)));
27716
+ if (!svg) {
27717
+ this.cache.set(key, null);
27718
+ return null;
27719
+ }
27720
+ const img = new Image();
27721
+ img.onload = () => this.onReady();
27722
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
27723
+ this.cache.set(key, img);
27724
+ return null;
27725
+ }
27726
+ clear() {
27727
+ this.cache.clear();
27728
+ }
27729
+ };
27730
+ function standaloneSvg(markup, ink, px) {
27731
+ const tpl = document.createElement("template");
27732
+ tpl.innerHTML = markup;
27733
+ const svg = tpl.content.firstElementChild;
27734
+ if (!svg || svg.tagName.toLowerCase() !== "svg") return null;
27735
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
27736
+ svg.setAttribute("width", String(px));
27737
+ svg.setAttribute("height", String(px));
27738
+ svg.setAttribute("color", ink);
27739
+ return new XMLSerializer().serializeToString(svg);
27740
+ }
27741
+
27062
27742
  // src/renderers/native/chrome/ChromeRenderer.ts
27063
27743
  var ChromeRenderer = class {
27064
27744
  constructor() {
@@ -27068,11 +27748,40 @@ var ChromeRenderer = class {
27068
27748
  this.axisTextColor = DARK_THEME.textColor;
27069
27749
  // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
27070
27750
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
27751
+ /** The timeline-mark lane as laid out by the last frame — what hover/click hit-test against. */
27752
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27753
+ /** Registry icons rasterized for the lane; the owner is asked for a chrome repaint when one lands. */
27754
+ this.markIcons = new MarkIconRaster(() => this.onMarkIconReady?.());
27755
+ this.onMarkIconReady = null;
27756
+ /** Bar open times of the current series, rebuilt only when the array or its length changes (a live tick keeps both). */
27757
+ this.barTimesSrc = null;
27758
+ this.barTimesCache = [];
27071
27759
  }
27072
27760
  mount(canvas) {
27073
27761
  this.canvas = canvas;
27074
27762
  this.ctx = canvas.getContext("2d");
27075
27763
  }
27764
+ /** Where to ask for a chrome repaint when a lane icon finishes rasterizing. */
27765
+ setMarkIconReady(cb) {
27766
+ this.onMarkIconReady = cb;
27767
+ }
27768
+ /** The interactive mark glyph under a plot point (last frame's layout), or null. */
27769
+ markGlyphAt(x, y) {
27770
+ return markGlyphAt(this.markLayout, x, y);
27771
+ }
27772
+ /** The mark stack (bar index) whose glyphs — or the gaps of its fan — cover a plot point. */
27773
+ markStackAt(x, y) {
27774
+ return markStackAt(this.markLayout, x, y);
27775
+ }
27776
+ /** A glyph of the last frame by its cluster key — how an open popup follows its anchor. */
27777
+ markGlyphByKey(key) {
27778
+ return this.markLayout.glyphs.find((g) => g.cluster.key === key) ?? null;
27779
+ }
27780
+ /** Hover text of the mark glyph under a plot point, or null. */
27781
+ markTooltipAt(x, y, groups) {
27782
+ const g = this.markGlyphAt(x, y);
27783
+ return g ? clusterTooltip(g.cluster, groups) : null;
27784
+ }
27076
27785
  /** Wire the drawing coordinate resolvers + theme (call once per frame before use). */
27077
27786
  prepare(scene, coords, theme) {
27078
27787
  this.drawScene.setDeps({
@@ -27121,6 +27830,7 @@ var ChromeRenderer = class {
27121
27830
  const panes = scene.orderedPanes();
27122
27831
  if (coords.barCount === 0) {
27123
27832
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
27833
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27124
27834
  return;
27125
27835
  }
27126
27836
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
@@ -27134,6 +27844,46 @@ var ChromeRenderer = class {
27134
27844
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
27135
27845
  this.drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane);
27136
27846
  this.drawTimeAxis(ctx, scene, coords, theme, dataW, dataH, fullH);
27847
+ this.drawMarkLane(ctx, scene, coords, theme, dataW, dataH);
27848
+ }
27849
+ /** The timeline-mark lane — after the axis, so the tokens read over the plot's bottom edge. */
27850
+ drawMarkLane(ctx, scene, coords, theme, dataW, dataH) {
27851
+ if (!scene.marks.visible || scene.timelineMarks.length === 0) {
27852
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27853
+ return;
27854
+ }
27855
+ this.markLayout = layoutMarkLane({
27856
+ marks: scene.timelineMarks,
27857
+ groups: scene.markGroups,
27858
+ hidden: (groupId) => !markGroupVisible(scene.marks, groupId, scene.markGroups),
27859
+ barTimes: this.barTimes(scene),
27860
+ intervalMs: coords.barInterval,
27861
+ xOf: (bar) => coords.logicalToX(bar),
27862
+ axisY: dataH,
27863
+ dataW,
27864
+ expanded: scene.marksExpandedStack
27865
+ });
27866
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
27867
+ paintMarkLane(ctx, this.markLayout, {
27868
+ axisY: dataH,
27869
+ background: theme.background,
27870
+ stemColor: scene.style.borderColor ?? theme.borderColor,
27871
+ fontFamily: theme.fontFamily,
27872
+ dpr: coords.dpr,
27873
+ icons: this.markIcons,
27874
+ hoverKey: scene.marksHoverKey,
27875
+ hoverSince: scene.marksHoverSince,
27876
+ activeKey: scene.marksActiveKey,
27877
+ flashKey: scene.marksFlash && scene.marksFlash.until > nowMs ? scene.marksFlash.key : null,
27878
+ nowMs
27879
+ });
27880
+ }
27881
+ barTimes(scene) {
27882
+ if (this.barTimesSrc !== scene.bars || this.barTimesCache.length !== scene.bars.length) {
27883
+ this.barTimesSrc = scene.bars;
27884
+ this.barTimesCache = scene.bars.map((b) => b.time);
27885
+ }
27886
+ return this.barTimesCache;
27137
27887
  }
27138
27888
  destroy() {
27139
27889
  this.canvas = null;
@@ -27249,7 +27999,8 @@ var ChromeRenderer = class {
27249
27999
  * - the countdown-to-bar-close chip (`showCountdown`).
27250
28000
  * When the label and countdown are both on they merge into one stacked block (countdown
27251
28001
  * under the label, text flushed left); a lone label or countdown is centered on the
27252
- * price level with centered text. The countdown ticks once per second (repaint scheduled).
28002
+ * price level with centered text. The countdown repaints on the renderer's second pulse
28003
+ * and disappears once the bar has closed, until the next bar arrives.
27253
28004
  */
27254
28005
  drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane) {
27255
28006
  const n = scene.bars.length;
@@ -27269,17 +28020,16 @@ var ChromeRenderer = class {
27269
28020
  ctx.stroke();
27270
28021
  setDash2(ctx, "solid");
27271
28022
  }
27272
- const interval = coords.barInterval;
27273
- const showCountdown = scene.showCountdown && interval > 0;
28023
+ const cdText = scene.showCountdown ? countdownText(last.time, coords.barInterval, Date.now()) : null;
28024
+ const showCountdown = cdText !== null;
27274
28025
  const showLabel = scene.showPriceLabel;
27275
28026
  if (!showLabel && !showCountdown) return;
27276
28027
  const priceText = formatAxisValue(pricePane.scale, pricePane.bounds.height, last.close, percentScaleFor(scene, pricePane), scene.priceMintick);
27277
- const cdText = showCountdown ? formatCountdown(last.time + interval - Date.now()) : "";
27278
28028
  const PAD = 8;
27279
28029
  const x = dataW + 1;
27280
28030
  const textColor = tagTextColor(color, theme.background);
27281
28031
  ctx.textBaseline = "middle";
27282
- if (showLabel && showCountdown) {
28032
+ if (showLabel && cdText !== null) {
27283
28033
  const w2 = Math.max(ctx.measureText(priceText).width, ctx.measureText(cdText).width) + PAD;
27284
28034
  const top = y - 8;
27285
28035
  const tx = x + PAD / 2;
@@ -27292,7 +28042,7 @@ var ChromeRenderer = class {
27292
28042
  ctx.textAlign = "start";
27293
28043
  return;
27294
28044
  }
27295
- const text = showLabel ? priceText : cdText;
28045
+ const text = showLabel ? priceText : cdText ?? "";
27296
28046
  const w = ctx.measureText(text).width + PAD;
27297
28047
  ctx.fillStyle = color;
27298
28048
  ctx.fillRect(x, y - 8, w, 16);
@@ -27363,29 +28113,6 @@ function setDash2(ctx, style) {
27363
28113
  else if (style === "dotted") ctx.setLineDash([2, 3]);
27364
28114
  else ctx.setLineDash([]);
27365
28115
  }
27366
- function tagTextColor(bg, over) {
27367
- const [r, g, b, a] = parseColor(bg);
27368
- let R = r;
27369
- let G = g;
27370
- let B = b;
27371
- if (a < 1) {
27372
- const [or, og, ob] = parseColor(over);
27373
- R = r * a + or * (1 - a);
27374
- G = g * a + og * (1 - a);
27375
- B = b * a + ob * (1 - a);
27376
- }
27377
- const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
27378
- const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
27379
- return L >= 0.4 ? "#000000" : "#ffffff";
27380
- }
27381
- function formatCountdown(ms) {
27382
- const total = Math.max(0, Math.floor(ms / 1e3));
27383
- const s = total % 60;
27384
- const m = Math.floor(total / 60) % 60;
27385
- const h = Math.floor(total / 3600);
27386
- const pad = (v) => String(v).padStart(2, "0");
27387
- return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
27388
- }
27389
28116
 
27390
28117
  // src/renderers/native/chrome/LabelTooltip.ts
27391
28118
  var HOVER_DELAY_MS = 350;
@@ -27591,6 +28318,11 @@ function setDash3(ctx, style) {
27591
28318
  function settingsIdSlug(label) {
27592
28319
  return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27593
28320
  }
28321
+ var MARKS_SETTINGS_ID = "events";
28322
+ var MARKS_GROUPS_SETTINGS_ID = "events.groups";
28323
+ function markGroupSettingsId(groupId) {
28324
+ return `${MARKS_GROUPS_SETTINGS_ID}.${settingsIdSlug(groupId)}`;
28325
+ }
27594
28326
  function settingsIdHidden(id, hidden) {
27595
28327
  if (hidden.size === 0) return false;
27596
28328
  let path = id;
@@ -27684,7 +28416,11 @@ var BUILTIN_SETTINGS_IDS = [
27684
28416
  "symbol.style.baseline.base-level",
27685
28417
  "symbol.style.baseline.width",
27686
28418
  "symbol.animation",
28419
+ "symbol.animation.zoom",
28420
+ "symbol.animation.pan",
28421
+ "symbol.animation.autoscale",
27687
28422
  "symbol.animation.price-changes",
28423
+ "symbol.animation.intro",
27688
28424
  "symbol.timezone",
27689
28425
  "scales",
27690
28426
  "scales.price-scale",
@@ -27710,8 +28446,13 @@ var BUILTIN_SETTINGS_IDS = [
27710
28446
  "canvas.grid.horizontal",
27711
28447
  "canvas.theme"
27712
28448
  ];
27713
- function settingsIdCatalog(hostSections) {
28449
+ function settingsIdCatalog(hostSections, markGroups = []) {
27714
28450
  const ids = new Set(BUILTIN_SETTINGS_IDS);
28451
+ if (markGroups.length > 0) {
28452
+ ids.add(MARKS_SETTINGS_ID);
28453
+ ids.add(MARKS_GROUPS_SETTINGS_ID);
28454
+ for (const g of markGroups) ids.add(markGroupSettingsId(g.id));
28455
+ }
27715
28456
  for (const def of chartTypes()) {
27716
28457
  if (hasOwnCandlePaint(def.id)) {
27717
28458
  const style = `symbol.style.${def.id}`;
@@ -27751,7 +28492,7 @@ function styleLabel(id) {
27751
28492
  return chartType(id)?.label ?? BUILTIN_STYLE_LABELS2[id] ?? id;
27752
28493
  }
27753
28494
  var SD_STYLE_ID = "vela-settings-controls";
27754
- var SD_STYLE_REV = "5";
28495
+ var SD_STYLE_REV = "6";
27755
28496
  var SETTINGS_BORDER = "var(--vela-border)";
27756
28497
  function ensureControlStyles() {
27757
28498
  if (typeof document === "undefined") return;
@@ -27827,7 +28568,15 @@ ${overlayScrollbarCss(".vela-sd-pane")}
27827
28568
  .vela-sd-mobile .vela-select-trigger,.vela-sd-mobile .vela-num input,.vela-sd-mobile .vela-width-field{height:34px;}
27828
28569
  .vela-sd-mobile .vela-sd-close{width:40px;height:40px;}
27829
28570
  .vela-sd-mobile .vela-sd-btn{height:38px;}
27830
- .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}`;
28571
+ .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}
28572
+ /* The wrap rule above is for ROW LABELS only: a select's closed value must keep its
28573
+ single-line ellipsis, or a long option wraps to several lines inside the 34px
28574
+ trigger and spills over the rows around it. Three classes so it outranks the
28575
+ two-classes-plus-element selector above. The kit's fixed 100px column is a desktop
28576
+ alignment device; on mobile the trigger hugs its value instead (the grid's control
28577
+ column is max-content), capped so a long option still ellipsizes before the label. */
28578
+ .vela-sd-mobile .vela-select-trigger .vela-select-label{white-space:nowrap !important;}
28579
+ .vela-sd-mobile .vela-select:not([data-fill]){width:auto;min-width:100px;max-width:min(220px,55vw);}`;
27831
28580
  if (!existing) document.head.appendChild(st);
27832
28581
  }
27833
28582
  var SettingsDialog = class {
@@ -27842,6 +28591,9 @@ var SettingsDialog = class {
27842
28591
  this.config = null;
27843
28592
  this.syncTypeTabs = null;
27844
28593
  this.hostSections = [];
28594
+ /** The timeline-mark groups (defined + named by marks) — one checkbox each on the Events tab. */
28595
+ this.markGroups = [];
28596
+ this.markGroupVisible = () => true;
27845
28597
  /** The Canvas → Theme row: current app theme + where a pick is raised. The row is a
27846
28598
  * host callback, NOT a config patch — the app theme stays out of the persisted
27847
28599
  * `ChartConfig`, so exported templates never carry it. */
@@ -27866,6 +28618,11 @@ var SettingsDialog = class {
27866
28618
  setHostSections(sections) {
27867
28619
  this.hostSections = sections;
27868
28620
  }
28621
+ /** The timeline-mark groups and their current visibility — the Events tab's rows on next open. */
28622
+ setMarkGroups(groups, visible) {
28623
+ this.markGroups = groups;
28624
+ this.markGroupVisible = visible;
28625
+ }
27869
28626
  /** Replace the visibility policy — an open dialog rebuilds in place to honor it. */
27870
28627
  setHiddenSettings(ids) {
27871
28628
  const next = new Set(ids);
@@ -28039,12 +28796,36 @@ var SettingsDialog = class {
28039
28796
  }
28040
28797
  showActive(config.series.style);
28041
28798
  body.append(sid(this.sectionTitle("Animation"), "symbol.animation"));
28799
+ body.append(sid(this.boolRow(
28800
+ "Animate zoom",
28801
+ config.animations.zoom,
28802
+ (v) => this.emit({ animations: { zoom: v } }),
28803
+ this.hint("Glide the chart to each zoom step instead of jumping.")
28804
+ ), "symbol.animation.zoom"));
28805
+ body.append(sid(this.boolRow(
28806
+ "Pan momentum",
28807
+ config.animations.pan,
28808
+ (v) => this.emit({ animations: { pan: v } }),
28809
+ this.hint("Keep gliding briefly after a drag release, and ease scroll-to-latest and keyboard pans.")
28810
+ ), "symbol.animation.pan"));
28811
+ body.append(sid(this.boolRow(
28812
+ "Animate price scale",
28813
+ config.animations.autoscale,
28814
+ (v) => this.emit({ animations: { autoscale: v } }),
28815
+ this.hint("Glide the price scale to its new range while zooming or panning.")
28816
+ ), "symbol.animation.autoscale"));
28042
28817
  body.append(sid(this.boolRow(
28043
28818
  "Animate price changes",
28044
28819
  config.priceScale.animateLastPrice,
28045
28820
  (v) => this.emit({ priceScale: { animateLastPrice: v } }),
28046
28821
  this.hint("Glide the live bar to each new price instead of snapping.")
28047
28822
  ), "symbol.animation.price-changes"));
28823
+ body.append(sid(this.boolRow(
28824
+ "Reveal on load",
28825
+ config.animations.intro,
28826
+ (v) => this.emit({ animations: { intro: v } }),
28827
+ this.hint("Draw the candles in when a chart first loads. Takes effect on the next load.")
28828
+ ), "symbol.animation.intro"));
28048
28829
  body.append(sid(this.sectionTitle("Time zone"), "symbol.timezone"));
28049
28830
  body.append(sid(this.selectRowLabeled("Time zone", normalizeTimezone(config.timeScale.timezone), timezoneOptions(config.timeScale.timezone), (v) => this.emit({ timeScale: { timezone: v } })), "symbol.timezone"));
28050
28831
  const renderHostSections = (placement) => {
@@ -28114,6 +28895,13 @@ var SettingsDialog = class {
28114
28895
  body.append(sid(this.sectionTitle("Theme"), "canvas.theme"));
28115
28896
  body.append(sid(this.selectRow("Color theme", tc.current === "dark" ? "Dark" : "Light", ["Dark", "Light"], (v) => tc.onSelect(v === "Dark" ? "dark" : "light")), "canvas.theme"));
28116
28897
  }
28898
+ if (this.markGroups.length > 0) {
28899
+ body.append(sid(this.section("Events"), MARKS_SETTINGS_ID));
28900
+ body.append(sid(this.sectionTitle("Visible events"), MARKS_GROUPS_SETTINGS_ID));
28901
+ for (const g of this.markGroups) {
28902
+ body.append(sid(this.boolRow(g.label, this.markGroupVisible(g.id), (v) => this.emit({ marks: { groups: { [g.id]: v } } })), markGroupSettingsId(g.id)));
28903
+ }
28904
+ }
28117
28905
  renderChartTypeSections("end");
28118
28906
  renderHostSections("end");
28119
28907
  if (this.hiddenSettings.size > 0) {
@@ -33673,6 +34461,383 @@ function expandScaleByPixels(scale, heightPx, abovePx, belowPx) {
33673
34461
  return { ...scale, min: scale.min - belowPx * perPx, max: scale.max + abovePx * perPx };
33674
34462
  }
33675
34463
 
34464
+ // src/ui/sanitize-html.ts
34465
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
34466
+ "a",
34467
+ "abbr",
34468
+ "b",
34469
+ "blockquote",
34470
+ "br",
34471
+ "code",
34472
+ "dd",
34473
+ "del",
34474
+ "div",
34475
+ "dl",
34476
+ "dt",
34477
+ "em",
34478
+ "h1",
34479
+ "h2",
34480
+ "h3",
34481
+ "h4",
34482
+ "h5",
34483
+ "h6",
34484
+ "hr",
34485
+ "i",
34486
+ "img",
34487
+ "ins",
34488
+ "kbd",
34489
+ "li",
34490
+ "mark",
34491
+ "ol",
34492
+ "p",
34493
+ "pre",
34494
+ "q",
34495
+ "s",
34496
+ "small",
34497
+ "span",
34498
+ "strong",
34499
+ "sub",
34500
+ "sup",
34501
+ "table",
34502
+ "tbody",
34503
+ "td",
34504
+ "tfoot",
34505
+ "th",
34506
+ "thead",
34507
+ "tr",
34508
+ "u",
34509
+ "ul"
34510
+ ]);
34511
+ var DROPPED_TAGS = /* @__PURE__ */ new Set([
34512
+ "script",
34513
+ "style",
34514
+ "iframe",
34515
+ "frame",
34516
+ "frameset",
34517
+ "object",
34518
+ "embed",
34519
+ "applet",
34520
+ "form",
34521
+ "input",
34522
+ "textarea",
34523
+ "button",
34524
+ "select",
34525
+ "option",
34526
+ "link",
34527
+ "meta",
34528
+ "base",
34529
+ "svg",
34530
+ "math",
34531
+ "template",
34532
+ "noscript",
34533
+ "audio",
34534
+ "video",
34535
+ "canvas",
34536
+ "dialog",
34537
+ "head",
34538
+ "title"
34539
+ ]);
34540
+ var ALLOWED_ATTRS = {
34541
+ a: /* @__PURE__ */ new Set(["href"]),
34542
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height"]),
34543
+ td: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
34544
+ th: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
34545
+ ol: /* @__PURE__ */ new Set(["start"])
34546
+ };
34547
+ var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "vbscript", "data", "file", "blob"]);
34548
+ function tagDisposition(tag) {
34549
+ const t = tag.toLowerCase();
34550
+ if (DROPPED_TAGS.has(t)) return "drop";
34551
+ return ALLOWED_TAGS.has(t) ? "keep" : "unwrap";
34552
+ }
34553
+ function attributeAllowed(tag, name) {
34554
+ const n = name.toLowerCase();
34555
+ if (n.startsWith("on") || n === "style") return false;
34556
+ if (n === "title") return true;
34557
+ return ALLOWED_ATTRS[tag.toLowerCase()]?.has(n) ?? false;
34558
+ }
34559
+ function safeUrl(value, absoluteOnly = false) {
34560
+ let url = "";
34561
+ for (const ch of value) if (ch.charCodeAt(0) > 32) url += ch;
34562
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(url);
34563
+ const scheme = m ? m[1].toLowerCase() : null;
34564
+ if (scheme !== null && BLOCKED_SCHEMES.has(scheme)) return null;
34565
+ if (absoluteOnly && scheme !== "http" && scheme !== "https") return null;
34566
+ return url;
34567
+ }
34568
+ var ELEMENT_NODE = 1;
34569
+ var TEXT_NODE = 3;
34570
+ function sanitizeHtml(html, doc) {
34571
+ const tpl = doc.createElement("template");
34572
+ tpl.innerHTML = html;
34573
+ const out = doc.createDocumentFragment();
34574
+ copyChildren(tpl.content, out, doc);
34575
+ return out;
34576
+ }
34577
+ function copyChildren(from, to, doc) {
34578
+ for (const child of Array.from(from.childNodes)) {
34579
+ if (child.nodeType === TEXT_NODE) {
34580
+ to.appendChild(doc.createTextNode(child.textContent ?? ""));
34581
+ continue;
34582
+ }
34583
+ if (child.nodeType !== ELEMENT_NODE) continue;
34584
+ const el = child;
34585
+ const tag = el.tagName.toLowerCase();
34586
+ const disposition = tagDisposition(tag);
34587
+ if (disposition === "drop") continue;
34588
+ if (disposition === "unwrap") {
34589
+ copyChildren(el, to, doc);
34590
+ continue;
34591
+ }
34592
+ const clean = doc.createElement(tag);
34593
+ for (const attr of Array.from(el.attributes)) {
34594
+ const name = attr.name.toLowerCase();
34595
+ if (!attributeAllowed(tag, name)) continue;
34596
+ let value = attr.value;
34597
+ if (name === "href" || name === "src") {
34598
+ const safe = safeUrl(value, name === "src");
34599
+ if (safe === null) continue;
34600
+ value = safe;
34601
+ }
34602
+ clean.setAttribute(name, value);
34603
+ }
34604
+ if (tag === "a") {
34605
+ clean.setAttribute("target", "_blank");
34606
+ clean.setAttribute("rel", "noopener noreferrer");
34607
+ }
34608
+ copyChildren(el, clean, doc);
34609
+ to.appendChild(clean);
34610
+ }
34611
+ }
34612
+
34613
+ // src/renderers/native/chrome/marks/MarkPopover.ts
34614
+ var MARKS_STYLE_ID = "vela-marks-popover";
34615
+ var MARKS_CSS = `
34616
+ .vela-marks-panel { padding: 0; gap: 0; min-width: 220px; max-width: 320px; max-height: 320px; overflow-y: auto; overscroll-behavior: contain; }
34617
+ .vela-marks-section { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; }
34618
+ .vela-marks-section + .vela-marks-section { border-top: 1px solid var(--vela-border); }
34619
+ .vela-marks-field { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
34620
+ .vela-marks-field-label { color: var(--vela-fg-muted); }
34621
+ .vela-marks-field-value { color: var(--vela-fg-bright); text-align: right; font-variant-numeric: tabular-nums; }
34622
+ .vela-marks-html { color: var(--vela-fg); line-height: 1.45; overflow-wrap: anywhere; }
34623
+ .vela-marks-html p { margin: 0 0 6px; }
34624
+ .vela-marks-html p:last-child { margin-bottom: 0; }
34625
+ .vela-marks-html a { color: var(--vela-accent); }
34626
+ .vela-marks-html img { max-width: 100%; height: auto; }
34627
+ .vela-marks-html table { border-collapse: collapse; }
34628
+ .vela-marks-html td, .vela-marks-html th { padding: 2px 6px; border: 1px solid var(--vela-border); }
34629
+ .vela-marks-html pre, .vela-marks-html code { font-family: var(--vela-font-mono, monospace); font-size: 0.92em; }
34630
+ .vela-marks-loading, .vela-marks-error { color: var(--vela-fg-muted); font-style: italic; }
34631
+ `;
34632
+ var MarkPopover = class {
34633
+ constructor(deps) {
34634
+ this.deps = deps;
34635
+ this.pop = null;
34636
+ this.openKey = null;
34637
+ /** Bumped per open/close — a lazy content resolving after its popup went away is dropped. */
34638
+ this.generation = 0;
34639
+ const doc = deps.plot.ownerDocument;
34640
+ injectStyles(CALLOUT_STYLE_ID, CALLOUT_CSS, doc);
34641
+ injectStyles(MARKS_STYLE_ID, MARKS_CSS, doc);
34642
+ this.anchor = doc.createElement("div");
34643
+ this.anchor.className = "vela-marks-anchor";
34644
+ Object.assign(this.anchor.style, { position: "absolute", pointerEvents: "none", left: "0", top: "0", width: "0", height: "0" });
34645
+ deps.plot.appendChild(this.anchor);
34646
+ }
34647
+ /** The cluster key the open popup belongs to, or null. */
34648
+ get key() {
34649
+ return this.openKey;
34650
+ }
34651
+ open(cluster, rect) {
34652
+ this.close();
34653
+ this.place(rect);
34654
+ const gen = ++this.generation;
34655
+ this.openKey = cluster.key;
34656
+ this.pop = new Popover({
34657
+ trigger: this.anchor,
34658
+ host: this.deps.host(),
34659
+ theme: this.deps.theme(),
34660
+ gap: 8,
34661
+ align: "center",
34662
+ // centered on the glyph
34663
+ fadeMs: 120,
34664
+ // a short, discreet fade in and out
34665
+ className: "vela-marks-pop",
34666
+ content: (body) => this.build(body, cluster, gen),
34667
+ onClose: () => {
34668
+ if (this.generation === gen) {
34669
+ this.openKey = null;
34670
+ this.pop = null;
34671
+ this.deps.onOpenChange?.(null);
34672
+ }
34673
+ }
34674
+ });
34675
+ this.pop.show();
34676
+ this.deps.onOpenChange?.(cluster.key);
34677
+ }
34678
+ /** Follow the anchor glyph after a repaint; `null` (glyph gone — hidden, scrolled off, marks replaced) closes. */
34679
+ track(rect) {
34680
+ if (!this.pop) return;
34681
+ if (!rect) {
34682
+ this.close();
34683
+ return;
34684
+ }
34685
+ this.place(rect);
34686
+ this.pop.reposition();
34687
+ }
34688
+ close() {
34689
+ const pop = this.pop;
34690
+ const wasOpen = this.openKey !== null;
34691
+ this.pop = null;
34692
+ this.openKey = null;
34693
+ this.generation++;
34694
+ pop?.destroy();
34695
+ if (wasOpen) this.deps.onOpenChange?.(null);
34696
+ }
34697
+ destroy() {
34698
+ this.close();
34699
+ this.anchor.remove();
34700
+ }
34701
+ place(rect) {
34702
+ Object.assign(this.anchor.style, {
34703
+ left: `${rect.x - rect.size / 2}px`,
34704
+ top: `${rect.y - rect.size / 2}px`,
34705
+ width: `${rect.size}px`,
34706
+ height: `${rect.size}px`
34707
+ });
34708
+ }
34709
+ build(body, cluster, gen) {
34710
+ const doc = body.ownerDocument;
34711
+ const root = doc.createElement("div");
34712
+ root.className = "vela-callout-panel vela-marks-panel";
34713
+ const pending = [];
34714
+ for (const mark of cluster.marks) {
34715
+ const section = doc.createElement("section");
34716
+ section.className = "vela-marks-section";
34717
+ if (mark.title) {
34718
+ const title = doc.createElement("div");
34719
+ title.className = "vela-callout-title";
34720
+ title.textContent = mark.title;
34721
+ section.appendChild(title);
34722
+ }
34723
+ const content = mark.content;
34724
+ if (typeof content === "function") {
34725
+ const slot = doc.createElement("div");
34726
+ slot.className = "vela-marks-loading";
34727
+ slot.textContent = "Loading\u2026";
34728
+ section.appendChild(slot);
34729
+ pending.push({ el: section, resolve: () => this.resolveLazy(content, slot, gen) });
34730
+ } else if (content !== void 0) {
34731
+ this.renderContent(section, content);
34732
+ }
34733
+ if (section.childElementCount > 0) root.appendChild(section);
34734
+ }
34735
+ body.appendChild(root);
34736
+ if (pending.length > 0) scheduleLazy(root, pending);
34737
+ }
34738
+ resolveLazy(source, slot, gen) {
34739
+ let result;
34740
+ try {
34741
+ result = Promise.resolve(source());
34742
+ } catch (err) {
34743
+ result = Promise.reject(err instanceof Error ? err : new Error(String(err)));
34744
+ }
34745
+ void result.then(
34746
+ (content) => {
34747
+ if (gen !== this.generation) return;
34748
+ const section = slot.parentElement;
34749
+ if (!section) return;
34750
+ slot.remove();
34751
+ this.renderContent(section, content);
34752
+ this.pop?.reposition();
34753
+ },
34754
+ () => {
34755
+ if (gen !== this.generation) return;
34756
+ slot.className = "vela-marks-error";
34757
+ slot.textContent = "Couldn\u2019t load this entry.";
34758
+ }
34759
+ );
34760
+ }
34761
+ renderContent(section, content) {
34762
+ const doc = section.ownerDocument;
34763
+ if (!content || typeof content !== "object") return;
34764
+ if ("text" in content) {
34765
+ const text = doc.createElement("div");
34766
+ text.className = "vela-callout-text";
34767
+ text.textContent = String(content.text);
34768
+ section.appendChild(text);
34769
+ return;
34770
+ }
34771
+ if ("html" in content) {
34772
+ const html = doc.createElement("div");
34773
+ html.className = "vela-marks-html";
34774
+ html.appendChild(sanitizeHtml(String(content.html), doc));
34775
+ section.appendChild(html);
34776
+ return;
34777
+ }
34778
+ if ("panel" in content && content.panel && Array.isArray(content.panel.items)) {
34779
+ let actions = null;
34780
+ for (const item of content.panel.items) {
34781
+ if (!item || typeof item !== "object") continue;
34782
+ if (item.type === "button") {
34783
+ if (!actions) {
34784
+ actions = doc.createElement("div");
34785
+ actions.className = "vela-callout-actions";
34786
+ section.appendChild(actions);
34787
+ }
34788
+ const btn2 = doc.createElement("button");
34789
+ btn2.type = "button";
34790
+ btn2.className = "vela-callout-btn" + (item.primary ? " vela-callout-btn-primary" : "");
34791
+ btn2.textContent = item.label;
34792
+ btn2.addEventListener("click", () => {
34793
+ item.run();
34794
+ if (item.close !== false) this.close();
34795
+ });
34796
+ actions.appendChild(btn2);
34797
+ continue;
34798
+ }
34799
+ actions = null;
34800
+ if (item.type === "text") {
34801
+ const text = doc.createElement("div");
34802
+ text.className = "vela-callout-text";
34803
+ text.textContent = item.text;
34804
+ section.appendChild(text);
34805
+ } else if (item.type === "field") {
34806
+ const row = doc.createElement("div");
34807
+ row.className = "vela-marks-field";
34808
+ const label = doc.createElement("span");
34809
+ label.className = "vela-marks-field-label";
34810
+ label.textContent = item.label;
34811
+ const value = doc.createElement("span");
34812
+ value.className = "vela-marks-field-value";
34813
+ value.textContent = item.value;
34814
+ row.append(label, value);
34815
+ section.appendChild(row);
34816
+ }
34817
+ }
34818
+ }
34819
+ }
34820
+ };
34821
+ function scheduleLazy(scroller, pending) {
34822
+ if (typeof IntersectionObserver === "undefined") {
34823
+ for (const p of pending) p.resolve();
34824
+ return;
34825
+ }
34826
+ const io = new IntersectionObserver(
34827
+ (entries) => {
34828
+ for (const e of entries) {
34829
+ if (!e.isIntersecting) continue;
34830
+ const p = pending.find((q) => q.el === e.target);
34831
+ if (!p) continue;
34832
+ io.unobserve(e.target);
34833
+ p.resolve();
34834
+ }
34835
+ },
34836
+ { root: scroller }
34837
+ );
34838
+ for (const p of pending) io.observe(p.el);
34839
+ }
34840
+
33676
34841
  // src/renderers/native/core/manualScale.ts
33677
34842
  function rescaleAround(start, factor) {
33678
34843
  if (start.log && start.min > 0 && start.max > start.min) {
@@ -34265,11 +35430,12 @@ var SCROLL_BTN_BOTTOM = TIME_AXIS_H + 14;
34265
35430
  var SCROLL_BTN_PROXIMITY_PX = 120;
34266
35431
  var MIN_VISIBLE_BARS = 2;
34267
35432
  var ZOOM_OUT_MARGIN_BARS = 6;
34268
- var ZOOM_TAU_MS = 70;
34269
- var SCALE_TAU_MS = 80;
34270
- var FLING_TAU_MS = 110;
34271
- var SCROLL_TO_TAU_MS = 130;
35433
+ var INTRO_MODEL_FADE_MS = 350;
34272
35434
  var FLING_STOP_PX = 0.02;
35435
+ var MARK_FLASH_MS = 220;
35436
+ function frameNow() {
35437
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
35438
+ }
34273
35439
  var PRICE_SCALE_K = 4e-3;
34274
35440
  var KEY_ZOOM_STEP = 0.2;
34275
35441
  var SEPARATOR_HIT_PX = 4;
@@ -34318,9 +35484,20 @@ var NativeRenderer = class {
34318
35484
  this.indicatorSlices = new IndicatorDrawingSlices();
34319
35485
  /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
34320
35486
  this.labelTooltip = null;
35487
+ /** The timeline-mark popup (a kit Popover anchored on a lane glyph); null before mount. */
35488
+ this.markPopover = null;
35489
+ /** How the fanned mark stack was opened: a hover folds when the pointer leaves, a tap only on a tap elsewhere. */
35490
+ this.marksExpandedBy = "hover";
35491
+ /** The rAF loop keeping the chrome repainting while a lane glyph pulses (hover) or flashes (click); null when idle. */
35492
+ this.markPulseRaf = null;
35493
+ this.markClickCbs = /* @__PURE__ */ new Set();
34321
35494
  this.crosshairLayer = new CrosshairRenderer();
34322
- /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
34323
- this.countdownTimer = null;
35495
+ /** The second pulse the countdown-to-bar-close chip ticks on: the host's (`setWallClock`)
35496
+ * when one is wired, else the renderer's own second-aligned clock. */
35497
+ this.hostClock = null;
35498
+ this.ownClock = null;
35499
+ /** Live subscription to the pulse while the countdown is on; null when off. */
35500
+ this.countdownUnsub = null;
34324
35501
  this.symbolPicker = null;
34325
35502
  /** Indicator titles (the legend rows) shown — held here so a remount re-applies it. */
34326
35503
  this.indicatorTitlesOn = true;
@@ -34338,19 +35515,25 @@ var NativeRenderer = class {
34338
35515
  /** Drawings layer self-serves Ctrl+Z/Y (see the `historyChords` feature). */
34339
35516
  this.historyChordsEnabled = true;
34340
35517
  this.liveRegion = null;
34341
- // ── animation state (eased zoom + inertial pan + live-bar glide) ──
34342
- this.animZoom = true;
34343
- this.animPan = true;
34344
- this.animLiveBarMs = 0;
34345
- // forming-bar OHLC glide time-constant; 0 = each tick snaps
34346
- this.animLiveBarOnMs = LIVE_BAR_EASE_DEFAULT_MS;
34347
- // the duration the settings-dialog on/off toggle restores (last non-zero value configured)
35518
+ // ── animation state: one ease time-constant per motion (0 = off), each remembering the
35519
+ // host's duration so the config's on/off switches restore it (see EaseSetting) ──
35520
+ this.animZoom = new EaseSetting(ZOOM_EASE_DEFAULT_MS);
35521
+ // wheel-zoom glide
35522
+ this.animPan = new EaseSetting(PAN_INERTIA_DEFAULT_MS);
35523
+ // inertial-pan velocity decay
35524
+ this.animScroll = new EaseSetting(SCROLL_EASE_DEFAULT_MS);
35525
+ // scroll-to-latest / panBy glide
35526
+ this.animAutoscale = new EaseSetting(AUTOSCALE_EASE_DEFAULT_MS);
35527
+ // autoscale glide during zoom/fling
35528
+ this.animLiveBar = new EaseSetting(LIVE_BAR_EASE_DEFAULT_MS, 0);
35529
+ // forming-bar OHLC glide; ships off
34348
35530
  // Brand default candles.
34349
35531
  this.candleUp = BULLISH;
34350
35532
  this.candleDown = BEARISH;
34351
35533
  // ── intro reveal (plays once when candles first appear) ──
34352
- this.introStyle = "settle";
34353
- // 'grow' | 'settle' | '' (off)
35534
+ this.intro = { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
35535
+ this.introOnStyle = "settle";
35536
+ // the style the config's on/off switch restores
34354
35537
  this.introPlayed = false;
34355
35538
  this.introRaf = null;
34356
35539
  /** The load affordance (three pulsing dots) — up while the host reports a bar load in
@@ -34469,7 +35652,7 @@ var NativeRenderer = class {
34469
35652
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
34470
35653
  this.priceStyleCbs = /* @__PURE__ */ new Set();
34471
35654
  this.name = "native";
34472
- 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"];
35655
+ 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"];
34473
35656
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
34474
35657
  * so moving onto the button doesn't count as leaving). */
34475
35658
  this.onScrollProximityMove = (e) => {
@@ -34501,9 +35684,12 @@ var NativeRenderer = class {
34501
35684
  this.scene.showPriceLine = opts.currentPriceLine;
34502
35685
  this.scene.logScale = opts.logScale;
34503
35686
  this.backendMode = opts.nativeBackend;
34504
- this.animZoom = opts.animZoom;
34505
- this.animPan = opts.animPan;
34506
- this.setLiveBarEase(resolveLiveBarEaseMs(opts.animLiveBar));
35687
+ this.animZoom.set(opts.animZoom);
35688
+ this.animPan.set(opts.animPan);
35689
+ this.animScroll.set(opts.animScroll);
35690
+ this.animAutoscale.set(opts.animAutoscale);
35691
+ this.animLiveBar.set(opts.animLiveBar);
35692
+ this.setIntro(opts.animIntro);
34507
35693
  this.glowAmount = opts.glow;
34508
35694
  this.candleUp = opts.upColor;
34509
35695
  this.candleDown = opts.downColor;
@@ -34547,20 +35733,29 @@ var NativeRenderer = class {
34547
35733
  if (this.backend && "glow" in this.backend) this.backend.glow = this.glowAmount;
34548
35734
  break;
34549
35735
  case "animZoom":
34550
- this.animZoom = Boolean(value);
35736
+ this.animZoom.set(resolveEaseMs(value, ZOOM_EASE_DEFAULT_MS));
34551
35737
  return;
34552
35738
  // affects the next interaction only — nothing to repaint
34553
- case "animPan":
34554
- this.animPan = Boolean(value);
35739
+ case "animPan": {
35740
+ const ms = resolveEaseMs(value, PAN_INERTIA_DEFAULT_MS);
35741
+ this.animPan.set(ms);
35742
+ this.animScroll.toggle(ms > 0);
35743
+ return;
35744
+ }
35745
+ case "animScroll":
35746
+ this.animScroll.set(resolveEaseMs(value, SCROLL_EASE_DEFAULT_MS));
35747
+ return;
35748
+ case "animAutoscale":
35749
+ this.animAutoscale.set(resolveEaseMs(value, AUTOSCALE_EASE_DEFAULT_MS));
34555
35750
  return;
35751
+ // a glide in flight finishes at the new rate (or snaps at 0)
34556
35752
  case "animLiveBar":
34557
- this.setLiveBarEase(resolveLiveBarEaseMs(value));
35753
+ this.animLiveBar.set(resolveLiveBarEaseMs(value));
34558
35754
  return;
34559
35755
  // affects the next tick only; a glide in flight finishes at the new rate (or snaps at 0)
34560
35756
  case "intro": {
34561
- const s = value === false || value === "none" || value === "off" || value == null ? "" : String(value);
34562
- this.introStyle = s;
34563
- if (s) this.playIntro(s);
35757
+ this.setIntro(resolveIntro(value));
35758
+ if (this.intro.style) this.playIntro();
34564
35759
  return;
34565
35760
  }
34566
35761
  case "zoomAnchor":
@@ -34632,6 +35827,10 @@ var NativeRenderer = class {
34632
35827
  case "tradeMarkers":
34633
35828
  this.scene.tradeMarkers = mergeTradeMarkersState(this.scene.tradeMarkers, value);
34634
35829
  break;
35830
+ case "marks":
35831
+ this.scene.marks = mergeMarksState(this.scene.marks, value);
35832
+ this.markPopover?.close();
35833
+ break;
34635
35834
  case "keyboard":
34636
35835
  this.setKeyboardEnabled(Boolean(value));
34637
35836
  return;
@@ -34690,13 +35889,17 @@ var NativeRenderer = class {
34690
35889
  case "glow":
34691
35890
  return this.glowAmount;
34692
35891
  case "animZoom":
34693
- return this.animZoom;
35892
+ return this.animZoom.tau;
34694
35893
  case "animPan":
34695
- return this.animPan;
35894
+ return this.animPan.tau;
35895
+ case "animScroll":
35896
+ return this.animScroll.tau;
35897
+ case "animAutoscale":
35898
+ return this.animAutoscale.tau;
34696
35899
  case "animLiveBar":
34697
- return this.animLiveBarMs;
35900
+ return this.animLiveBar.tau;
34698
35901
  case "intro":
34699
- return this.introStyle;
35902
+ return this.intro.style;
34700
35903
  case "zoomAnchor":
34701
35904
  return this.zoomAnchorMode;
34702
35905
  case "axisDrag":
@@ -34739,6 +35942,8 @@ var NativeRenderer = class {
34739
35942
  }
34740
35943
  case "tradeMarkers":
34741
35944
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
35945
+ case "marks":
35946
+ return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
34742
35947
  case "keyboard":
34743
35948
  return this.keyboardEnabled;
34744
35949
  case "historyChords":
@@ -34864,7 +36069,13 @@ var NativeRenderer = class {
34864
36069
  currentPriceLine: this.scene.showPriceLine,
34865
36070
  priceLabel: this.scene.showPriceLabel,
34866
36071
  countdown: this.scene.showCountdown,
34867
- animateLastPrice: this.animLiveBarMs > 0
36072
+ animateLastPrice: this.animLiveBar.on
36073
+ },
36074
+ animations: {
36075
+ zoom: this.animZoom.on,
36076
+ pan: this.animPan.on,
36077
+ autoscale: this.animAutoscale.on,
36078
+ intro: this.intro.style !== false
34868
36079
  },
34869
36080
  panes: { separatorColor: s.separatorColor ?? t.borderColor },
34870
36081
  trades: {
@@ -34876,6 +36087,7 @@ var NativeRenderer = class {
34876
36087
  exitColor: this.scene.tradeMarkers.colors.exit
34877
36088
  },
34878
36089
  timeScale: { timezone: this.scene.timezone },
36090
+ marks: { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } },
34879
36091
  candles: {
34880
36092
  upColor: this.candleUp,
34881
36093
  downColor: this.candleDown,
@@ -34971,7 +36183,12 @@ var NativeRenderer = class {
34971
36183
  this.scene.showPriceLabel = next.priceScale.priceLabel;
34972
36184
  this.scene.showCountdown = next.priceScale.countdown;
34973
36185
  this.syncCountdownTimer();
34974
- this.animLiveBarMs = next.priceScale.animateLastPrice ? this.animLiveBarOnMs : 0;
36186
+ this.animLiveBar.toggle(next.priceScale.animateLastPrice);
36187
+ this.animZoom.toggle(next.animations.zoom);
36188
+ this.animPan.toggle(next.animations.pan);
36189
+ this.animScroll.toggle(next.animations.pan);
36190
+ this.animAutoscale.toggle(next.animations.autoscale);
36191
+ this.intro = { style: next.animations.intro ? this.introOnStyle : false, duration: this.intro.duration || INTRO_DURATION_DEFAULT_MS };
34975
36192
  s.separatorColor = keepInherit(s.separatorColor, next.panes.separatorColor, prevTheme.borderColor);
34976
36193
  this.scene.tradeMarkers = {
34977
36194
  visible: next.trades.visible,
@@ -34980,6 +36197,7 @@ var NativeRenderer = class {
34980
36197
  colors: { long: next.trades.longColor, short: next.trades.shortColor, exit: next.trades.exitColor }
34981
36198
  };
34982
36199
  this.scene.timezone = next.timeScale.timezone;
36200
+ this.scene.marks = { visible: next.marks.visible, groups: { ...next.marks.groups } };
34983
36201
  this.candleUp = next.candles.upColor;
34984
36202
  this.candleDown = next.candles.downColor;
34985
36203
  s.candle = {
@@ -35112,6 +36330,7 @@ var NativeRenderer = class {
35112
36330
  }
35113
36331
  this.settingsDialog.setTheme(this.theme);
35114
36332
  this.settingsDialog.setHostSections(this.hostSettingsSections);
36333
+ this.settingsDialog.setMarkGroups(this.markGroupsInUse(), (id) => markGroupVisible(this.scene.marks, id, this.scene.markGroups));
35115
36334
  this.settingsDialog.setHiddenSettings(this.hiddenSettings);
35116
36335
  this.syncThemeControl();
35117
36336
  this.settingsDialog.toggle(
@@ -35202,10 +36421,10 @@ var NativeRenderer = class {
35202
36421
  this.glideRightOffset(ZOOM_OUT_MARGIN_BARS);
35203
36422
  }
35204
36423
  /** Ease rightOffset to `target` at constant zoom (see animTick's scroll glide);
35205
- * instant when pan animation is off. Shared by scroll-to-latest and panBy. */
36424
+ * instant when the scroll glide is off. Shared by scroll-to-latest and panBy. */
35206
36425
  glideRightOffset(target) {
35207
36426
  const vp = this.coords.getViewport();
35208
- if (!this.animPan) {
36427
+ if (!this.animScroll.on) {
35209
36428
  this.applyViewport({ barSpacing: vp.barSpacing, rightOffset: target });
35210
36429
  return;
35211
36430
  }
@@ -35270,20 +36489,21 @@ var NativeRenderer = class {
35270
36489
  * full size, eased, with a left→right stagger so the chart draws itself; `settle`
35271
36490
  * adds an ease-out-back overshoot. Autoscale stays on the real bars so the frame
35272
36491
  * never moves. Re-callable, so styles can be compared live from the console.
36492
+ * Style and sweep duration come from the resolved `intro` setting.
35273
36493
  */
35274
- playIntro(style) {
36494
+ playIntro() {
35275
36495
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
35276
36496
  this.introRaf = null;
36497
+ const { style, duration } = this.intro;
35277
36498
  const real = this.bars;
35278
36499
  const n = real.length;
35279
- if (n === 0) return;
36500
+ if (n === 0 || !style) return;
35280
36501
  this.computeScales();
35281
36502
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
35282
36503
  this.modelAlpha = 0;
35283
- const DURATION = 650;
35284
36504
  const start = performance.now();
35285
36505
  const step = (now) => {
35286
- const p = Math.min(1, (now - start) / DURATION);
36506
+ const p = Math.min(1, (now - start) / duration);
35287
36507
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
35288
36508
  this.paintData();
35289
36509
  if (p < 1) {
@@ -35297,10 +36517,10 @@ var NativeRenderer = class {
35297
36517
  }
35298
36518
  /** After the candle reveal, fade the indicator models (series/fills/…) from hidden to full. */
35299
36519
  fadeInModels() {
35300
- const FADE = 350;
36520
+ const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
35301
36521
  const start = performance.now();
35302
36522
  const step = (now) => {
35303
- this.modelAlpha = Math.min(1, (now - start) / FADE);
36523
+ this.modelAlpha = Math.min(1, (now - start) / fade);
35304
36524
  this.paintData();
35305
36525
  if (this.modelAlpha < 1) {
35306
36526
  this.introRaf = requestAnimationFrame(step);
@@ -35415,7 +36635,8 @@ var NativeRenderer = class {
35415
36635
  zoomTo: (target, anchorLogical, anchorX) => this.zoomTo(target, anchorLogical, anchorX),
35416
36636
  fling: (v) => this.fling(v),
35417
36637
  onPointerMove: (x, y) => this.handlePointerMove(x, y),
35418
- onClick: (x) => {
36638
+ onClick: (x, y) => {
36639
+ if (this.handleMarkClick(x, y)) return;
35419
36640
  this.userDrawings?.deselect();
35420
36641
  this.handleClick(x);
35421
36642
  },
@@ -35444,7 +36665,7 @@ var NativeRenderer = class {
35444
36665
  drawingsPointerDown: (x, y, snap, shift2, mod) => this.userDrawings?.pointerDown(x, y, snap, shift2, mod),
35445
36666
  drawingsPointerMove: (x, y, snap, shift2, mod) => this.userDrawings?.pointerMove(x, y, snap, shift2, mod),
35446
36667
  drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
35447
- drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
36668
+ drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? (this.chrome.markGlyphAt(x, y) ? "pointer" : null),
35448
36669
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
35449
36670
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
35450
36671
  });
@@ -35460,8 +36681,18 @@ var NativeRenderer = class {
35460
36681
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
35461
36682
  this.labelTooltip = new LabelTooltip(this.plot, {
35462
36683
  theme: () => this.chromeTheme(),
35463
- lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
36684
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y) ?? this.chrome.markTooltipAt(x, y, this.markGroupsInUse())
36685
+ });
36686
+ this.markPopover = new MarkPopover({
36687
+ plot: this.plot,
36688
+ host: () => this.dialogHost ?? this.plot,
36689
+ theme: () => this.chromeTheme(),
36690
+ onOpenChange: (key) => {
36691
+ this.scene.marksActiveKey = key;
36692
+ this.scheduler?.invalidate(2 /* Chrome */);
36693
+ }
35464
36694
  });
36695
+ this.chrome.setMarkIconReady(() => this.scheduler?.invalidate(2 /* Chrome */));
35465
36696
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
35466
36697
  projector: () => this.drawingProjector(),
35467
36698
  dpr: () => this.coords.dpr,
@@ -35676,29 +36907,40 @@ var NativeRenderer = class {
35676
36907
  resize() {
35677
36908
  this.syncSize();
35678
36909
  }
35679
- /** Run a 1 Hz repaint pump while the countdown chip is on (so it ticks); stop it otherwise.
35680
- * Chrome tier: only the chip's wall-clock text moves — an idle chart must not recompute
36910
+ /** Drive the countdown chip from the host's second pulse (`null` the renderer's own). */
36911
+ setWallClock(clock) {
36912
+ if (clock === this.hostClock) return;
36913
+ this.hostClock = clock;
36914
+ if (this.countdownUnsub != null) {
36915
+ this.countdownUnsub();
36916
+ this.countdownUnsub = null;
36917
+ }
36918
+ this.syncCountdownTimer();
36919
+ }
36920
+ /** Subscribe to the second pulse while the countdown chip is on (so it ticks); unsubscribe
36921
+ * otherwise. Chrome tier: only the chip's text moves — an idle chart must not recompute
35681
36922
  * scales or repaint the geometry/volume/VPVR/SDK layers once a second (that cost
35682
36923
  * multiplies by the cell count in a multi-chart workspace). */
35683
36924
  syncCountdownTimer() {
35684
36925
  if (this.scene.showCountdown) {
35685
- if (this.countdownTimer == null) {
35686
- this.countdownTimer = setInterval(() => {
36926
+ if (this.countdownUnsub == null) {
36927
+ const clock = this.hostClock ?? (this.ownClock ?? (this.ownClock = new SecondClock()));
36928
+ this.countdownUnsub = clock.onTick(() => {
35687
36929
  if (this.scene.showCountdown && this.scene.bars.length > 0) this.scheduler?.invalidate(2 /* Chrome */);
35688
- }, 1e3);
36930
+ });
35689
36931
  }
35690
- } else if (this.countdownTimer != null) {
35691
- clearInterval(this.countdownTimer);
35692
- this.countdownTimer = null;
36932
+ } else if (this.countdownUnsub != null) {
36933
+ this.countdownUnsub();
36934
+ this.countdownUnsub = null;
35693
36935
  }
35694
36936
  }
35695
36937
  destroy() {
35696
36938
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
35697
36939
  this.loadingEl?.remove();
35698
36940
  this.loadingEl = null;
35699
- if (this.countdownTimer != null) {
35700
- clearInterval(this.countdownTimer);
35701
- this.countdownTimer = null;
36941
+ if (this.countdownUnsub != null) {
36942
+ this.countdownUnsub();
36943
+ this.countdownUnsub = null;
35702
36944
  }
35703
36945
  this.scheduler?.destroy();
35704
36946
  this.animator?.stop();
@@ -35723,6 +36965,11 @@ var NativeRenderer = class {
35723
36965
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
35724
36966
  this.labelTooltip?.destroy();
35725
36967
  this.labelTooltip = null;
36968
+ this.markPopover?.destroy();
36969
+ this.markPopover = null;
36970
+ this.chrome.setMarkIconReady(null);
36971
+ if (this.markPulseRaf !== null) cancelAnimationFrame(this.markPulseRaf);
36972
+ this.markPulseRaf = null;
35726
36973
  this.scrollButton?.remove();
35727
36974
  this.scrollButton = null;
35728
36975
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -35781,8 +37028,8 @@ var NativeRenderer = class {
35781
37028
  }
35782
37029
  if (!this.introPlayed && this.bars.length > 0) {
35783
37030
  this.introPlayed = true;
35784
- if (this.introStyle) {
35785
- this.playIntro(this.introStyle);
37031
+ if (this.intro.style) {
37032
+ this.playIntro();
35786
37033
  return;
35787
37034
  }
35788
37035
  }
@@ -35793,7 +37040,7 @@ var NativeRenderer = class {
35793
37040
  const last = this.bars[n - 1];
35794
37041
  if (last && bar.time === last.time) {
35795
37042
  this.bars[n - 1] = bar;
35796
- if (this.animLiveBarMs <= 0 || this.liveEaseTime !== bar.time) {
37043
+ if (!this.animLiveBar.on || this.liveEaseTime !== bar.time) {
35797
37044
  this.syncLiveEase(bar);
35798
37045
  } else {
35799
37046
  this.animator.start();
@@ -35809,11 +37056,11 @@ var NativeRenderer = class {
35809
37056
  this.scene.bars = this.bars;
35810
37057
  this.scheduler.invalidate(4 /* Full */);
35811
37058
  }
35812
- /** Set the live-bar glide duration (0 = off). A non-zero value is also remembered as
35813
- * what the config's on/off toggle (`priceScale.animateLastPrice`) switches back on to. */
35814
- setLiveBarEase(ms) {
35815
- this.animLiveBarMs = ms;
35816
- if (ms > 0) this.animLiveBarOnMs = ms;
37059
+ /** Set the reveal (style + duration). A non-off style is also remembered as what the
37060
+ * config's on/off toggle (`animations.intro`) switches back on to. */
37061
+ setIntro(next) {
37062
+ this.intro = next;
37063
+ if (next.style) this.introOnStyle = next.style;
35817
37064
  }
35818
37065
  /** Snap the eased forming-bar state to `bar` — no glide (a fresh bar or the first tick of one). */
35819
37066
  syncLiveEase(bar) {
@@ -35827,7 +37074,7 @@ var NativeRenderer = class {
35827
37074
  const target = this.bars[this.bars.length - 1];
35828
37075
  if (!target || this.liveEaseTime !== target.time) return false;
35829
37076
  const eps = Math.max(1e-9, Math.abs(target.close) * 1e-6);
35830
- const tau = this.animLiveBarMs;
37077
+ const tau = this.animLiveBar.tau;
35831
37078
  const nh = easeToward(this.liveEaseHigh, target.high, dtMs, tau);
35832
37079
  const nl = easeToward(this.liveEaseLow, target.low, dtMs, tau);
35833
37080
  const nc = easeToward(this.liveEaseClose, target.close, dtMs, tau);
@@ -35967,10 +37214,12 @@ var NativeRenderer = class {
35967
37214
  this.refreshAnchorOffset(model);
35968
37215
  if (model.native && this.extLayers.some((l) => l.def.id === model.native.type)) this.scene.assignIndicatorZTop(model.id);
35969
37216
  else this.scene.assignIndicatorZ(model.id);
35970
- this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
35971
- native: !!model.native,
35972
- ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
35973
- });
37217
+ if (model.legend !== false) {
37218
+ this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
37219
+ native: !!model.native,
37220
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
37221
+ });
37222
+ }
35974
37223
  if (model.native?.type === "volume") {
35975
37224
  this.volumeActive = true;
35976
37225
  this.volumeHidden = false;
@@ -36117,7 +37366,7 @@ var NativeRenderer = class {
36117
37366
  this.settingsDialog?.setHiddenSettings(this.hiddenSettings);
36118
37367
  }
36119
37368
  listSettingsIds() {
36120
- return settingsIdCatalog(this.hostSettingsSections);
37369
+ return settingsIdCatalog(this.hostSettingsSections, this.markGroupsInUse());
36121
37370
  }
36122
37371
  onChartTypeSettingsChange(cb) {
36123
37372
  this.chartTypeSettingsCbs.add(cb);
@@ -36143,6 +37392,22 @@ var NativeRenderer = class {
36143
37392
  this.axisLongPressCbs.add(cb);
36144
37393
  return () => this.axisLongPressCbs.delete(cb);
36145
37394
  }
37395
+ // ── timeline marks (the `chart.marks` model; see the port) ──
37396
+ setTimelineMarks(marks, groups) {
37397
+ this.scene.timelineMarks = marks;
37398
+ this.scene.markGroups = groups;
37399
+ this.scene.marksExpandedStack = null;
37400
+ this.markPopover?.close();
37401
+ this.scheduler?.invalidate(2 /* Chrome */);
37402
+ }
37403
+ onMarkClick(cb) {
37404
+ this.markClickCbs.add(cb);
37405
+ return () => this.markClickCbs.delete(cb);
37406
+ }
37407
+ /** Every group the lane knows: the defined ones, then those marks name without a definition. */
37408
+ markGroupsInUse() {
37409
+ return effectiveMarkGroups(this.scene.timelineMarks, this.scene.markGroups);
37410
+ }
36146
37411
  onViewportChange(cb) {
36147
37412
  this.viewportCbs.add(cb);
36148
37413
  return () => this.viewportCbs.delete(cb);
@@ -36196,7 +37461,7 @@ var NativeRenderer = class {
36196
37461
  this.zoomAnchorX = anchorX;
36197
37462
  this.panVelocity = 0;
36198
37463
  this.scrollTargetRO = null;
36199
- if (!this.animZoom) {
37464
+ if (!this.animZoom.on) {
36200
37465
  const v = this.clampViewport(barSpacing, this.anchoredRightOffset(barSpacing));
36201
37466
  this.coords.setViewport(v);
36202
37467
  this.targetBarSpacing = v.barSpacing;
@@ -36209,7 +37474,7 @@ var NativeRenderer = class {
36209
37474
  }
36210
37475
  /** Inertial pan: continue with a rightOffset velocity (logical units / ms) that decays. */
36211
37476
  fling(velocity) {
36212
- if (!this.animPan) return;
37477
+ if (!this.animPan.on) return;
36213
37478
  this.scrollTargetRO = null;
36214
37479
  this.panVelocity = velocity;
36215
37480
  this.animator.start();
@@ -36249,7 +37514,7 @@ var NativeRenderer = class {
36249
37514
  let active = false;
36250
37515
  const tbs = this.targetBarSpacing;
36251
37516
  if (Math.abs(barSpacing - tbs) > tbs * 1e-3) {
36252
- barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, ZOOM_TAU_MS));
37517
+ barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, this.animZoom.tau));
36253
37518
  rightOffset = this.anchoredRightOffset(barSpacing);
36254
37519
  active = true;
36255
37520
  } else if (barSpacing !== tbs) {
@@ -36259,13 +37524,14 @@ var NativeRenderer = class {
36259
37524
  const stopVel = FLING_STOP_PX / Math.max(1e-6, barSpacing * this.coords.spacingScale);
36260
37525
  if (Math.abs(this.panVelocity) > stopVel) {
36261
37526
  rightOffset += this.panVelocity * dtMs;
36262
- this.panVelocity *= Math.exp(-dtMs / FLING_TAU_MS);
37527
+ const tau = this.animPan.tau;
37528
+ this.panVelocity = tau > 0 ? this.panVelocity * Math.exp(-dtMs / tau) : 0;
36263
37529
  if (Math.abs(this.panVelocity) <= stopVel) this.panVelocity = 0;
36264
37530
  else active = true;
36265
37531
  }
36266
37532
  if (this.scrollTargetRO != null) {
36267
37533
  const target = this.scrollTargetRO;
36268
- const next = easeToward(rightOffset, target, dtMs, SCROLL_TO_TAU_MS);
37534
+ const next = easeToward(rightOffset, target, dtMs, this.animScroll.tau);
36269
37535
  if (Math.abs(next - target) < 1e-3) {
36270
37536
  rightOffset = target;
36271
37537
  this.scrollTargetRO = null;
@@ -36293,6 +37559,7 @@ var NativeRenderer = class {
36293
37559
  * through Math.log), not a non-linear jump. */
36294
37560
  easeScales(dtMs) {
36295
37561
  let moving = false;
37562
+ const tau = this.animAutoscale.tau;
36296
37563
  for (const pane of this.scene.panes.values()) {
36297
37564
  const t = pane.scaleTarget;
36298
37565
  const s = pane.scale;
@@ -36300,8 +37567,8 @@ var NativeRenderer = class {
36300
37567
  const lt0 = Math.log(t.min);
36301
37568
  const lt1 = Math.log(t.max);
36302
37569
  const lspan = Math.max(1e-9, Math.abs(lt1 - lt0));
36303
- const n0 = easeToward(Math.log(s.min), lt0, dtMs, SCALE_TAU_MS);
36304
- const n1 = easeToward(Math.log(s.max), lt1, dtMs, SCALE_TAU_MS);
37570
+ const n0 = easeToward(Math.log(s.min), lt0, dtMs, tau);
37571
+ const n1 = easeToward(Math.log(s.max), lt1, dtMs, tau);
36305
37572
  if (Math.abs(n0 - lt0) <= lspan * 1e-3 && Math.abs(n1 - lt1) <= lspan * 1e-3) {
36306
37573
  pane.scale = { min: t.min, max: t.max, log: true };
36307
37574
  } else {
@@ -36311,8 +37578,8 @@ var NativeRenderer = class {
36311
37578
  continue;
36312
37579
  }
36313
37580
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
36314
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
36315
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
37581
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
37582
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
36316
37583
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
36317
37584
  nmin = t.min;
36318
37585
  nmax = t.max;
@@ -36325,8 +37592,8 @@ var NativeRenderer = class {
36325
37592
  const t = sl.scaleTarget;
36326
37593
  const s = sl.scale;
36327
37594
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
36328
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
36329
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
37595
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
37596
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
36330
37597
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
36331
37598
  nmin = t.min;
36332
37599
  nmax = t.max;
@@ -36347,6 +37614,8 @@ var NativeRenderer = class {
36347
37614
  this.scene.crosshair = null;
36348
37615
  this.hoverSeparatorY = null;
36349
37616
  this.lastPointer = null;
37617
+ if (this.marksExpandedBy === "hover") this.setMarksExpanded(null);
37618
+ this.setMarkHover(null);
36350
37619
  this.scheduler.invalidate(1 /* Cursor */);
36351
37620
  this.hoverLogical = null;
36352
37621
  const empty = { time: null, price: null, paneKind: null, values: /* @__PURE__ */ new Map(), ohlc: null };
@@ -36358,6 +37627,8 @@ var NativeRenderer = class {
36358
37627
  this.lastPointer = inData ? { x, y } : null;
36359
37628
  this.hoverSeparatorY = x >= 0 && y >= 0 && y <= this.coords.height ? this.separatorHoverY(y) : null;
36360
37629
  this.scheduler.invalidate(1 /* Cursor */);
37630
+ this.setMarksExpanded(inData ? this.chrome.markStackAt(x, y) : null);
37631
+ this.setMarkHover(inData ? this.chrome.markGlyphAt(x, y)?.cluster.key ?? null : null);
36361
37632
  const logical = Math.round(this.coords.xToLogical(x));
36362
37633
  const onBar = logical >= 0 && logical < this.coords.barCount;
36363
37634
  const time = onBar ? this.coords.logicalToTime(logical) : null;
@@ -36387,6 +37658,76 @@ var NativeRenderer = class {
36387
37658
  const onBar = logical >= 0 && logical < this.coords.barCount;
36388
37659
  for (const cb of this.clickCbs) cb({ time: onBar ? this.coords.logicalToTime(logical) : null, price: null });
36389
37660
  }
37661
+ /**
37662
+ * Fan out (or collapse, with null) a multi-group mark stack; repaints the chrome tier when
37663
+ * it changes. A stack whose glyph holds the open popup stays fanned — the pointer leaving
37664
+ * the plot for the popup must not bury the glyph under the deck (which would close it).
37665
+ */
37666
+ setMarksExpanded(stack, by = "hover") {
37667
+ if (this.scene.marksExpandedStack === stack) return;
37668
+ if (stack === null && this.markPopover?.key) {
37669
+ const open2 = this.chrome.markGlyphByKey(this.markPopover.key);
37670
+ if (open2 && open2.stack === this.scene.marksExpandedStack) return;
37671
+ }
37672
+ this.scene.marksExpandedStack = stack;
37673
+ this.marksExpandedBy = by;
37674
+ this.scheduler?.invalidate(2 /* Chrome */);
37675
+ }
37676
+ /**
37677
+ * A click on the mark lane: a collapsed deck fans out (the touch path — a mouse already
37678
+ * fanned it by hovering), a glyph reports its cluster (`onMarkClick`) and opens the popup
37679
+ * when any of its marks carries content. True when the click landed on the lane.
37680
+ */
37681
+ handleMarkClick(x, y) {
37682
+ const glyph = this.chrome.markGlyphAt(x, y);
37683
+ if (!glyph) {
37684
+ if (this.scene.marksExpandedStack !== null && this.chrome.markStackAt(x, y) === null) this.setMarksExpanded(null);
37685
+ return false;
37686
+ }
37687
+ if (glyph.decked) {
37688
+ this.setMarksExpanded(glyph.stack, "tap");
37689
+ return true;
37690
+ }
37691
+ const marks = glyph.cluster.marks;
37692
+ const first = marks[0];
37693
+ const event = { id: first.id, ids: marks.map((m) => m.id), time: first.time, ...glyph.cluster.group !== void 0 ? { group: glyph.cluster.group } : {} };
37694
+ for (const cb of this.markClickCbs) cb(event);
37695
+ if (marks.some((m) => m.content !== void 0)) {
37696
+ this.markPopover?.open(glyph.cluster, { x: glyph.x, y: glyph.y, size: glyph.size });
37697
+ } else {
37698
+ this.scene.marksFlash = { key: glyph.cluster.key, until: frameNow() + MARK_FLASH_MS };
37699
+ this.syncMarkPulse();
37700
+ }
37701
+ return true;
37702
+ }
37703
+ /** After a chrome frame: keep the open mark popup on its glyph, or close it once the glyph is gone. */
37704
+ trackMarkPopover() {
37705
+ const key = this.markPopover?.key;
37706
+ if (!key) return;
37707
+ const g = this.chrome.markGlyphByKey(key);
37708
+ this.markPopover.track(g && !(g.decked && g.depth !== 0) ? { x: g.x, y: g.y, size: g.size } : null);
37709
+ }
37710
+ /** The lane glyph under the pointer — it swells once as the pointer lands (a rAF-driven chrome repaint for the pulse's duration). */
37711
+ setMarkHover(key) {
37712
+ if (this.scene.marksHoverKey === key) return;
37713
+ this.scene.marksHoverKey = key;
37714
+ this.scene.marksHoverSince = frameNow();
37715
+ this.scheduler?.invalidate(2 /* Chrome */);
37716
+ this.syncMarkPulse();
37717
+ }
37718
+ /** Run a chrome-tier repaint loop while a glyph's hover pulse or click flash plays; it stops itself once both are over. */
37719
+ syncMarkPulse() {
37720
+ if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
37721
+ const tick = () => {
37722
+ this.markPulseRaf = null;
37723
+ const now = frameNow();
37724
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
37725
+ this.scheduler?.invalidate(2 /* Chrome */);
37726
+ const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
37727
+ if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
37728
+ };
37729
+ this.markPulseRaf = requestAnimationFrame(tick);
37730
+ }
36390
37731
  paneAtY(y) {
36391
37732
  return this.paneNodeAtY(y);
36392
37733
  }
@@ -36778,6 +38119,7 @@ var NativeRenderer = class {
36778
38119
  } else if (repaintsChrome(level) && this.paintedData) {
36779
38120
  this.chrome.prepare(this.scene, this.coords, this.theme);
36780
38121
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
38122
+ this.trackMarkPopover();
36781
38123
  }
36782
38124
  this.crosshairLayer.render(this.scene, this.coords, this.theme, this.hoverSeparatorY, this.externalCrossPx());
36783
38125
  if (!repaintsData(level) && this.paintedData) this.repaintCursorLayers();
@@ -36793,7 +38135,7 @@ var NativeRenderer = class {
36793
38135
  const lp = this.layerPane(l.def.id) ?? pane;
36794
38136
  if (lp.collapsed) continue;
36795
38137
  l.instance.render(this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs));
36796
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
38138
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
36797
38139
  }
36798
38140
  }
36799
38141
  /** Blank one SDK layer canvas (a collapsed host pane suppresses the layer's painting). */
@@ -36858,7 +38200,7 @@ var NativeRenderer = class {
36858
38200
  const args = this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs);
36859
38201
  l.instance.render(args);
36860
38202
  if (lp === pane) folded = foldBaseModulation(folded, l.instance.modulateBase?.(args) ?? null);
36861
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
38203
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
36862
38204
  }
36863
38205
  if (folded) {
36864
38206
  if (folded.candleBodyScale != null) this.backend.candleBodyScale = clamp012(folded.candleBodyScale) || 0.01;
@@ -36877,6 +38219,7 @@ var NativeRenderer = class {
36877
38219
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
36878
38220
  this.backend.render(this.scene, this.coords, this.theme);
36879
38221
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
38222
+ this.trackMarkPopover();
36880
38223
  this.userDrawings?.render();
36881
38224
  if (easeLive && liveActual) this.bars[li] = liveActual;
36882
38225
  this.paintedData = true;
@@ -37389,7 +38732,7 @@ var NativeRenderer = class {
37389
38732
  const map2 = /* @__PURE__ */ new Map();
37390
38733
  for (const pane of this.scene.panes.values()) {
37391
38734
  if (!pane.collapsed) continue;
37392
- const models = this.scene.orderedIndicatorsForPane(pane.id);
38735
+ const models = this.scene.orderedIndicatorsForPane(pane.id).filter((m) => m.legend !== false);
37393
38736
  const merged = new Set(this.scene.ownScaleIndicatorsForPane(pane.id).map((m) => m.id));
37394
38737
  const master = models.find((m) => !merged.has(m.id)) ?? models[0];
37395
38738
  map2.set(pane.id, master?.id ?? null);
@@ -40184,6 +41527,7 @@ var Vela = class {
40184
41527
  if (Object.keys(defaults2).length > 0) this.rendererControl.set(defaults2);
40185
41528
  this.panesControl = new PanesControl(this.orchestrator);
40186
41529
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
41530
+ this.marksControl = new MarksControl(this.orchestrator.marks);
40187
41531
  }
40188
41532
  /**
40189
41533
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -40402,6 +41746,17 @@ var Vela = class {
40402
41746
  get drawings() {
40403
41747
  return this.drawingsControl;
40404
41748
  }
41749
+ /**
41750
+ * The chart's timeline-marks control surface: host events pinned to a bar and shown
41751
+ * as glyphs on a lane above the time axis, each opening a popup on click —
41752
+ * `chart.marks.add({ id, time, glyph, title, content })`, `chart.marks.set(list)`,
41753
+ * `chart.marks.defineGroup({ id, label })`. Marks are data, not user state: re-supply
41754
+ * them on `market:changed`. On a renderer without the `timelineMarks` capability the
41755
+ * model still fills but nothing paints (`chart.marks.supported`).
41756
+ */
41757
+ get marks() {
41758
+ return this.marksControl;
41759
+ }
40405
41760
  on(event, handler) {
40406
41761
  return this.orchestrator.events.on(event, handler);
40407
41762
  }
@@ -43193,6 +44548,9 @@ var VelaWorkspace = class {
43193
44548
  this.events = new TypedEventBus();
43194
44549
  this.feed = new MultiProviderFeed();
43195
44550
  this.cellsById = /* @__PURE__ */ new Map();
44551
+ /** The one second pulse behind every time display: the bottom-bar clock and each
44552
+ * cell's countdown chip subscribe to it, so they can never read different seconds. */
44553
+ this.clock = new SecondClock();
43196
44554
  this.pool = /* @__PURE__ */ new Map();
43197
44555
  this.trackSizes = /* @__PURE__ */ new Map();
43198
44556
  this.resizeObserver = null;
@@ -43470,6 +44828,7 @@ var VelaWorkspace = class {
43470
44828
  this.drawToolbar?.setDrawingsSyncMode(!!this.syncOpts.drawings);
43471
44829
  this.bottombar = opts.bottombar !== false ? new Bottombar(this.root, {
43472
44830
  timezone: this.timezone,
44831
+ clock: this.clock,
43473
44832
  onRange: (preset) => {
43474
44833
  this.active.applyRange(preset);
43475
44834
  this.bottombar?.setActiveRange(preset.id);
@@ -43913,6 +45272,7 @@ var VelaWorkspace = class {
43913
45272
  const cell = this.activeId ? this.cellsById.get(this.activeId) : void 0;
43914
45273
  const paneMax = cell ? cell.chart.panes.list().some((p) => p.maximized) : false;
43915
45274
  this.mobileBar.setMaximizeActive(this.maximizedId != null || paneMax);
45275
+ this.mobileBar.setMaximizeVisible(this.def.cells.length > 1);
43916
45276
  }
43917
45277
  /**
43918
45278
  * Trade the SLOTS of two live cells — the grid arrangement changes, the cells
@@ -44213,6 +45573,7 @@ var VelaWorkspace = class {
44213
45573
  if (id === this.activeId) cell.host.dataset.active = "1";
44214
45574
  this.wireCell(cell);
44215
45575
  cell.chart.renderer.setLayoutMode(this.layoutCtl.current);
45576
+ cell.chart.renderer.setWallClock(this.clock);
44216
45577
  cell.setControlsSuspended(this.layoutCtl.current === "mobile");
44217
45578
  if (this.favs.length > 0) cell.chart.drawings.setFavorites(this.favs);
44218
45579
  cell.setManifest(this.manifest, pooled?.indicators == null);