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