@luxalgo/vela 0.6.20 → 0.6.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/{DataProvider-Cb-2lC5O.d.ts → DataProvider-Bu9E5Zh5.d.ts} +1 -1
  2. package/dist/{DataProvider-UtWw2CgJ.d.cts → DataProvider-Ck2qyS_9.d.cts} +1 -1
  3. package/dist/{chunk-M6T5A733.js → chunk-A347YL2P.js} +31 -8
  4. package/dist/{chunk-4Q4B5AO3.js → chunk-A4G64KVF.js} +1453 -131
  5. package/dist/{chunk-T5Z5YUCF.js → chunk-CZRNTHD6.js} +24 -2
  6. package/dist/{chunk-XCBJU674.js → chunk-UHXG5J7C.js} +1 -1
  7. package/dist/{contributions-D8HdlKmp.d.cts → contributions-BjQ7hyIx.d.ts} +108 -3
  8. package/dist/{contributions-FqIWhN4p.d.ts → contributions-Cv2Mutvn.d.cts} +108 -3
  9. package/dist/index.cjs +1476 -130
  10. package/dist/index.d.cts +76 -20
  11. package/dist/index.d.ts +76 -20
  12. package/dist/index.js +2 -2
  13. package/dist/{options-BCRmYALw.d.ts → options-ex2_gtKp.d.cts} +200 -12
  14. package/dist/{options-BCRmYALw.d.cts → options-ex2_gtKp.d.ts} +200 -12
  15. package/dist/{plugin-B4mnNAeh.d.ts → plugin-CiyhU7pi.d.ts} +3 -3
  16. package/dist/{plugin-Ccupy_BV.d.cts → plugin-absNH7Yn.d.cts} +3 -3
  17. package/dist/plugin.d.cts +4 -4
  18. package/dist/plugin.d.ts +4 -4
  19. package/dist/providers/binance.d.cts +2 -2
  20. package/dist/providers/binance.d.ts +2 -2
  21. package/dist/providers/coinbase.d.cts +2 -2
  22. package/dist/providers/coinbase.d.ts +2 -2
  23. package/dist/providers/hyperliquid.d.cts +2 -2
  24. package/dist/providers/hyperliquid.d.ts +2 -2
  25. package/dist/{statusline-model-CCP1zm_Z.d.ts → statusline-model-BXoU4Kua.d.ts} +8 -4
  26. package/dist/{statusline-model-E7CMw5pQ.d.cts → statusline-model-cRhPBLPO.d.cts} +8 -4
  27. package/dist/ui.cjs +24 -2
  28. package/dist/ui.d.cts +9 -3
  29. package/dist/ui.d.ts +9 -3
  30. package/dist/ui.js +2 -2
  31. package/dist/vela.global.js +1476 -130
  32. package/dist/vela.global.min.js +77 -53
  33. package/dist/widget.cjs +1502 -135
  34. package/dist/widget.d.cts +6 -6
  35. package/dist/widget.d.ts +6 -6
  36. package/dist/widget.js +5 -5
  37. package/dist/workspace.cjs +1502 -135
  38. package/dist/workspace.d.cts +8 -5
  39. package/dist/workspace.d.ts +8 -5
  40. package/dist/workspace.js +4 -4
  41. package/package.json +2 -1
@@ -2,19 +2,48 @@ var Vela = (function (exports) {
2
2
  'use strict';
3
3
 
4
4
  // src/core/options.ts
5
+ var ZOOM_EASE_DEFAULT_MS = 70;
6
+ var PAN_INERTIA_DEFAULT_MS = 110;
7
+ var SCROLL_EASE_DEFAULT_MS = 130;
8
+ var AUTOSCALE_EASE_DEFAULT_MS = 80;
5
9
  var LIVE_BAR_EASE_DEFAULT_MS = 90;
6
- var LIVE_BAR_EASE_MAX_MS = 1e3;
7
- function resolveLiveBarEaseMs(value) {
8
- if (value === true) return LIVE_BAR_EASE_DEFAULT_MS;
10
+ var INTRO_DURATION_DEFAULT_MS = 650;
11
+ var ANIMATION_EASE_MAX_MS = 1e3;
12
+ var LIVE_BAR_EASE_MAX_MS = ANIMATION_EASE_MAX_MS;
13
+ var INTRO_DURATION_MAX_MS = 5e3;
14
+ function resolveEaseMs(value, defaultMs, maxMs = ANIMATION_EASE_MAX_MS) {
15
+ if (value === true) return defaultMs;
9
16
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
10
- return Math.min(value, LIVE_BAR_EASE_MAX_MS);
17
+ return Math.min(value, maxMs);
18
+ }
19
+ function resolveLiveBarEaseMs(value) {
20
+ return resolveEaseMs(value, LIVE_BAR_EASE_DEFAULT_MS, LIVE_BAR_EASE_MAX_MS);
21
+ }
22
+ function resolveIntro(value) {
23
+ const off = { style: false, duration: 0 };
24
+ if (value === true) return { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
25
+ if (value === "settle" || value === "grow") return { style: value, duration: INTRO_DURATION_DEFAULT_MS };
26
+ if (value && typeof value === "object") {
27
+ const o = value;
28
+ const d = o.duration;
29
+ return {
30
+ style: o.style === "grow" ? "grow" : "settle",
31
+ duration: typeof d === "number" && Number.isFinite(d) && d > 0 ? Math.min(d, INTRO_DURATION_MAX_MS) : INTRO_DURATION_DEFAULT_MS
32
+ };
33
+ }
34
+ return off;
11
35
  }
12
36
  function resolveAnimations(animations) {
13
- if (typeof animations === "boolean") return { animZoom: animations, animPan: animations, animLiveBar: 0 };
37
+ if (animations === false) return { animZoom: 0, animPan: 0, animScroll: 0, animAutoscale: 0, animLiveBar: 0, animIntro: { style: false, duration: 0 } };
38
+ const cfg = animations === true || animations == null ? {} : animations;
39
+ const animPan = resolveEaseMs(cfg.pan ?? true, PAN_INERTIA_DEFAULT_MS);
14
40
  return {
15
- animZoom: animations?.zoom ?? true,
16
- animPan: animations?.pan ?? true,
17
- animLiveBar: resolveLiveBarEaseMs(animations?.liveBar)
41
+ animZoom: resolveEaseMs(cfg.zoom ?? true, ZOOM_EASE_DEFAULT_MS),
42
+ animPan,
43
+ animScroll: cfg.scroll === void 0 ? animPan > 0 ? SCROLL_EASE_DEFAULT_MS : 0 : resolveEaseMs(cfg.scroll, SCROLL_EASE_DEFAULT_MS),
44
+ animAutoscale: resolveEaseMs(cfg.autoscale ?? true, AUTOSCALE_EASE_DEFAULT_MS),
45
+ animLiveBar: resolveLiveBarEaseMs(cfg.liveBar),
46
+ animIntro: resolveIntro(cfg.intro ?? true)
18
47
  };
19
48
  }
20
49
 
@@ -7057,6 +7086,93 @@ var Vela = (function (exports) {
7057
7086
  }
7058
7087
  };
7059
7088
 
7089
+ // src/core/marks/MarksController.ts
7090
+ var MarksController = class {
7091
+ constructor(renderer, events) {
7092
+ this.renderer = renderer;
7093
+ /** Insertion-ordered — the order a cluster falls back to for equal times. */
7094
+ this.marks = /* @__PURE__ */ new Map();
7095
+ this.groups = /* @__PURE__ */ new Map();
7096
+ this.subs = [];
7097
+ this.enabled = !!renderer.capabilities.timelineMarks && typeof renderer.setTimelineMarks === "function";
7098
+ if (this.enabled && renderer.onMarkClick) this.subs.push(renderer.onMarkClick((e) => events.emit("mark:click", e)));
7099
+ }
7100
+ /** Whether the active renderer paints timeline marks. */
7101
+ get supported() {
7102
+ return this.enabled;
7103
+ }
7104
+ /** Add (or replace, by id) one mark. */
7105
+ add(mark) {
7106
+ this.marks.set(mark.id, validateMark(mark));
7107
+ this.sync();
7108
+ }
7109
+ /** Replace the whole set — a market switch. */
7110
+ set(marks) {
7111
+ this.marks.clear();
7112
+ for (const m of marks) this.marks.set(m.id, validateMark(m));
7113
+ this.sync();
7114
+ }
7115
+ remove(id) {
7116
+ const had = this.marks.delete(id);
7117
+ if (had) this.sync();
7118
+ return had;
7119
+ }
7120
+ clear() {
7121
+ if (this.marks.size === 0) return;
7122
+ this.marks.clear();
7123
+ this.sync();
7124
+ }
7125
+ /** Every mark, in insertion order (shallow copies — mutating one changes nothing). */
7126
+ all() {
7127
+ return [...this.marks.values()].map((m) => ({ ...m, glyph: { ...m.glyph } }));
7128
+ }
7129
+ /** Define (or replace) a group's presentation — its settings label and default visibility. */
7130
+ defineGroup(group) {
7131
+ if (!group || typeof group.id !== "string" || group.id.length === 0) throw new Error("[vela] marks.defineGroup: `id` must be a non-empty string");
7132
+ if (typeof group.label !== "string") throw new Error(`[vela] marks.defineGroup: group "${group.id}" needs a string \`label\``);
7133
+ this.groups.set(group.id, { ...group });
7134
+ this.sync();
7135
+ }
7136
+ /** The defined groups, in definition order. */
7137
+ groupDefinitions() {
7138
+ return [...this.groups.values()].map((g) => ({ ...g }));
7139
+ }
7140
+ /**
7141
+ * Show or hide one group's marks. The choice lives in the renderer's cosmetic config
7142
+ * (the `marks` feature) — what the settings dialog's Events checkboxes edit and what
7143
+ * a persisted chart restores — so it warns + no-ops on a renderer without it.
7144
+ */
7145
+ setGroupVisible(id, visible) {
7146
+ if (!this.renderer.features.includes("marks")) {
7147
+ console.warn(`[vela] renderer "${this.renderer.name}" does not paint timeline marks \u2014 setGroupVisible ignored.`);
7148
+ return;
7149
+ }
7150
+ this.renderer.applyFeature("marks", { groups: { [id]: visible } });
7151
+ }
7152
+ /** A group's effective visibility: the user's (persisted) choice, else the group's declared default, else visible. */
7153
+ isGroupVisible(id) {
7154
+ const state = this.renderer.readFeature("marks");
7155
+ const chosen = state?.groups?.[id];
7156
+ if (typeof chosen === "boolean") return chosen;
7157
+ return this.groups.get(id)?.visible !== false;
7158
+ }
7159
+ destroy() {
7160
+ for (const unsub of this.subs) unsub();
7161
+ this.subs.length = 0;
7162
+ }
7163
+ sync() {
7164
+ if (!this.enabled) return;
7165
+ this.renderer.setTimelineMarks([...this.marks.values()], [...this.groups.values()]);
7166
+ }
7167
+ };
7168
+ function validateMark(mark) {
7169
+ if (!mark || typeof mark !== "object") throw new Error("[vela] marks: a mark must be an object");
7170
+ if (typeof mark.id !== "string" || mark.id.length === 0) throw new Error("[vela] marks: `id` must be a non-empty string");
7171
+ if (typeof mark.time !== "number" || !Number.isFinite(mark.time)) throw new Error(`[vela] marks: mark "${mark.id}" needs a finite epoch-ms \`time\``);
7172
+ 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\``);
7173
+ return { ...mark, glyph: { ...mark.glyph } };
7174
+ }
7175
+
7060
7176
  // src/data/timeframe.ts
7061
7177
  var NAMED_TF_MS = { D: 864e5, W: 6048e5, M: 2592e6 };
7062
7178
  function timeframeToMs(timeframe) {
@@ -7735,6 +7851,7 @@ var Vela = (function (exports) {
7735
7851
  marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
7736
7852
  });
7737
7853
  this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
7854
+ this.marks = new MarksController(this.renderer, this.events);
7738
7855
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7739
7856
  this.endLoad();
7740
7857
  this.events.emit("data:unresolved", info);
@@ -8107,6 +8224,7 @@ var Vela = (function (exports) {
8107
8224
  if (!record.native) continue;
8108
8225
  record.native.instance.stop();
8109
8226
  record.native.instance = record.native.descriptor.create();
8227
+ record.native.started = false;
8110
8228
  record.pendingStructural = true;
8111
8229
  if (record.hidden) {
8112
8230
  record.native.stale = true;
@@ -8598,11 +8716,12 @@ var Vela = (function (exports) {
8598
8716
  record.session = void 0;
8599
8717
  record.native?.instance.suspend();
8600
8718
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, false);
8719
+ else if (record.native) this.mountHiddenNativeRow(id, record);
8601
8720
  } else {
8602
8721
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, true);
8603
8722
  record.pendingStructural = true;
8604
8723
  if (record.native) {
8605
- if (record.native.stale) {
8724
+ if (record.native.stale || !record.native.started) {
8606
8725
  record.native.stale = false;
8607
8726
  const handle = this.handles.get(id);
8608
8727
  if (handle) void this.startNativeIndicator(id, handle);
@@ -8685,6 +8804,7 @@ var Vela = (function (exports) {
8685
8804
  this.unresolvedUnsub = null;
8686
8805
  this.feed.destroy?.();
8687
8806
  this.drawings.destroy();
8807
+ this.marks.destroy();
8688
8808
  this.renderer.destroy();
8689
8809
  this.events.clear();
8690
8810
  }
@@ -8783,6 +8903,7 @@ var Vela = (function (exports) {
8783
8903
  }
8784
8904
  };
8785
8905
  record.native.instance.start(ctx, record.inputValues);
8906
+ record.native.started = true;
8786
8907
  } catch (err) {
8787
8908
  this.fail(id, handle, err);
8788
8909
  }
@@ -8797,6 +8918,7 @@ var Vela = (function (exports) {
8797
8918
  overlay: d.overlay,
8798
8919
  paneHint: d.paneHint,
8799
8920
  native: { type: record.native.type },
8921
+ ...d.legend === false ? { legend: false } : {},
8800
8922
  ...out.paneAxis != null ? { paneAxis: out.paneAxis } : {},
8801
8923
  series: out.series ?? [],
8802
8924
  fills: out.fills ?? [],
@@ -8819,9 +8941,17 @@ var Vela = (function (exports) {
8819
8941
  * over it in place (`pendingStructural`), clears the spinner, and only THEN fires
8820
8942
  * `indicator:added`/`ready` — so event semantics and `inspect()` (which skips
8821
8943
  * loading records) still mean "the indicator produced output".
8944
+ *
8945
+ * A HIDDEN record mounts too — dimmed, no spinner (its session never starts while
8946
+ * hidden, so nothing is computing and no model will ever arrive to mount the row
8947
+ * later). Without this an indicator ADDED hidden (a restored ledger/ext entry) had
8948
+ * no legend row at all: invisible AND unreachable — the eye that unhides it never
8949
+ * existed. The hidden mount announces immediately for the same reason: the "first
8950
+ * computed model" that normally announces cannot come until the indicator is shown,
8951
+ * and host UIs (object tree, landing watchers) must know it exists NOW.
8822
8952
  */
8823
8953
  mountLoadingPlaceholder(id, record) {
8824
- if (record.renderHandle || record.hidden || !record.prepared) return;
8954
+ if (record.renderHandle || !record.prepared) return;
8825
8955
  const meta = record.prepared.meta;
8826
8956
  const model = {
8827
8957
  id,
@@ -8845,8 +8975,34 @@ var Vela = (function (exports) {
8845
8975
  this.ensurePaneFor(paneId);
8846
8976
  record.renderHandle = this.renderer.mountIndicator(model);
8847
8977
  record.pendingStructural = true;
8978
+ if (record.hidden) {
8979
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
8980
+ this.announce(record, this.handles.get(id));
8981
+ return;
8982
+ }
8848
8983
  this.setLoading(record, true);
8849
8984
  }
8985
+ /**
8986
+ * Mount the legend row for a NATIVE indicator that is being hidden BEFORE it ever
8987
+ * started (a restored-hidden ledger entry: `startNativeIndicator` bails on hidden
8988
+ * records, so no model — and therefore no row — would ever mount). The native
8989
+ * counterpart of {@link mountLoadingPlaceholder}'s hidden branch: an empty model
8990
+ * carries the title + inputs schema, the renderer marks the row hidden, and the
8991
+ * announce makes the indicator visible to host UIs. Showing later STARTS the
8992
+ * instance (the `started` flag path) and its first emit remounts over this row.
8993
+ */
8994
+ mountHiddenNativeRow(id, record) {
8995
+ if (record.renderHandle || !record.native) return;
8996
+ const model = this.buildNativeModel(record, {});
8997
+ const paneId = this.routePane(id, model, record.options ?? {});
8998
+ this.placeModel(model, id, paneId);
8999
+ record.model = model;
9000
+ this.ensurePaneFor(paneId);
9001
+ record.renderHandle = this.renderer.mountIndicator(model);
9002
+ record.pendingStructural = true;
9003
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
9004
+ this.announce(record, this.handles.get(id));
9005
+ }
8850
9006
  /** Flip the record's loading state and reflect it in the legend row (spinner on/off). */
8851
9007
  setLoading(record, loading) {
8852
9008
  record.loading = loading;
@@ -8881,6 +9037,7 @@ var Vela = (function (exports) {
8881
9037
  for (const r of this.registry.all()) {
8882
9038
  const model = r.model;
8883
9039
  if (!model) continue;
9040
+ if (model.legend === false) continue;
8884
9041
  const paneId = model.paneId ?? "price";
8885
9042
  if (!byPane.has(paneId)) byPane.set(paneId, []);
8886
9043
  byPane.get(paneId).push({
@@ -9456,6 +9613,16 @@ var Vela = (function (exports) {
9456
9613
  this.renderer.setLayoutMode?.(mode);
9457
9614
  return this;
9458
9615
  }
9616
+ /**
9617
+ * Drive the renderer's time-of-day chrome (the countdown-to-bar-close chip) from the
9618
+ * host's own second pulse, so it ticks in step with a host clock display instead of
9619
+ * on a separate timer that can read a different second. `null` hands the pulse back
9620
+ * to the renderer. Silent no-op on a renderer without time-of-day chrome.
9621
+ */
9622
+ setWallClock(clock) {
9623
+ this.renderer.setWallClock?.(clock);
9624
+ return this;
9625
+ }
9459
9626
  };
9460
9627
 
9461
9628
  // src/core/renderer-defaults.ts
@@ -10348,6 +10515,95 @@ var Vela = (function (exports) {
10348
10515
  }
10349
10516
  };
10350
10517
 
10518
+ // src/core/MarksControl.ts
10519
+ var MarksControl = class {
10520
+ constructor(ctrl) {
10521
+ this.ctrl = ctrl;
10522
+ }
10523
+ /** Whether the active renderer paints timeline marks. */
10524
+ get supported() {
10525
+ return this.ctrl.supported;
10526
+ }
10527
+ /** Add one mark; an existing id is replaced in place. */
10528
+ add(mark) {
10529
+ this.ctrl.add(mark);
10530
+ return this;
10531
+ }
10532
+ /** Replace the whole set (a market switch). */
10533
+ set(marks) {
10534
+ this.ctrl.set(marks);
10535
+ return this;
10536
+ }
10537
+ remove(id) {
10538
+ this.ctrl.remove(id);
10539
+ return this;
10540
+ }
10541
+ clear() {
10542
+ this.ctrl.clear();
10543
+ return this;
10544
+ }
10545
+ /** Every mark, in insertion order. */
10546
+ all() {
10547
+ return this.ctrl.all();
10548
+ }
10549
+ /**
10550
+ * Define a visibility group's presentation: the label of its checkbox in chart
10551
+ * settings (the Events tab) and its default visibility. Marks may name a group
10552
+ * that was never defined — it then shows its capitalized id.
10553
+ */
10554
+ defineGroup(group) {
10555
+ this.ctrl.defineGroup(group);
10556
+ return this;
10557
+ }
10558
+ /** The defined groups, in definition order. */
10559
+ groups() {
10560
+ return this.ctrl.groupDefinitions();
10561
+ }
10562
+ /** Show or hide one group's marks — the same switch as the settings checkbox, persisted with the chart's config. */
10563
+ setGroupVisible(id, visible = true) {
10564
+ this.ctrl.setGroupVisible(id, visible);
10565
+ return this;
10566
+ }
10567
+ /** A group's effective visibility (the user's choice, else the group's declared default). */
10568
+ isGroupVisible(id) {
10569
+ return this.ctrl.isGroupVisible(id);
10570
+ }
10571
+ };
10572
+
10573
+ // src/core/util/wall-clock.ts
10574
+ var BOUNDARY_SLACK_MS = 5;
10575
+ var SecondClock = class _SecondClock {
10576
+ constructor(now = () => Date.now()) {
10577
+ this.now = now;
10578
+ this.subs = /* @__PURE__ */ new Set();
10579
+ this.timer = null;
10580
+ }
10581
+ onTick(cb) {
10582
+ this.subs.add(cb);
10583
+ if (this.timer == null) this.arm();
10584
+ return () => {
10585
+ this.subs.delete(cb);
10586
+ if (this.subs.size === 0 && this.timer != null) {
10587
+ clearTimeout(this.timer);
10588
+ this.timer = null;
10589
+ }
10590
+ };
10591
+ }
10592
+ /** Milliseconds from `now` to just past the next second boundary. */
10593
+ static delayToNextSecond(now) {
10594
+ const intoSecond = (now % 1e3 + 1e3) % 1e3;
10595
+ return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
10596
+ }
10597
+ arm() {
10598
+ this.timer = setTimeout(() => {
10599
+ this.timer = null;
10600
+ const now = this.now();
10601
+ for (const cb of this.subs) cb(now);
10602
+ if (this.subs.size > 0) this.arm();
10603
+ }, _SecondClock.delayToNextSecond(this.now()));
10604
+ }
10605
+ };
10606
+
10351
10607
  // src/core/color.ts
10352
10608
  function parseRgb(color) {
10353
10609
  const s = color.trim();
@@ -17259,7 +17515,7 @@ ${STATIC_DECLS}
17259
17515
  return { left, top, right, bottom, width: right - left, height: bottom - top };
17260
17516
  }
17261
17517
  function placePopover(a) {
17262
- let left = a.align === "end" ? a.trigger.right - a.pop.width : a.trigger.left;
17518
+ 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;
17263
17519
  const below = a.trigger.bottom + a.gap;
17264
17520
  const above = a.trigger.top - a.pop.height - a.gap;
17265
17521
  const fitsBelow = below + a.pop.height <= a.clamp.bottom;
@@ -17331,6 +17587,8 @@ ${STATIC_DECLS}
17331
17587
  this.onKey = null;
17332
17588
  this.onReflow = null;
17333
17589
  this.shown = false;
17590
+ /** The pending removal of a fading-out shell; a show() that reuses the shell cancels it. */
17591
+ this.leaveTimer = null;
17334
17592
  const doc = opts.trigger.ownerDocument;
17335
17593
  injectStyles(POPOVER_STYLE_ID, POPOVER_CSS, doc);
17336
17594
  this.trigger = opts.trigger;
@@ -17338,6 +17596,7 @@ ${STATIC_DECLS}
17338
17596
  this.ctrl = popoverController(opts);
17339
17597
  this.boundary = opts.boundary ?? "viewport";
17340
17598
  this.theme = opts.theme;
17599
+ this.fadeMs = Math.max(0, opts.fadeMs ?? 0);
17341
17600
  this.el = doc.createElement("div");
17342
17601
  this.el.className = "vela-popover vela-ui-layer" + (opts.className ? ` ${opts.className}` : "");
17343
17602
  this.el.dataset.position = this.ctrl.position;
@@ -17361,11 +17620,21 @@ ${STATIC_DECLS}
17361
17620
  return;
17362
17621
  }
17363
17622
  if (open && open !== this) open.hide();
17623
+ if (this.leaveTimer !== null) {
17624
+ clearTimeout(this.leaveTimer);
17625
+ this.leaveTimer = null;
17626
+ }
17364
17627
  ensureUIHost(this.el, this.theme);
17628
+ if (this.fadeMs > 0) {
17629
+ this.el.style.transition = `opacity ${this.fadeMs}ms ease`;
17630
+ this.el.style.opacity = "0";
17631
+ this.el.style.pointerEvents = "";
17632
+ }
17365
17633
  this.host.appendChild(this.el);
17366
17634
  this.shown = true;
17367
17635
  open = this;
17368
17636
  this.place();
17637
+ if (this.fadeMs > 0) this.el.style.opacity = "1";
17369
17638
  const onOutside = (ev) => {
17370
17639
  const t = ev.target;
17371
17640
  if (this.el.contains(t) || this.trigger.contains(t)) return;
@@ -17398,7 +17667,16 @@ ${STATIC_DECLS}
17398
17667
  this.onOutside = null;
17399
17668
  this.onKey = null;
17400
17669
  this.onReflow = null;
17401
- this.el.remove();
17670
+ if (this.fadeMs > 0) {
17671
+ this.el.style.opacity = "0";
17672
+ this.el.style.pointerEvents = "none";
17673
+ this.leaveTimer = setTimeout(() => {
17674
+ this.leaveTimer = null;
17675
+ this.el.remove();
17676
+ }, this.fadeMs);
17677
+ } else {
17678
+ this.el.remove();
17679
+ }
17402
17680
  this.shown = false;
17403
17681
  if (open === this) open = null;
17404
17682
  this.ctrl.onClose?.();
@@ -22794,6 +23072,8 @@ ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
22794
23072
  // canvas-painted into the owning indicator's interleave slice
22795
23073
  trades: true,
22796
23074
  // strategy order-fill markers (arrows + labels + fill-price ticks)
23075
+ timelineMarks: true,
23076
+ // host events on a lane above the time axis (glyphs + detail popup)
22797
23077
  inputsUI: true
22798
23078
  // reuses the DOM InputsUI
22799
23079
  };
@@ -23066,9 +23346,12 @@ ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
23066
23346
  const gh = asObject(grid.horzLines);
23067
23347
  const cross = asObject(p.crosshair);
23068
23348
  const ps = asObject(p.priceScale);
23349
+ const anim = asObject(p.animations);
23069
23350
  const panes = asObject(p.panes);
23070
23351
  const trades = asObject(p.trades);
23071
23352
  const ts = asObject(p.timeScale);
23353
+ const marks = asObject(p.marks);
23354
+ const markGroups = asObject(marks.groups);
23072
23355
  const candles = asObject(p.candles);
23073
23356
  const bars = asObject(p.bars);
23074
23357
  const line = asObject(p.line);
@@ -23115,6 +23398,12 @@ ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
23115
23398
  countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown,
23116
23399
  animateLastPrice: isBool(ps.animateLastPrice) ? ps.animateLastPrice : base.priceScale.animateLastPrice
23117
23400
  },
23401
+ animations: {
23402
+ zoom: isBool(anim.zoom) ? anim.zoom : base.animations.zoom,
23403
+ pan: isBool(anim.pan) ? anim.pan : base.animations.pan,
23404
+ autoscale: isBool(anim.autoscale) ? anim.autoscale : base.animations.autoscale,
23405
+ intro: isBool(anim.intro) ? anim.intro : base.animations.intro
23406
+ },
23118
23407
  panes: {
23119
23408
  separatorColor: isColor(panes.separatorColor) ? panes.separatorColor : base.panes.separatorColor
23120
23409
  },
@@ -23129,6 +23418,15 @@ ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
23129
23418
  timeScale: {
23130
23419
  timezone: typeof ts.timezone === "string" && ts.timezone ? ts.timezone : base.timeScale.timezone
23131
23420
  },
23421
+ marks: {
23422
+ visible: isBool(marks.visible) ? marks.visible : base.marks.visible,
23423
+ // Additive like `stacking.series`: a patch names only the groups it carries, so a
23424
+ // choice stored for a group the host has not registered yet survives verbatim.
23425
+ groups: {
23426
+ ...base.marks.groups,
23427
+ ...Object.fromEntries(Object.entries(markGroups).filter(([, v]) => isBool(v)))
23428
+ }
23429
+ },
23132
23430
  candles: {
23133
23431
  upColor: isColor(candles.upColor) ? candles.upColor : base.candles.upColor,
23134
23432
  downColor: isColor(candles.downColor) ? candles.downColor : base.candles.downColor,
@@ -24647,6 +24945,31 @@ void main() {
24647
24945
  }
24648
24946
  }
24649
24947
  };
24948
+ var EaseSetting = class {
24949
+ /** `defaultMs` is what the on/off switch restores when nothing else was configured;
24950
+ * `initialMs` (default: `defaultMs`) is the starting value — 0 for a motion that
24951
+ * ships off. */
24952
+ constructor(defaultMs, initialMs = defaultMs) {
24953
+ this.onMs = defaultMs;
24954
+ this.ms = initialMs;
24955
+ }
24956
+ /** The active time-constant; 0 when off. */
24957
+ get tau() {
24958
+ return this.ms;
24959
+ }
24960
+ get on() {
24961
+ return this.ms > 0;
24962
+ }
24963
+ /** Set the time-constant (0 = off). A non-zero value becomes what `toggle(true)` restores. */
24964
+ set(ms) {
24965
+ this.ms = ms;
24966
+ if (ms > 0) this.onMs = ms;
24967
+ }
24968
+ /** On/off only — the duration stays the last one configured. */
24969
+ toggle(on) {
24970
+ this.ms = on ? this.onMs : 0;
24971
+ }
24972
+ };
24650
24973
  function easeToward(current, target, dtMs, tauMs) {
24651
24974
  if (tauMs <= 0) return target;
24652
24975
  return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
@@ -25370,6 +25693,25 @@ void main() {
25370
25693
  for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], x, firstY + i * step);
25371
25694
  }
25372
25695
 
25696
+ // src/renderers/shared/marks-state.ts
25697
+ function defaultMarksState() {
25698
+ return { visible: true, groups: {} };
25699
+ }
25700
+ function mergeMarksState(base, patch) {
25701
+ if (typeof patch === "boolean") return { visible: patch, groups: { ...base.groups } };
25702
+ const p = patch && typeof patch === "object" ? patch : {};
25703
+ const g = p.groups && typeof p.groups === "object" ? p.groups : {};
25704
+ const groups = { ...base.groups };
25705
+ for (const [id, v] of Object.entries(g)) if (typeof v === "boolean") groups[id] = v;
25706
+ return { visible: typeof p.visible === "boolean" ? p.visible : base.visible, groups };
25707
+ }
25708
+ function markGroupVisible(state, groupId, groups) {
25709
+ if (groupId === void 0) return true;
25710
+ const chosen = state.groups[groupId];
25711
+ if (typeof chosen === "boolean") return chosen;
25712
+ return groups.find((g) => g.id === groupId)?.visible !== false;
25713
+ }
25714
+
25373
25715
  // src/renderers/native/core/SceneGraph.ts
25374
25716
  var SceneGraph = class {
25375
25717
  constructor() {
@@ -25433,6 +25775,20 @@ void main() {
25433
25775
  /** Strategy trade-marker display (the `tradeMarkers` feature): master toggle, the
25434
25776
  * two text lines, and the palette. Trade markers always paint on the price pane. */
25435
25777
  this.tradeMarkers = defaultTradeMarkersState();
25778
+ /** Timeline-mark display (the `marks` feature): the lane's master toggle + per-group visibility. */
25779
+ this.marks = defaultMarksState();
25780
+ /** The host's timeline marks + group definitions (`setTimelineMarks`), painted on the lane above the time axis. */
25781
+ this.timelineMarks = [];
25782
+ this.markGroups = [];
25783
+ /** The mark stack (bar index) fanned out by hover or tap, if any. */
25784
+ this.marksExpandedStack = null;
25785
+ /** The lane glyph (cluster key) under the pointer — it pulses — and when the hover began (frame-clock ms). */
25786
+ this.marksHoverKey = null;
25787
+ this.marksHoverSince = 0;
25788
+ /** The cluster whose popup is open: its glyph paints filled ("active"). */
25789
+ this.marksActiveKey = null;
25790
+ /** A content-less click's brief filled flash — the cluster key and the frame-clock time it ends. */
25791
+ this.marksFlash = null;
25436
25792
  /** Renderer-owned shaded time bands (session highlighting), behind grid + data. */
25437
25793
  this.highlights = [];
25438
25794
  /** Pre/post-market bands pushed by the host (`sessionZones` feature); null ⇒ no sessions. */
@@ -27281,6 +27637,315 @@ void main() {
27281
27637
  return out;
27282
27638
  }
27283
27639
 
27640
+ // src/renderers/native/chrome/countdown.ts
27641
+ function countdownText(barOpen, barMs, now) {
27642
+ if (!(barMs > 0)) return null;
27643
+ const remaining = barOpen + barMs - now;
27644
+ if (remaining <= 0) return null;
27645
+ return formatCountdown(remaining);
27646
+ }
27647
+ function formatCountdown(ms) {
27648
+ const total = Math.max(0, Math.ceil(ms / 1e3));
27649
+ const s = total % 60;
27650
+ const m = Math.floor(total / 60) % 60;
27651
+ const h = Math.floor(total / 3600);
27652
+ const pad = (v) => String(v).padStart(2, "0");
27653
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
27654
+ }
27655
+
27656
+ // src/renderers/native/chrome/contrast.ts
27657
+ function tagTextColor(bg, over) {
27658
+ const [r, g, b, a] = parseColor(bg);
27659
+ let R = r;
27660
+ let G = g;
27661
+ let B = b;
27662
+ if (a < 1) {
27663
+ const [or2, og, ob] = parseColor(over);
27664
+ R = r * a + or2 * (1 - a);
27665
+ G = g * a + og * (1 - a);
27666
+ B = b * a + ob * (1 - a);
27667
+ }
27668
+ const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
27669
+ const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
27670
+ return L >= 0.4 ? "#000000" : "#ffffff";
27671
+ }
27672
+
27673
+ // src/renderers/native/chrome/marks/layout.ts
27674
+ var MARK_GLYPH_PX = 16;
27675
+ var MARK_CLUSTER_PX = 20;
27676
+ var MARK_LANE_INSET = 4;
27677
+ var MARK_DECK_STEP = 3;
27678
+ var MARK_FAN_GAP = 4;
27679
+ var MARK_HIT_PAD = 3;
27680
+ var MARK_FAN_HOLD = 8;
27681
+ function snapMarkBar(time, barTimes, intervalMs2) {
27682
+ const n = barTimes.length;
27683
+ if (n === 0 || !(intervalMs2 > 0) || !Number.isFinite(time)) return null;
27684
+ if (time < barTimes[0]) return null;
27685
+ const last2 = barTimes[n - 1];
27686
+ if (time >= last2 + intervalMs2) return n - 1 + Math.floor((time - last2) / intervalMs2);
27687
+ let lo = 0;
27688
+ let hi = n - 1;
27689
+ while (lo < hi) {
27690
+ const mid = lo + hi + 1 >> 1;
27691
+ if (barTimes[mid] <= time) lo = mid;
27692
+ else hi = mid - 1;
27693
+ }
27694
+ if (time < barTimes[lo] + intervalMs2) return lo;
27695
+ return lo + 1;
27696
+ }
27697
+ function clusterMarks(marks, barTimes, intervalMs2, hidden) {
27698
+ const byKey = /* @__PURE__ */ new Map();
27699
+ marks.forEach((m, seq) => {
27700
+ if (m.group !== void 0 && hidden(m.group)) return;
27701
+ const bar = snapMarkBar(m.time, barTimes, intervalMs2);
27702
+ if (bar === null) return;
27703
+ const key = `${bar}|${m.group ?? ""}`;
27704
+ let c = byKey.get(key);
27705
+ if (!c) {
27706
+ c = { key, bar, group: m.group, marks: [], seq: [] };
27707
+ byKey.set(key, c);
27708
+ }
27709
+ c.marks.push(m);
27710
+ c.seq.push(seq);
27711
+ });
27712
+ const out = [];
27713
+ for (const c of byKey.values()) {
27714
+ 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);
27715
+ out.push({ key: c.key, bar: c.bar, group: c.group, marks: order.map((o) => o.m) });
27716
+ }
27717
+ return out;
27718
+ }
27719
+ function groupRank(groups, clusters) {
27720
+ const rank = /* @__PURE__ */ new Map();
27721
+ groups.forEach((g, i) => rank.set(g.id, i));
27722
+ for (const c of clusters) {
27723
+ if (c.group !== void 0 && !rank.has(c.group)) rank.set(c.group, rank.size);
27724
+ }
27725
+ return (group) => group === void 0 ? Number.MAX_SAFE_INTEGER : rank.get(group) ?? Number.MAX_SAFE_INTEGER - 1;
27726
+ }
27727
+ function layoutMarkLane(input) {
27728
+ const clusters = clusterMarks(input.marks, input.barTimes, input.intervalMs, input.hidden);
27729
+ const rankOf = groupRank(input.groups, clusters);
27730
+ const byBar = /* @__PURE__ */ new Map();
27731
+ for (const c of clusters) {
27732
+ const list = byBar.get(c.bar);
27733
+ if (list) list.push(c);
27734
+ else byBar.set(c.bar, [c]);
27735
+ }
27736
+ const glyphs = [];
27737
+ const stacks = /* @__PURE__ */ new Map();
27738
+ for (const [bar, list] of byBar) {
27739
+ const x = input.xOf(bar);
27740
+ if (!Number.isFinite(x) || x < -MARK_CLUSTER_PX || x > input.dataW + MARK_CLUSTER_PX) continue;
27741
+ list.sort((a, b) => rankOf(a.group) - rankOf(b.group));
27742
+ const multi = list.length > 1;
27743
+ const expanded = multi && input.expanded === bar;
27744
+ const decked = multi && !expanded;
27745
+ const placed = [];
27746
+ const deckSize = list[0].marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
27747
+ let bottom = input.axisY - MARK_LANE_INSET;
27748
+ list.forEach((cluster, depth) => {
27749
+ const size3 = decked ? deckSize : cluster.marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
27750
+ let y;
27751
+ if (expanded) {
27752
+ y = bottom - size3 / 2;
27753
+ bottom -= size3 + MARK_FAN_GAP;
27754
+ } else {
27755
+ y = input.axisY - MARK_LANE_INSET - size3 / 2 - depth * MARK_DECK_STEP;
27756
+ }
27757
+ placed.push({ cluster, x, y, size: size3, stack: bar, depth, decked });
27758
+ });
27759
+ for (let i = placed.length - 1; i >= 0; i--) glyphs.push(placed[i]);
27760
+ stacks.set(bar, placed);
27761
+ }
27762
+ return { glyphs, stacks };
27763
+ }
27764
+ function markGlyphAt(layout, x, y) {
27765
+ for (let i = layout.glyphs.length - 1; i >= 0; i--) {
27766
+ const g = layout.glyphs[i];
27767
+ if (g.decked && g.depth !== 0) continue;
27768
+ const r = g.size / 2 + MARK_HIT_PAD;
27769
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return g;
27770
+ }
27771
+ return null;
27772
+ }
27773
+ function markStackAt(layout, x, y) {
27774
+ for (const [bar, placed] of layout.stacks) {
27775
+ for (const g of placed) {
27776
+ const r = g.size / 2 + MARK_HIT_PAD;
27777
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return bar;
27778
+ }
27779
+ if (placed.length > 1 && !placed[0].decked) {
27780
+ const top = placed[placed.length - 1];
27781
+ const base = placed[0];
27782
+ const r = Math.max(top.size, base.size) / 2 + MARK_HIT_PAD;
27783
+ 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;
27784
+ }
27785
+ }
27786
+ return null;
27787
+ }
27788
+ function clusterTooltip(cluster, groups) {
27789
+ const first2 = cluster.marks[0];
27790
+ if (!first2) return null;
27791
+ if (cluster.marks.length === 1) return first2.tooltip ?? first2.title ?? null;
27792
+ const label = cluster.group !== void 0 ? markGroupLabel(cluster.group, groups) : first2.title ?? first2.tooltip ?? "Marks";
27793
+ return `${label} \xB7 ${cluster.marks.length}`;
27794
+ }
27795
+ function markGroupLabel(groupId, groups) {
27796
+ const def = groups.find((g) => g.id === groupId);
27797
+ if (def) return def.label;
27798
+ return groupId.charAt(0).toUpperCase() + groupId.slice(1);
27799
+ }
27800
+ function effectiveMarkGroups(marks, groups) {
27801
+ const out = groups.map((g) => ({ ...g }));
27802
+ const seen = new Set(out.map((g) => g.id));
27803
+ for (const m of marks) {
27804
+ if (m.group === void 0 || seen.has(m.group)) continue;
27805
+ seen.add(m.group);
27806
+ out.push({ id: m.group, label: markGroupLabel(m.group, groups) });
27807
+ }
27808
+ return out;
27809
+ }
27810
+
27811
+ // src/renderers/native/chrome/marks/paint.ts
27812
+ var MARK_PULSE_MS = 360;
27813
+ var MARK_PULSE_AMPLITUDE = 0.1;
27814
+ var ACTIVE_INK = "#ffffff";
27815
+ function pulseScale(elapsedMs) {
27816
+ if (!(elapsedMs > 0) || elapsedMs >= MARK_PULSE_MS) return 1;
27817
+ return 1 + MARK_PULSE_AMPLITUDE * Math.sin(Math.PI * elapsedMs / MARK_PULSE_MS);
27818
+ }
27819
+ function paintMarkLane(ctx, layout, deps) {
27820
+ if (layout.glyphs.length === 0) return;
27821
+ ctx.save();
27822
+ ctx.setLineDash([]);
27823
+ ctx.textAlign = "center";
27824
+ ctx.textBaseline = "middle";
27825
+ for (const g of layout.glyphs) {
27826
+ const mark = g.cluster.marks[0];
27827
+ if (!mark) continue;
27828
+ const key = g.cluster.key;
27829
+ const color = mark.glyph.color;
27830
+ const active = key === deps.activeKey || key === deps.flashKey;
27831
+ const size3 = g.size * (key === deps.hoverKey ? pulseScale(deps.nowMs - deps.hoverSince) : 1);
27832
+ if (g.depth === 0) {
27833
+ const sx = Math.round(g.x) + 0.5;
27834
+ ctx.lineWidth = 1;
27835
+ ctx.strokeStyle = deps.stemColor;
27836
+ ctx.beginPath();
27837
+ ctx.moveTo(sx, g.y + g.size / 2);
27838
+ ctx.lineTo(sx, deps.axisY);
27839
+ ctx.stroke();
27840
+ }
27841
+ const shape = mark.glyph.shape ?? "circle";
27842
+ const center = traceShape(ctx, shape, g.x, g.y, size3);
27843
+ ctx.lineWidth = 4;
27844
+ ctx.strokeStyle = deps.background;
27845
+ ctx.stroke();
27846
+ ctx.fillStyle = active ? color : deps.background;
27847
+ ctx.fill();
27848
+ ctx.lineWidth = 1.5;
27849
+ ctx.strokeStyle = color;
27850
+ ctx.stroke();
27851
+ const ink = active ? ACTIVE_INK : color;
27852
+ const symbolPx = Math.round(size3 * 0.62);
27853
+ if (mark.glyph.icon) {
27854
+ const img = deps.icons.get(mark.glyph.icon, ink, symbolPx, deps.dpr);
27855
+ if (img) ctx.drawImage(img, center.x - symbolPx / 2, center.y - symbolPx / 2, symbolPx, symbolPx);
27856
+ } else if (mark.glyph.letter) {
27857
+ ctx.fillStyle = ink;
27858
+ ctx.font = `600 ${Math.round(size3 * 0.58)}px ${deps.fontFamily}`;
27859
+ ctx.fillText(mark.glyph.letter.slice(0, 2), center.x, center.y + 0.5);
27860
+ }
27861
+ }
27862
+ ctx.restore();
27863
+ }
27864
+ function traceShape(ctx, shape, x, y, size3) {
27865
+ const r = size3 / 2;
27866
+ ctx.beginPath();
27867
+ switch (shape) {
27868
+ case "square": {
27869
+ const c = Math.min(3, r / 2);
27870
+ roundedRect(ctx, x - r, y - r, size3, size3, c);
27871
+ return { x, y };
27872
+ }
27873
+ case "diamond":
27874
+ ctx.moveTo(x, y - r);
27875
+ ctx.lineTo(x + r, y);
27876
+ ctx.lineTo(x, y + r);
27877
+ ctx.lineTo(x - r, y);
27878
+ ctx.closePath();
27879
+ return { x, y };
27880
+ case "pin": {
27881
+ const hr = r * 0.82;
27882
+ const hy = y - r + hr;
27883
+ ctx.arc(x, hy, hr, Math.PI * 0.75, Math.PI * 0.25, false);
27884
+ ctx.lineTo(x, y + r);
27885
+ ctx.closePath();
27886
+ return { x, y: hy };
27887
+ }
27888
+ case "circle":
27889
+ default:
27890
+ ctx.arc(x, y, r, 0, Math.PI * 2);
27891
+ return { x, y };
27892
+ }
27893
+ }
27894
+ function roundedRect(ctx, x, y, w, h, radius) {
27895
+ ctx.moveTo(x + radius, y);
27896
+ ctx.lineTo(x + w - radius, y);
27897
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
27898
+ ctx.lineTo(x + w, y + h - radius);
27899
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
27900
+ ctx.lineTo(x + radius, y + h);
27901
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
27902
+ ctx.lineTo(x, y + radius);
27903
+ ctx.quadraticCurveTo(x, y, x + radius, y);
27904
+ ctx.closePath();
27905
+ }
27906
+ var MarkIconRaster = class {
27907
+ constructor(onReady) {
27908
+ this.onReady = onReady;
27909
+ this.cache = /* @__PURE__ */ new Map();
27910
+ }
27911
+ get(icon2, ink, px, dpr) {
27912
+ const key = `${icon2}|${ink}|${px}|${dpr}`;
27913
+ if (this.cache.has(key)) {
27914
+ const img2 = this.cache.get(key);
27915
+ return img2 && img2.complete && img2.naturalWidth > 0 ? img2 : null;
27916
+ }
27917
+ const markup = iconMarkup(icon2);
27918
+ if (!markup || typeof document === "undefined" || typeof Image === "undefined" || typeof XMLSerializer === "undefined") {
27919
+ this.cache.set(key, null);
27920
+ return null;
27921
+ }
27922
+ const svg = standaloneSvg(markup, ink, Math.max(1, Math.ceil(px * dpr)));
27923
+ if (!svg) {
27924
+ this.cache.set(key, null);
27925
+ return null;
27926
+ }
27927
+ const img = new Image();
27928
+ img.onload = () => this.onReady();
27929
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
27930
+ this.cache.set(key, img);
27931
+ return null;
27932
+ }
27933
+ clear() {
27934
+ this.cache.clear();
27935
+ }
27936
+ };
27937
+ function standaloneSvg(markup, ink, px) {
27938
+ const tpl = document.createElement("template");
27939
+ tpl.innerHTML = markup;
27940
+ const svg = tpl.content.firstElementChild;
27941
+ if (!svg || svg.tagName.toLowerCase() !== "svg") return null;
27942
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
27943
+ svg.setAttribute("width", String(px));
27944
+ svg.setAttribute("height", String(px));
27945
+ svg.setAttribute("color", ink);
27946
+ return new XMLSerializer().serializeToString(svg);
27947
+ }
27948
+
27284
27949
  // src/renderers/native/chrome/ChromeRenderer.ts
27285
27950
  var ChromeRenderer = class {
27286
27951
  constructor() {
@@ -27290,11 +27955,40 @@ void main() {
27290
27955
  this.axisTextColor = DARK_THEME.textColor;
27291
27956
  // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
27292
27957
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
27958
+ /** The timeline-mark lane as laid out by the last frame — what hover/click hit-test against. */
27959
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27960
+ /** Registry icons rasterized for the lane; the owner is asked for a chrome repaint when one lands. */
27961
+ this.markIcons = new MarkIconRaster(() => this.onMarkIconReady?.());
27962
+ this.onMarkIconReady = null;
27963
+ /** Bar open times of the current series, rebuilt only when the array or its length changes (a live tick keeps both). */
27964
+ this.barTimesSrc = null;
27965
+ this.barTimesCache = [];
27293
27966
  }
27294
27967
  mount(canvas) {
27295
27968
  this.canvas = canvas;
27296
27969
  this.ctx = canvas.getContext("2d");
27297
27970
  }
27971
+ /** Where to ask for a chrome repaint when a lane icon finishes rasterizing. */
27972
+ setMarkIconReady(cb) {
27973
+ this.onMarkIconReady = cb;
27974
+ }
27975
+ /** The interactive mark glyph under a plot point (last frame's layout), or null. */
27976
+ markGlyphAt(x, y) {
27977
+ return markGlyphAt(this.markLayout, x, y);
27978
+ }
27979
+ /** The mark stack (bar index) whose glyphs — or the gaps of its fan — cover a plot point. */
27980
+ markStackAt(x, y) {
27981
+ return markStackAt(this.markLayout, x, y);
27982
+ }
27983
+ /** A glyph of the last frame by its cluster key — how an open popup follows its anchor. */
27984
+ markGlyphByKey(key) {
27985
+ return this.markLayout.glyphs.find((g) => g.cluster.key === key) ?? null;
27986
+ }
27987
+ /** Hover text of the mark glyph under a plot point, or null. */
27988
+ markTooltipAt(x, y, groups) {
27989
+ const g = this.markGlyphAt(x, y);
27990
+ return g ? clusterTooltip(g.cluster, groups) : null;
27991
+ }
27298
27992
  /** Wire the drawing coordinate resolvers + theme (call once per frame before use). */
27299
27993
  prepare(scene, coords, theme) {
27300
27994
  this.drawScene.setDeps({
@@ -27343,6 +28037,7 @@ void main() {
27343
28037
  const panes = scene.orderedPanes();
27344
28038
  if (coords.barCount === 0) {
27345
28039
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
28040
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
27346
28041
  return;
27347
28042
  }
27348
28043
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
@@ -27356,6 +28051,46 @@ void main() {
27356
28051
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
27357
28052
  this.drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane);
27358
28053
  this.drawTimeAxis(ctx, scene, coords, theme, dataW, dataH, fullH);
28054
+ this.drawMarkLane(ctx, scene, coords, theme, dataW, dataH);
28055
+ }
28056
+ /** The timeline-mark lane — after the axis, so the tokens read over the plot's bottom edge. */
28057
+ drawMarkLane(ctx, scene, coords, theme, dataW, dataH) {
28058
+ if (!scene.marks.visible || scene.timelineMarks.length === 0) {
28059
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
28060
+ return;
28061
+ }
28062
+ this.markLayout = layoutMarkLane({
28063
+ marks: scene.timelineMarks,
28064
+ groups: scene.markGroups,
28065
+ hidden: (groupId) => !markGroupVisible(scene.marks, groupId, scene.markGroups),
28066
+ barTimes: this.barTimes(scene),
28067
+ intervalMs: coords.barInterval,
28068
+ xOf: (bar) => coords.logicalToX(bar),
28069
+ axisY: dataH,
28070
+ dataW,
28071
+ expanded: scene.marksExpandedStack
28072
+ });
28073
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
28074
+ paintMarkLane(ctx, this.markLayout, {
28075
+ axisY: dataH,
28076
+ background: theme.background,
28077
+ stemColor: scene.style.borderColor ?? theme.borderColor,
28078
+ fontFamily: theme.fontFamily,
28079
+ dpr: coords.dpr,
28080
+ icons: this.markIcons,
28081
+ hoverKey: scene.marksHoverKey,
28082
+ hoverSince: scene.marksHoverSince,
28083
+ activeKey: scene.marksActiveKey,
28084
+ flashKey: scene.marksFlash && scene.marksFlash.until > nowMs ? scene.marksFlash.key : null,
28085
+ nowMs
28086
+ });
28087
+ }
28088
+ barTimes(scene) {
28089
+ if (this.barTimesSrc !== scene.bars || this.barTimesCache.length !== scene.bars.length) {
28090
+ this.barTimesSrc = scene.bars;
28091
+ this.barTimesCache = scene.bars.map((b) => b.time);
28092
+ }
28093
+ return this.barTimesCache;
27359
28094
  }
27360
28095
  destroy() {
27361
28096
  this.canvas = null;
@@ -27471,7 +28206,8 @@ void main() {
27471
28206
  * - the countdown-to-bar-close chip (`showCountdown`).
27472
28207
  * When the label and countdown are both on they merge into one stacked block (countdown
27473
28208
  * under the label, text flushed left); a lone label or countdown is centered on the
27474
- * price level with centered text. The countdown ticks once per second (repaint scheduled).
28209
+ * price level with centered text. The countdown repaints on the renderer's second pulse
28210
+ * and disappears once the bar has closed, until the next bar arrives.
27475
28211
  */
27476
28212
  drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane) {
27477
28213
  const n = scene.bars.length;
@@ -27491,17 +28227,16 @@ void main() {
27491
28227
  ctx.stroke();
27492
28228
  setDash2(ctx, "solid");
27493
28229
  }
27494
- const interval = coords.barInterval;
27495
- const showCountdown = scene.showCountdown && interval > 0;
28230
+ const cdText = scene.showCountdown ? countdownText(last2.time, coords.barInterval, Date.now()) : null;
28231
+ const showCountdown = cdText !== null;
27496
28232
  const showLabel = scene.showPriceLabel;
27497
28233
  if (!showLabel && !showCountdown) return;
27498
28234
  const priceText = formatAxisValue(pricePane.scale, pricePane.bounds.height, last2.close, percentScaleFor(scene, pricePane), scene.priceMintick);
27499
- const cdText = showCountdown ? formatCountdown(last2.time + interval - Date.now()) : "";
27500
28235
  const PAD = 8;
27501
28236
  const x = dataW + 1;
27502
28237
  const textColor = tagTextColor(color, theme.background);
27503
28238
  ctx.textBaseline = "middle";
27504
- if (showLabel && showCountdown) {
28239
+ if (showLabel && cdText !== null) {
27505
28240
  const w2 = Math.max(ctx.measureText(priceText).width, ctx.measureText(cdText).width) + PAD;
27506
28241
  const top = y - 8;
27507
28242
  const tx = x + PAD / 2;
@@ -27514,7 +28249,7 @@ void main() {
27514
28249
  ctx.textAlign = "start";
27515
28250
  return;
27516
28251
  }
27517
- const text = showLabel ? priceText : cdText;
28252
+ const text = showLabel ? priceText : cdText ?? "";
27518
28253
  const w = ctx.measureText(text).width + PAD;
27519
28254
  ctx.fillStyle = color;
27520
28255
  ctx.fillRect(x, y - 8, w, 16);
@@ -27585,29 +28320,6 @@ void main() {
27585
28320
  else if (style === "dotted") ctx.setLineDash([2, 3]);
27586
28321
  else ctx.setLineDash([]);
27587
28322
  }
27588
- function tagTextColor(bg, over) {
27589
- const [r, g, b, a] = parseColor(bg);
27590
- let R = r;
27591
- let G = g;
27592
- let B = b;
27593
- if (a < 1) {
27594
- const [or2, og, ob] = parseColor(over);
27595
- R = r * a + or2 * (1 - a);
27596
- G = g * a + og * (1 - a);
27597
- B = b * a + ob * (1 - a);
27598
- }
27599
- const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
27600
- const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
27601
- return L >= 0.4 ? "#000000" : "#ffffff";
27602
- }
27603
- function formatCountdown(ms) {
27604
- const total = Math.max(0, Math.floor(ms / 1e3));
27605
- const s = total % 60;
27606
- const m = Math.floor(total / 60) % 60;
27607
- const h = Math.floor(total / 3600);
27608
- const pad = (v) => String(v).padStart(2, "0");
27609
- return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
27610
- }
27611
28323
 
27612
28324
  // src/renderers/native/chrome/LabelTooltip.ts
27613
28325
  var HOVER_DELAY_MS = 350;
@@ -27890,6 +28602,11 @@ void main() {
27890
28602
  function settingsIdSlug(label) {
27891
28603
  return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
27892
28604
  }
28605
+ var MARKS_SETTINGS_ID = "events";
28606
+ var MARKS_GROUPS_SETTINGS_ID = "events.groups";
28607
+ function markGroupSettingsId(groupId) {
28608
+ return `${MARKS_GROUPS_SETTINGS_ID}.${settingsIdSlug(groupId)}`;
28609
+ }
27893
28610
  function settingsIdHidden(id, hidden) {
27894
28611
  if (hidden.size === 0) return false;
27895
28612
  let path = id;
@@ -27983,7 +28700,11 @@ void main() {
27983
28700
  "symbol.style.baseline.base-level",
27984
28701
  "symbol.style.baseline.width",
27985
28702
  "symbol.animation",
28703
+ "symbol.animation.zoom",
28704
+ "symbol.animation.pan",
28705
+ "symbol.animation.autoscale",
27986
28706
  "symbol.animation.price-changes",
28707
+ "symbol.animation.intro",
27987
28708
  "symbol.timezone",
27988
28709
  "scales",
27989
28710
  "scales.price-scale",
@@ -28009,8 +28730,13 @@ void main() {
28009
28730
  "canvas.grid.horizontal",
28010
28731
  "canvas.theme"
28011
28732
  ];
28012
- function settingsIdCatalog(hostSections) {
28733
+ function settingsIdCatalog(hostSections, markGroups = []) {
28013
28734
  const ids = new Set(BUILTIN_SETTINGS_IDS);
28735
+ if (markGroups.length > 0) {
28736
+ ids.add(MARKS_SETTINGS_ID);
28737
+ ids.add(MARKS_GROUPS_SETTINGS_ID);
28738
+ for (const g of markGroups) ids.add(markGroupSettingsId(g.id));
28739
+ }
28014
28740
  for (const def of chartTypes()) {
28015
28741
  if (hasOwnCandlePaint(def.id)) {
28016
28742
  const style = `symbol.style.${def.id}`;
@@ -28050,7 +28776,7 @@ void main() {
28050
28776
  return chartType(id)?.label ?? BUILTIN_STYLE_LABELS[id] ?? id;
28051
28777
  }
28052
28778
  var SD_STYLE_ID = "vela-settings-controls";
28053
- var SD_STYLE_REV = "5";
28779
+ var SD_STYLE_REV = "6";
28054
28780
  var SETTINGS_BORDER = "var(--vela-border)";
28055
28781
  function ensureControlStyles() {
28056
28782
  if (typeof document === "undefined") return;
@@ -28126,7 +28852,15 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28126
28852
  .vela-sd-mobile .vela-select-trigger,.vela-sd-mobile .vela-num input,.vela-sd-mobile .vela-width-field{height:34px;}
28127
28853
  .vela-sd-mobile .vela-sd-close{width:40px;height:40px;}
28128
28854
  .vela-sd-mobile .vela-sd-btn{height:38px;}
28129
- .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}`;
28855
+ .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}
28856
+ /* The wrap rule above is for ROW LABELS only: a select's closed value must keep its
28857
+ single-line ellipsis, or a long option wraps to several lines inside the 34px
28858
+ trigger and spills over the rows around it. Three classes so it outranks the
28859
+ two-classes-plus-element selector above. The kit's fixed 100px column is a desktop
28860
+ alignment device; on mobile the trigger hugs its value instead (the grid's control
28861
+ column is max-content), capped so a long option still ellipsizes before the label. */
28862
+ .vela-sd-mobile .vela-select-trigger .vela-select-label{white-space:nowrap !important;}
28863
+ .vela-sd-mobile .vela-select:not([data-fill]){width:auto;min-width:100px;max-width:min(220px,55vw);}`;
28130
28864
  if (!existing) document.head.appendChild(st);
28131
28865
  }
28132
28866
  var SettingsDialog = class {
@@ -28141,6 +28875,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28141
28875
  this.config = null;
28142
28876
  this.syncTypeTabs = null;
28143
28877
  this.hostSections = [];
28878
+ /** The timeline-mark groups (defined + named by marks) — one checkbox each on the Events tab. */
28879
+ this.markGroups = [];
28880
+ this.markGroupVisible = () => true;
28144
28881
  /** The Canvas → Theme row: current app theme + where a pick is raised. The row is a
28145
28882
  * host callback, NOT a config patch — the app theme stays out of the persisted
28146
28883
  * `ChartConfig`, so exported templates never carry it. */
@@ -28165,6 +28902,11 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28165
28902
  setHostSections(sections) {
28166
28903
  this.hostSections = sections;
28167
28904
  }
28905
+ /** The timeline-mark groups and their current visibility — the Events tab's rows on next open. */
28906
+ setMarkGroups(groups, visible) {
28907
+ this.markGroups = groups;
28908
+ this.markGroupVisible = visible;
28909
+ }
28168
28910
  /** Replace the visibility policy — an open dialog rebuilds in place to honor it. */
28169
28911
  setHiddenSettings(ids) {
28170
28912
  const next2 = new Set(ids);
@@ -28338,12 +29080,36 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28338
29080
  }
28339
29081
  showActive(config.series.style);
28340
29082
  body.append(sid(this.sectionTitle("Animation"), "symbol.animation"));
29083
+ body.append(sid(this.boolRow(
29084
+ "Animate zoom",
29085
+ config.animations.zoom,
29086
+ (v) => this.emit({ animations: { zoom: v } }),
29087
+ this.hint("Glide the chart to each zoom step instead of jumping.")
29088
+ ), "symbol.animation.zoom"));
29089
+ body.append(sid(this.boolRow(
29090
+ "Pan momentum",
29091
+ config.animations.pan,
29092
+ (v) => this.emit({ animations: { pan: v } }),
29093
+ this.hint("Keep gliding briefly after a drag release, and ease scroll-to-latest and keyboard pans.")
29094
+ ), "symbol.animation.pan"));
29095
+ body.append(sid(this.boolRow(
29096
+ "Animate price scale",
29097
+ config.animations.autoscale,
29098
+ (v) => this.emit({ animations: { autoscale: v } }),
29099
+ this.hint("Glide the price scale to its new range while zooming or panning.")
29100
+ ), "symbol.animation.autoscale"));
28341
29101
  body.append(sid(this.boolRow(
28342
29102
  "Animate price changes",
28343
29103
  config.priceScale.animateLastPrice,
28344
29104
  (v) => this.emit({ priceScale: { animateLastPrice: v } }),
28345
29105
  this.hint("Glide the live bar to each new price instead of snapping.")
28346
29106
  ), "symbol.animation.price-changes"));
29107
+ body.append(sid(this.boolRow(
29108
+ "Reveal on load",
29109
+ config.animations.intro,
29110
+ (v) => this.emit({ animations: { intro: v } }),
29111
+ this.hint("Draw the candles in when a chart first loads. Takes effect on the next load.")
29112
+ ), "symbol.animation.intro"));
28347
29113
  body.append(sid(this.sectionTitle("Time zone"), "symbol.timezone"));
28348
29114
  body.append(sid(this.selectRowLabeled("Time zone", normalizeTimezone(config.timeScale.timezone), timezoneOptions(config.timeScale.timezone), (v) => this.emit({ timeScale: { timezone: v } })), "symbol.timezone"));
28349
29115
  const renderHostSections = (placement) => {
@@ -28413,6 +29179,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28413
29179
  body.append(sid(this.sectionTitle("Theme"), "canvas.theme"));
28414
29180
  body.append(sid(this.selectRow("Color theme", tc.current === "dark" ? "Dark" : "Light", ["Dark", "Light"], (v) => tc.onSelect(v === "Dark" ? "dark" : "light")), "canvas.theme"));
28415
29181
  }
29182
+ if (this.markGroups.length > 0) {
29183
+ body.append(sid(this.section("Events"), MARKS_SETTINGS_ID));
29184
+ body.append(sid(this.sectionTitle("Visible events"), MARKS_GROUPS_SETTINGS_ID));
29185
+ for (const g of this.markGroups) {
29186
+ body.append(sid(this.boolRow(g.label, this.markGroupVisible(g.id), (v) => this.emit({ marks: { groups: { [g.id]: v } } })), markGroupSettingsId(g.id)));
29187
+ }
29188
+ }
28416
29189
  renderChartTypeSections("end");
28417
29190
  renderHostSections("end");
28418
29191
  if (this.hiddenSettings.size > 0) {
@@ -34617,6 +35390,383 @@ ${overlayScrollbarCss(".vela-sd-pane")}
34617
35390
  return { ...scale, min: scale.min - belowPx * perPx, max: scale.max + abovePx * perPx };
34618
35391
  }
34619
35392
 
35393
+ // src/ui/sanitize-html.ts
35394
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
35395
+ "a",
35396
+ "abbr",
35397
+ "b",
35398
+ "blockquote",
35399
+ "br",
35400
+ "code",
35401
+ "dd",
35402
+ "del",
35403
+ "div",
35404
+ "dl",
35405
+ "dt",
35406
+ "em",
35407
+ "h1",
35408
+ "h2",
35409
+ "h3",
35410
+ "h4",
35411
+ "h5",
35412
+ "h6",
35413
+ "hr",
35414
+ "i",
35415
+ "img",
35416
+ "ins",
35417
+ "kbd",
35418
+ "li",
35419
+ "mark",
35420
+ "ol",
35421
+ "p",
35422
+ "pre",
35423
+ "q",
35424
+ "s",
35425
+ "small",
35426
+ "span",
35427
+ "strong",
35428
+ "sub",
35429
+ "sup",
35430
+ "table",
35431
+ "tbody",
35432
+ "td",
35433
+ "tfoot",
35434
+ "th",
35435
+ "thead",
35436
+ "tr",
35437
+ "u",
35438
+ "ul"
35439
+ ]);
35440
+ var DROPPED_TAGS = /* @__PURE__ */ new Set([
35441
+ "script",
35442
+ "style",
35443
+ "iframe",
35444
+ "frame",
35445
+ "frameset",
35446
+ "object",
35447
+ "embed",
35448
+ "applet",
35449
+ "form",
35450
+ "input",
35451
+ "textarea",
35452
+ "button",
35453
+ "select",
35454
+ "option",
35455
+ "link",
35456
+ "meta",
35457
+ "base",
35458
+ "svg",
35459
+ "math",
35460
+ "template",
35461
+ "noscript",
35462
+ "audio",
35463
+ "video",
35464
+ "canvas",
35465
+ "dialog",
35466
+ "head",
35467
+ "title"
35468
+ ]);
35469
+ var ALLOWED_ATTRS = {
35470
+ a: /* @__PURE__ */ new Set(["href"]),
35471
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height"]),
35472
+ td: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
35473
+ th: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
35474
+ ol: /* @__PURE__ */ new Set(["start"])
35475
+ };
35476
+ var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "vbscript", "data", "file", "blob"]);
35477
+ function tagDisposition(tag) {
35478
+ const t = tag.toLowerCase();
35479
+ if (DROPPED_TAGS.has(t)) return "drop";
35480
+ return ALLOWED_TAGS.has(t) ? "keep" : "unwrap";
35481
+ }
35482
+ function attributeAllowed(tag, name) {
35483
+ const n = name.toLowerCase();
35484
+ if (n.startsWith("on") || n === "style") return false;
35485
+ if (n === "title") return true;
35486
+ return ALLOWED_ATTRS[tag.toLowerCase()]?.has(n) ?? false;
35487
+ }
35488
+ function safeUrl(value, absoluteOnly = false) {
35489
+ let url = "";
35490
+ for (const ch of value) if (ch.charCodeAt(0) > 32) url += ch;
35491
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(url);
35492
+ const scheme = m ? m[1].toLowerCase() : null;
35493
+ if (scheme !== null && BLOCKED_SCHEMES.has(scheme)) return null;
35494
+ if (absoluteOnly && scheme !== "http" && scheme !== "https") return null;
35495
+ return url;
35496
+ }
35497
+ var ELEMENT_NODE2 = 1;
35498
+ var TEXT_NODE = 3;
35499
+ function sanitizeHtml(html, doc) {
35500
+ const tpl = doc.createElement("template");
35501
+ tpl.innerHTML = html;
35502
+ const out = doc.createDocumentFragment();
35503
+ copyChildren(tpl.content, out, doc);
35504
+ return out;
35505
+ }
35506
+ function copyChildren(from, to, doc) {
35507
+ for (const child of Array.from(from.childNodes)) {
35508
+ if (child.nodeType === TEXT_NODE) {
35509
+ to.appendChild(doc.createTextNode(child.textContent ?? ""));
35510
+ continue;
35511
+ }
35512
+ if (child.nodeType !== ELEMENT_NODE2) continue;
35513
+ const el = child;
35514
+ const tag = el.tagName.toLowerCase();
35515
+ const disposition = tagDisposition(tag);
35516
+ if (disposition === "drop") continue;
35517
+ if (disposition === "unwrap") {
35518
+ copyChildren(el, to, doc);
35519
+ continue;
35520
+ }
35521
+ const clean = doc.createElement(tag);
35522
+ for (const attr of Array.from(el.attributes)) {
35523
+ const name = attr.name.toLowerCase();
35524
+ if (!attributeAllowed(tag, name)) continue;
35525
+ let value = attr.value;
35526
+ if (name === "href" || name === "src") {
35527
+ const safe = safeUrl(value, name === "src");
35528
+ if (safe === null) continue;
35529
+ value = safe;
35530
+ }
35531
+ clean.setAttribute(name, value);
35532
+ }
35533
+ if (tag === "a") {
35534
+ clean.setAttribute("target", "_blank");
35535
+ clean.setAttribute("rel", "noopener noreferrer");
35536
+ }
35537
+ copyChildren(el, clean, doc);
35538
+ to.appendChild(clean);
35539
+ }
35540
+ }
35541
+
35542
+ // src/renderers/native/chrome/marks/MarkPopover.ts
35543
+ var MARKS_STYLE_ID = "vela-marks-popover";
35544
+ var MARKS_CSS = `
35545
+ .vela-marks-panel { padding: 0; gap: 0; min-width: 220px; max-width: 320px; max-height: 320px; overflow-y: auto; overscroll-behavior: contain; }
35546
+ .vela-marks-section { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; }
35547
+ .vela-marks-section + .vela-marks-section { border-top: 1px solid var(--vela-border); }
35548
+ .vela-marks-field { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
35549
+ .vela-marks-field-label { color: var(--vela-fg-muted); }
35550
+ .vela-marks-field-value { color: var(--vela-fg-bright); text-align: right; font-variant-numeric: tabular-nums; }
35551
+ .vela-marks-html { color: var(--vela-fg); line-height: 1.45; overflow-wrap: anywhere; }
35552
+ .vela-marks-html p { margin: 0 0 6px; }
35553
+ .vela-marks-html p:last-child { margin-bottom: 0; }
35554
+ .vela-marks-html a { color: var(--vela-accent); }
35555
+ .vela-marks-html img { max-width: 100%; height: auto; }
35556
+ .vela-marks-html table { border-collapse: collapse; }
35557
+ .vela-marks-html td, .vela-marks-html th { padding: 2px 6px; border: 1px solid var(--vela-border); }
35558
+ .vela-marks-html pre, .vela-marks-html code { font-family: var(--vela-font-mono, monospace); font-size: 0.92em; }
35559
+ .vela-marks-loading, .vela-marks-error { color: var(--vela-fg-muted); font-style: italic; }
35560
+ `;
35561
+ var MarkPopover = class {
35562
+ constructor(deps) {
35563
+ this.deps = deps;
35564
+ this.pop = null;
35565
+ this.openKey = null;
35566
+ /** Bumped per open/close — a lazy content resolving after its popup went away is dropped. */
35567
+ this.generation = 0;
35568
+ const doc = deps.plot.ownerDocument;
35569
+ injectStyles(CALLOUT_STYLE_ID, CALLOUT_CSS, doc);
35570
+ injectStyles(MARKS_STYLE_ID, MARKS_CSS, doc);
35571
+ this.anchor = doc.createElement("div");
35572
+ this.anchor.className = "vela-marks-anchor";
35573
+ Object.assign(this.anchor.style, { position: "absolute", pointerEvents: "none", left: "0", top: "0", width: "0", height: "0" });
35574
+ deps.plot.appendChild(this.anchor);
35575
+ }
35576
+ /** The cluster key the open popup belongs to, or null. */
35577
+ get key() {
35578
+ return this.openKey;
35579
+ }
35580
+ open(cluster, rect) {
35581
+ this.close();
35582
+ this.place(rect);
35583
+ const gen = ++this.generation;
35584
+ this.openKey = cluster.key;
35585
+ this.pop = new Popover({
35586
+ trigger: this.anchor,
35587
+ host: this.deps.host(),
35588
+ theme: this.deps.theme(),
35589
+ gap: 8,
35590
+ align: "center",
35591
+ // centered on the glyph
35592
+ fadeMs: 120,
35593
+ // a short, discreet fade in and out
35594
+ className: "vela-marks-pop",
35595
+ content: (body) => this.build(body, cluster, gen),
35596
+ onClose: () => {
35597
+ if (this.generation === gen) {
35598
+ this.openKey = null;
35599
+ this.pop = null;
35600
+ this.deps.onOpenChange?.(null);
35601
+ }
35602
+ }
35603
+ });
35604
+ this.pop.show();
35605
+ this.deps.onOpenChange?.(cluster.key);
35606
+ }
35607
+ /** Follow the anchor glyph after a repaint; `null` (glyph gone — hidden, scrolled off, marks replaced) closes. */
35608
+ track(rect) {
35609
+ if (!this.pop) return;
35610
+ if (!rect) {
35611
+ this.close();
35612
+ return;
35613
+ }
35614
+ this.place(rect);
35615
+ this.pop.reposition();
35616
+ }
35617
+ close() {
35618
+ const pop = this.pop;
35619
+ const wasOpen = this.openKey !== null;
35620
+ this.pop = null;
35621
+ this.openKey = null;
35622
+ this.generation++;
35623
+ pop?.destroy();
35624
+ if (wasOpen) this.deps.onOpenChange?.(null);
35625
+ }
35626
+ destroy() {
35627
+ this.close();
35628
+ this.anchor.remove();
35629
+ }
35630
+ place(rect) {
35631
+ Object.assign(this.anchor.style, {
35632
+ left: `${rect.x - rect.size / 2}px`,
35633
+ top: `${rect.y - rect.size / 2}px`,
35634
+ width: `${rect.size}px`,
35635
+ height: `${rect.size}px`
35636
+ });
35637
+ }
35638
+ build(body, cluster, gen) {
35639
+ const doc = body.ownerDocument;
35640
+ const root = doc.createElement("div");
35641
+ root.className = "vela-callout-panel vela-marks-panel";
35642
+ const pending = [];
35643
+ for (const mark of cluster.marks) {
35644
+ const section = doc.createElement("section");
35645
+ section.className = "vela-marks-section";
35646
+ if (mark.title) {
35647
+ const title = doc.createElement("div");
35648
+ title.className = "vela-callout-title";
35649
+ title.textContent = mark.title;
35650
+ section.appendChild(title);
35651
+ }
35652
+ const content = mark.content;
35653
+ if (typeof content === "function") {
35654
+ const slot = doc.createElement("div");
35655
+ slot.className = "vela-marks-loading";
35656
+ slot.textContent = "Loading\u2026";
35657
+ section.appendChild(slot);
35658
+ pending.push({ el: section, resolve: () => this.resolveLazy(content, slot, gen) });
35659
+ } else if (content !== void 0) {
35660
+ this.renderContent(section, content);
35661
+ }
35662
+ if (section.childElementCount > 0) root.appendChild(section);
35663
+ }
35664
+ body.appendChild(root);
35665
+ if (pending.length > 0) scheduleLazy(root, pending);
35666
+ }
35667
+ resolveLazy(source, slot, gen) {
35668
+ let result;
35669
+ try {
35670
+ result = Promise.resolve(source());
35671
+ } catch (err) {
35672
+ result = Promise.reject(err instanceof Error ? err : new Error(String(err)));
35673
+ }
35674
+ void result.then(
35675
+ (content) => {
35676
+ if (gen !== this.generation) return;
35677
+ const section = slot.parentElement;
35678
+ if (!section) return;
35679
+ slot.remove();
35680
+ this.renderContent(section, content);
35681
+ this.pop?.reposition();
35682
+ },
35683
+ () => {
35684
+ if (gen !== this.generation) return;
35685
+ slot.className = "vela-marks-error";
35686
+ slot.textContent = "Couldn\u2019t load this entry.";
35687
+ }
35688
+ );
35689
+ }
35690
+ renderContent(section, content) {
35691
+ const doc = section.ownerDocument;
35692
+ if (!content || typeof content !== "object") return;
35693
+ if ("text" in content) {
35694
+ const text = doc.createElement("div");
35695
+ text.className = "vela-callout-text";
35696
+ text.textContent = String(content.text);
35697
+ section.appendChild(text);
35698
+ return;
35699
+ }
35700
+ if ("html" in content) {
35701
+ const html = doc.createElement("div");
35702
+ html.className = "vela-marks-html";
35703
+ html.appendChild(sanitizeHtml(String(content.html), doc));
35704
+ section.appendChild(html);
35705
+ return;
35706
+ }
35707
+ if ("panel" in content && content.panel && Array.isArray(content.panel.items)) {
35708
+ let actions = null;
35709
+ for (const item of content.panel.items) {
35710
+ if (!item || typeof item !== "object") continue;
35711
+ if (item.type === "button") {
35712
+ if (!actions) {
35713
+ actions = doc.createElement("div");
35714
+ actions.className = "vela-callout-actions";
35715
+ section.appendChild(actions);
35716
+ }
35717
+ const btn2 = doc.createElement("button");
35718
+ btn2.type = "button";
35719
+ btn2.className = "vela-callout-btn" + (item.primary ? " vela-callout-btn-primary" : "");
35720
+ btn2.textContent = item.label;
35721
+ btn2.addEventListener("click", () => {
35722
+ item.run();
35723
+ if (item.close !== false) this.close();
35724
+ });
35725
+ actions.appendChild(btn2);
35726
+ continue;
35727
+ }
35728
+ actions = null;
35729
+ if (item.type === "text") {
35730
+ const text = doc.createElement("div");
35731
+ text.className = "vela-callout-text";
35732
+ text.textContent = item.text;
35733
+ section.appendChild(text);
35734
+ } else if (item.type === "field") {
35735
+ const row = doc.createElement("div");
35736
+ row.className = "vela-marks-field";
35737
+ const label = doc.createElement("span");
35738
+ label.className = "vela-marks-field-label";
35739
+ label.textContent = item.label;
35740
+ const value = doc.createElement("span");
35741
+ value.className = "vela-marks-field-value";
35742
+ value.textContent = item.value;
35743
+ row.append(label, value);
35744
+ section.appendChild(row);
35745
+ }
35746
+ }
35747
+ }
35748
+ }
35749
+ };
35750
+ function scheduleLazy(scroller, pending) {
35751
+ if (typeof IntersectionObserver === "undefined") {
35752
+ for (const p of pending) p.resolve();
35753
+ return;
35754
+ }
35755
+ const io = new IntersectionObserver(
35756
+ (entries) => {
35757
+ for (const e of entries) {
35758
+ if (!e.isIntersecting) continue;
35759
+ const p = pending.find((q) => q.el === e.target);
35760
+ if (!p) continue;
35761
+ io.unobserve(e.target);
35762
+ p.resolve();
35763
+ }
35764
+ },
35765
+ { root: scroller }
35766
+ );
35767
+ for (const p of pending) io.observe(p.el);
35768
+ }
35769
+
34620
35770
  // src/renderers/native/core/manualScale.ts
34621
35771
  function rescaleAround(start, factor) {
34622
35772
  if (start.log && start.min > 0 && start.max > start.min) {
@@ -35345,11 +36495,12 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35345
36495
  var SCROLL_BTN_PROXIMITY_PX = 120;
35346
36496
  var MIN_VISIBLE_BARS = 2;
35347
36497
  var ZOOM_OUT_MARGIN_BARS = 6;
35348
- var ZOOM_TAU_MS = 70;
35349
- var SCALE_TAU_MS = 80;
35350
- var FLING_TAU_MS = 110;
35351
- var SCROLL_TO_TAU_MS = 130;
36498
+ var INTRO_MODEL_FADE_MS = 350;
35352
36499
  var FLING_STOP_PX = 0.02;
36500
+ var MARK_FLASH_MS = 220;
36501
+ function frameNow() {
36502
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
36503
+ }
35353
36504
  var PRICE_SCALE_K = 4e-3;
35354
36505
  var KEY_ZOOM_STEP = 0.2;
35355
36506
  var SEPARATOR_HIT_PX = 4;
@@ -35398,9 +36549,20 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35398
36549
  this.indicatorSlices = new IndicatorDrawingSlices();
35399
36550
  /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
35400
36551
  this.labelTooltip = null;
36552
+ /** The timeline-mark popup (a kit Popover anchored on a lane glyph); null before mount. */
36553
+ this.markPopover = null;
36554
+ /** How the fanned mark stack was opened: a hover folds when the pointer leaves, a tap only on a tap elsewhere. */
36555
+ this.marksExpandedBy = "hover";
36556
+ /** The rAF loop keeping the chrome repainting while a lane glyph pulses (hover) or flashes (click); null when idle. */
36557
+ this.markPulseRaf = null;
36558
+ this.markClickCbs = /* @__PURE__ */ new Set();
35401
36559
  this.crosshairLayer = new CrosshairRenderer();
35402
- /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
35403
- this.countdownTimer = null;
36560
+ /** The second pulse the countdown-to-bar-close chip ticks on: the host's (`setWallClock`)
36561
+ * when one is wired, else the renderer's own second-aligned clock. */
36562
+ this.hostClock = null;
36563
+ this.ownClock = null;
36564
+ /** Live subscription to the pulse while the countdown is on; null when off. */
36565
+ this.countdownUnsub = null;
35404
36566
  this.symbolPicker = null;
35405
36567
  /** Indicator titles (the legend rows) shown — held here so a remount re-applies it. */
35406
36568
  this.indicatorTitlesOn = true;
@@ -35418,19 +36580,25 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35418
36580
  /** Drawings layer self-serves Ctrl+Z/Y (see the `historyChords` feature). */
35419
36581
  this.historyChordsEnabled = true;
35420
36582
  this.liveRegion = null;
35421
- // ── animation state (eased zoom + inertial pan + live-bar glide) ──
35422
- this.animZoom = true;
35423
- this.animPan = true;
35424
- this.animLiveBarMs = 0;
35425
- // forming-bar OHLC glide time-constant; 0 = each tick snaps
35426
- this.animLiveBarOnMs = LIVE_BAR_EASE_DEFAULT_MS;
35427
- // the duration the settings-dialog on/off toggle restores (last non-zero value configured)
36583
+ // ── animation state: one ease time-constant per motion (0 = off), each remembering the
36584
+ // host's duration so the config's on/off switches restore it (see EaseSetting) ──
36585
+ this.animZoom = new EaseSetting(ZOOM_EASE_DEFAULT_MS);
36586
+ // wheel-zoom glide
36587
+ this.animPan = new EaseSetting(PAN_INERTIA_DEFAULT_MS);
36588
+ // inertial-pan velocity decay
36589
+ this.animScroll = new EaseSetting(SCROLL_EASE_DEFAULT_MS);
36590
+ // scroll-to-latest / panBy glide
36591
+ this.animAutoscale = new EaseSetting(AUTOSCALE_EASE_DEFAULT_MS);
36592
+ // autoscale glide during zoom/fling
36593
+ this.animLiveBar = new EaseSetting(LIVE_BAR_EASE_DEFAULT_MS, 0);
36594
+ // forming-bar OHLC glide; ships off
35428
36595
  // Brand default candles.
35429
36596
  this.candleUp = BULLISH;
35430
36597
  this.candleDown = BEARISH;
35431
36598
  // ── intro reveal (plays once when candles first appear) ──
35432
- this.introStyle = "settle";
35433
- // 'grow' | 'settle' | '' (off)
36599
+ this.intro = { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
36600
+ this.introOnStyle = "settle";
36601
+ // the style the config's on/off switch restores
35434
36602
  this.introPlayed = false;
35435
36603
  this.introRaf = null;
35436
36604
  /** The load affordance (three pulsing dots) — up while the host reports a bar load in
@@ -35549,7 +36717,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35549
36717
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
35550
36718
  this.priceStyleCbs = /* @__PURE__ */ new Set();
35551
36719
  this.name = "native";
35552
- 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"];
36720
+ 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"];
35553
36721
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
35554
36722
  * so moving onto the button doesn't count as leaving). */
35555
36723
  this.onScrollProximityMove = (e) => {
@@ -35581,9 +36749,12 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35581
36749
  this.scene.showPriceLine = opts.currentPriceLine;
35582
36750
  this.scene.logScale = opts.logScale;
35583
36751
  this.backendMode = opts.nativeBackend;
35584
- this.animZoom = opts.animZoom;
35585
- this.animPan = opts.animPan;
35586
- this.setLiveBarEase(resolveLiveBarEaseMs(opts.animLiveBar));
36752
+ this.animZoom.set(opts.animZoom);
36753
+ this.animPan.set(opts.animPan);
36754
+ this.animScroll.set(opts.animScroll);
36755
+ this.animAutoscale.set(opts.animAutoscale);
36756
+ this.animLiveBar.set(opts.animLiveBar);
36757
+ this.setIntro(opts.animIntro);
35587
36758
  this.glowAmount = opts.glow;
35588
36759
  this.candleUp = opts.upColor;
35589
36760
  this.candleDown = opts.downColor;
@@ -35627,20 +36798,29 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35627
36798
  if (this.backend && "glow" in this.backend) this.backend.glow = this.glowAmount;
35628
36799
  break;
35629
36800
  case "animZoom":
35630
- this.animZoom = Boolean(value);
36801
+ this.animZoom.set(resolveEaseMs(value, ZOOM_EASE_DEFAULT_MS));
35631
36802
  return;
35632
36803
  // affects the next interaction only — nothing to repaint
35633
- case "animPan":
35634
- this.animPan = Boolean(value);
36804
+ case "animPan": {
36805
+ const ms = resolveEaseMs(value, PAN_INERTIA_DEFAULT_MS);
36806
+ this.animPan.set(ms);
36807
+ this.animScroll.toggle(ms > 0);
35635
36808
  return;
36809
+ }
36810
+ case "animScroll":
36811
+ this.animScroll.set(resolveEaseMs(value, SCROLL_EASE_DEFAULT_MS));
36812
+ return;
36813
+ case "animAutoscale":
36814
+ this.animAutoscale.set(resolveEaseMs(value, AUTOSCALE_EASE_DEFAULT_MS));
36815
+ return;
36816
+ // a glide in flight finishes at the new rate (or snaps at 0)
35636
36817
  case "animLiveBar":
35637
- this.setLiveBarEase(resolveLiveBarEaseMs(value));
36818
+ this.animLiveBar.set(resolveLiveBarEaseMs(value));
35638
36819
  return;
35639
36820
  // affects the next tick only; a glide in flight finishes at the new rate (or snaps at 0)
35640
36821
  case "intro": {
35641
- const s = value === false || value === "none" || value === "off" || value == null ? "" : String(value);
35642
- this.introStyle = s;
35643
- if (s) this.playIntro(s);
36822
+ this.setIntro(resolveIntro(value));
36823
+ if (this.intro.style) this.playIntro();
35644
36824
  return;
35645
36825
  }
35646
36826
  case "zoomAnchor":
@@ -35712,6 +36892,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35712
36892
  case "tradeMarkers":
35713
36893
  this.scene.tradeMarkers = mergeTradeMarkersState(this.scene.tradeMarkers, value);
35714
36894
  break;
36895
+ case "marks":
36896
+ this.scene.marks = mergeMarksState(this.scene.marks, value);
36897
+ this.markPopover?.close();
36898
+ break;
35715
36899
  case "keyboard":
35716
36900
  this.setKeyboardEnabled(Boolean(value));
35717
36901
  return;
@@ -35770,13 +36954,17 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35770
36954
  case "glow":
35771
36955
  return this.glowAmount;
35772
36956
  case "animZoom":
35773
- return this.animZoom;
36957
+ return this.animZoom.tau;
35774
36958
  case "animPan":
35775
- return this.animPan;
36959
+ return this.animPan.tau;
36960
+ case "animScroll":
36961
+ return this.animScroll.tau;
36962
+ case "animAutoscale":
36963
+ return this.animAutoscale.tau;
35776
36964
  case "animLiveBar":
35777
- return this.animLiveBarMs;
36965
+ return this.animLiveBar.tau;
35778
36966
  case "intro":
35779
- return this.introStyle;
36967
+ return this.intro.style;
35780
36968
  case "zoomAnchor":
35781
36969
  return this.zoomAnchorMode;
35782
36970
  case "axisDrag":
@@ -35819,6 +37007,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35819
37007
  }
35820
37008
  case "tradeMarkers":
35821
37009
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
37010
+ case "marks":
37011
+ return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
35822
37012
  case "keyboard":
35823
37013
  return this.keyboardEnabled;
35824
37014
  case "historyChords":
@@ -35944,7 +37134,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35944
37134
  currentPriceLine: this.scene.showPriceLine,
35945
37135
  priceLabel: this.scene.showPriceLabel,
35946
37136
  countdown: this.scene.showCountdown,
35947
- animateLastPrice: this.animLiveBarMs > 0
37137
+ animateLastPrice: this.animLiveBar.on
37138
+ },
37139
+ animations: {
37140
+ zoom: this.animZoom.on,
37141
+ pan: this.animPan.on,
37142
+ autoscale: this.animAutoscale.on,
37143
+ intro: this.intro.style !== false
35948
37144
  },
35949
37145
  panes: { separatorColor: s.separatorColor ?? t.borderColor },
35950
37146
  trades: {
@@ -35956,6 +37152,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35956
37152
  exitColor: this.scene.tradeMarkers.colors.exit
35957
37153
  },
35958
37154
  timeScale: { timezone: this.scene.timezone },
37155
+ marks: { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } },
35959
37156
  candles: {
35960
37157
  upColor: this.candleUp,
35961
37158
  downColor: this.candleDown,
@@ -36051,7 +37248,12 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36051
37248
  this.scene.showPriceLabel = next2.priceScale.priceLabel;
36052
37249
  this.scene.showCountdown = next2.priceScale.countdown;
36053
37250
  this.syncCountdownTimer();
36054
- this.animLiveBarMs = next2.priceScale.animateLastPrice ? this.animLiveBarOnMs : 0;
37251
+ this.animLiveBar.toggle(next2.priceScale.animateLastPrice);
37252
+ this.animZoom.toggle(next2.animations.zoom);
37253
+ this.animPan.toggle(next2.animations.pan);
37254
+ this.animScroll.toggle(next2.animations.pan);
37255
+ this.animAutoscale.toggle(next2.animations.autoscale);
37256
+ this.intro = { style: next2.animations.intro ? this.introOnStyle : false, duration: this.intro.duration || INTRO_DURATION_DEFAULT_MS };
36055
37257
  s.separatorColor = keepInherit(s.separatorColor, next2.panes.separatorColor, prevTheme.borderColor);
36056
37258
  this.scene.tradeMarkers = {
36057
37259
  visible: next2.trades.visible,
@@ -36060,6 +37262,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36060
37262
  colors: { long: next2.trades.longColor, short: next2.trades.shortColor, exit: next2.trades.exitColor }
36061
37263
  };
36062
37264
  this.scene.timezone = next2.timeScale.timezone;
37265
+ this.scene.marks = { visible: next2.marks.visible, groups: { ...next2.marks.groups } };
36063
37266
  this.candleUp = next2.candles.upColor;
36064
37267
  this.candleDown = next2.candles.downColor;
36065
37268
  s.candle = {
@@ -36192,6 +37395,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36192
37395
  }
36193
37396
  this.settingsDialog.setTheme(this.theme);
36194
37397
  this.settingsDialog.setHostSections(this.hostSettingsSections);
37398
+ this.settingsDialog.setMarkGroups(this.markGroupsInUse(), (id) => markGroupVisible(this.scene.marks, id, this.scene.markGroups));
36195
37399
  this.settingsDialog.setHiddenSettings(this.hiddenSettings);
36196
37400
  this.syncThemeControl();
36197
37401
  this.settingsDialog.toggle(
@@ -36282,10 +37486,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36282
37486
  this.glideRightOffset(ZOOM_OUT_MARGIN_BARS);
36283
37487
  }
36284
37488
  /** Ease rightOffset to `target` at constant zoom (see animTick's scroll glide);
36285
- * instant when pan animation is off. Shared by scroll-to-latest and panBy. */
37489
+ * instant when the scroll glide is off. Shared by scroll-to-latest and panBy. */
36286
37490
  glideRightOffset(target) {
36287
37491
  const vp = this.coords.getViewport();
36288
- if (!this.animPan) {
37492
+ if (!this.animScroll.on) {
36289
37493
  this.applyViewport({ barSpacing: vp.barSpacing, rightOffset: target });
36290
37494
  return;
36291
37495
  }
@@ -36350,20 +37554,21 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36350
37554
  * full size, eased, with a left→right stagger so the chart draws itself; `settle`
36351
37555
  * adds an ease-out-back overshoot. Autoscale stays on the real bars so the frame
36352
37556
  * never moves. Re-callable, so styles can be compared live from the console.
37557
+ * Style and sweep duration come from the resolved `intro` setting.
36353
37558
  */
36354
- playIntro(style) {
37559
+ playIntro() {
36355
37560
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
36356
37561
  this.introRaf = null;
37562
+ const { style, duration } = this.intro;
36357
37563
  const real = this.bars;
36358
37564
  const n = real.length;
36359
- if (n === 0) return;
37565
+ if (n === 0 || !style) return;
36360
37566
  this.computeScales();
36361
37567
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
36362
37568
  this.modelAlpha = 0;
36363
- const DURATION = 650;
36364
37569
  const start = performance.now();
36365
37570
  const step = (now) => {
36366
- const p = Math.min(1, (now - start) / DURATION);
37571
+ const p = Math.min(1, (now - start) / duration);
36367
37572
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
36368
37573
  this.paintData();
36369
37574
  if (p < 1) {
@@ -36377,10 +37582,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36377
37582
  }
36378
37583
  /** After the candle reveal, fade the indicator models (series/fills/…) from hidden to full. */
36379
37584
  fadeInModels() {
36380
- const FADE = 350;
37585
+ const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
36381
37586
  const start = performance.now();
36382
37587
  const step = (now) => {
36383
- this.modelAlpha = Math.min(1, (now - start) / FADE);
37588
+ this.modelAlpha = Math.min(1, (now - start) / fade);
36384
37589
  this.paintData();
36385
37590
  if (this.modelAlpha < 1) {
36386
37591
  this.introRaf = requestAnimationFrame(step);
@@ -36495,7 +37700,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36495
37700
  zoomTo: (target, anchorLogical, anchorX) => this.zoomTo(target, anchorLogical, anchorX),
36496
37701
  fling: (v) => this.fling(v),
36497
37702
  onPointerMove: (x, y) => this.handlePointerMove(x, y),
36498
- onClick: (x) => {
37703
+ onClick: (x, y) => {
37704
+ if (this.handleMarkClick(x, y)) return;
36499
37705
  this.userDrawings?.deselect();
36500
37706
  this.handleClick(x);
36501
37707
  },
@@ -36524,7 +37730,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36524
37730
  drawingsPointerDown: (x, y, snap, shift4, mod) => this.userDrawings?.pointerDown(x, y, snap, shift4, mod),
36525
37731
  drawingsPointerMove: (x, y, snap, shift4, mod) => this.userDrawings?.pointerMove(x, y, snap, shift4, mod),
36526
37732
  drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
36527
- drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
37733
+ drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? (this.chrome.markGlyphAt(x, y) ? "pointer" : null),
36528
37734
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
36529
37735
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
36530
37736
  });
@@ -36540,8 +37746,18 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36540
37746
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
36541
37747
  this.labelTooltip = new LabelTooltip(this.plot, {
36542
37748
  theme: () => this.chromeTheme(),
36543
- lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
37749
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y) ?? this.chrome.markTooltipAt(x, y, this.markGroupsInUse())
36544
37750
  });
37751
+ this.markPopover = new MarkPopover({
37752
+ plot: this.plot,
37753
+ host: () => this.dialogHost ?? this.plot,
37754
+ theme: () => this.chromeTheme(),
37755
+ onOpenChange: (key) => {
37756
+ this.scene.marksActiveKey = key;
37757
+ this.scheduler?.invalidate(2 /* Chrome */);
37758
+ }
37759
+ });
37760
+ this.chrome.setMarkIconReady(() => this.scheduler?.invalidate(2 /* Chrome */));
36545
37761
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
36546
37762
  projector: () => this.drawingProjector(),
36547
37763
  dpr: () => this.coords.dpr,
@@ -36756,29 +37972,40 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36756
37972
  resize() {
36757
37973
  this.syncSize();
36758
37974
  }
36759
- /** Run a 1 Hz repaint pump while the countdown chip is on (so it ticks); stop it otherwise.
36760
- * Chrome tier: only the chip's wall-clock text moves — an idle chart must not recompute
37975
+ /** Drive the countdown chip from the host's second pulse (`null` the renderer's own). */
37976
+ setWallClock(clock) {
37977
+ if (clock === this.hostClock) return;
37978
+ this.hostClock = clock;
37979
+ if (this.countdownUnsub != null) {
37980
+ this.countdownUnsub();
37981
+ this.countdownUnsub = null;
37982
+ }
37983
+ this.syncCountdownTimer();
37984
+ }
37985
+ /** Subscribe to the second pulse while the countdown chip is on (so it ticks); unsubscribe
37986
+ * otherwise. Chrome tier: only the chip's text moves — an idle chart must not recompute
36761
37987
  * scales or repaint the geometry/volume/VPVR/SDK layers once a second (that cost
36762
37988
  * multiplies by the cell count in a multi-chart workspace). */
36763
37989
  syncCountdownTimer() {
36764
37990
  if (this.scene.showCountdown) {
36765
- if (this.countdownTimer == null) {
36766
- this.countdownTimer = setInterval(() => {
37991
+ if (this.countdownUnsub == null) {
37992
+ const clock = this.hostClock ?? (this.ownClock ?? (this.ownClock = new SecondClock()));
37993
+ this.countdownUnsub = clock.onTick(() => {
36767
37994
  if (this.scene.showCountdown && this.scene.bars.length > 0) this.scheduler?.invalidate(2 /* Chrome */);
36768
- }, 1e3);
37995
+ });
36769
37996
  }
36770
- } else if (this.countdownTimer != null) {
36771
- clearInterval(this.countdownTimer);
36772
- this.countdownTimer = null;
37997
+ } else if (this.countdownUnsub != null) {
37998
+ this.countdownUnsub();
37999
+ this.countdownUnsub = null;
36773
38000
  }
36774
38001
  }
36775
38002
  destroy() {
36776
38003
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
36777
38004
  this.loadingEl?.remove();
36778
38005
  this.loadingEl = null;
36779
- if (this.countdownTimer != null) {
36780
- clearInterval(this.countdownTimer);
36781
- this.countdownTimer = null;
38006
+ if (this.countdownUnsub != null) {
38007
+ this.countdownUnsub();
38008
+ this.countdownUnsub = null;
36782
38009
  }
36783
38010
  this.scheduler?.destroy();
36784
38011
  this.animator?.stop();
@@ -36803,6 +38030,11 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36803
38030
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
36804
38031
  this.labelTooltip?.destroy();
36805
38032
  this.labelTooltip = null;
38033
+ this.markPopover?.destroy();
38034
+ this.markPopover = null;
38035
+ this.chrome.setMarkIconReady(null);
38036
+ if (this.markPulseRaf !== null) cancelAnimationFrame(this.markPulseRaf);
38037
+ this.markPulseRaf = null;
36806
38038
  this.scrollButton?.remove();
36807
38039
  this.scrollButton = null;
36808
38040
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -36861,8 +38093,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36861
38093
  }
36862
38094
  if (!this.introPlayed && this.bars.length > 0) {
36863
38095
  this.introPlayed = true;
36864
- if (this.introStyle) {
36865
- this.playIntro(this.introStyle);
38096
+ if (this.intro.style) {
38097
+ this.playIntro();
36866
38098
  return;
36867
38099
  }
36868
38100
  }
@@ -36873,7 +38105,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36873
38105
  const last2 = this.bars[n - 1];
36874
38106
  if (last2 && bar.time === last2.time) {
36875
38107
  this.bars[n - 1] = bar;
36876
- if (this.animLiveBarMs <= 0 || this.liveEaseTime !== bar.time) {
38108
+ if (!this.animLiveBar.on || this.liveEaseTime !== bar.time) {
36877
38109
  this.syncLiveEase(bar);
36878
38110
  } else {
36879
38111
  this.animator.start();
@@ -36889,11 +38121,11 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36889
38121
  this.scene.bars = this.bars;
36890
38122
  this.scheduler.invalidate(4 /* Full */);
36891
38123
  }
36892
- /** Set the live-bar glide duration (0 = off). A non-zero value is also remembered as
36893
- * what the config's on/off toggle (`priceScale.animateLastPrice`) switches back on to. */
36894
- setLiveBarEase(ms) {
36895
- this.animLiveBarMs = ms;
36896
- if (ms > 0) this.animLiveBarOnMs = ms;
38124
+ /** Set the reveal (style + duration). A non-off style is also remembered as what the
38125
+ * config's on/off toggle (`animations.intro`) switches back on to. */
38126
+ setIntro(next2) {
38127
+ this.intro = next2;
38128
+ if (next2.style) this.introOnStyle = next2.style;
36897
38129
  }
36898
38130
  /** Snap the eased forming-bar state to `bar` — no glide (a fresh bar or the first tick of one). */
36899
38131
  syncLiveEase(bar) {
@@ -36907,7 +38139,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36907
38139
  const target = this.bars[this.bars.length - 1];
36908
38140
  if (!target || this.liveEaseTime !== target.time) return false;
36909
38141
  const eps = Math.max(1e-9, Math.abs(target.close) * 1e-6);
36910
- const tau = this.animLiveBarMs;
38142
+ const tau = this.animLiveBar.tau;
36911
38143
  const nh = easeToward(this.liveEaseHigh, target.high, dtMs, tau);
36912
38144
  const nl = easeToward(this.liveEaseLow, target.low, dtMs, tau);
36913
38145
  const nc = easeToward(this.liveEaseClose, target.close, dtMs, tau);
@@ -37047,10 +38279,12 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37047
38279
  this.refreshAnchorOffset(model);
37048
38280
  if (model.native && this.extLayers.some((l) => l.def.id === model.native.type)) this.scene.assignIndicatorZTop(model.id);
37049
38281
  else this.scene.assignIndicatorZ(model.id);
37050
- this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
37051
- native: !!model.native,
37052
- ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
37053
- });
38282
+ if (model.legend !== false) {
38283
+ this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
38284
+ native: !!model.native,
38285
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
38286
+ });
38287
+ }
37054
38288
  if (model.native?.type === "volume") {
37055
38289
  this.volumeActive = true;
37056
38290
  this.volumeHidden = false;
@@ -37197,7 +38431,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37197
38431
  this.settingsDialog?.setHiddenSettings(this.hiddenSettings);
37198
38432
  }
37199
38433
  listSettingsIds() {
37200
- return settingsIdCatalog(this.hostSettingsSections);
38434
+ return settingsIdCatalog(this.hostSettingsSections, this.markGroupsInUse());
37201
38435
  }
37202
38436
  onChartTypeSettingsChange(cb) {
37203
38437
  this.chartTypeSettingsCbs.add(cb);
@@ -37223,6 +38457,22 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37223
38457
  this.axisLongPressCbs.add(cb);
37224
38458
  return () => this.axisLongPressCbs.delete(cb);
37225
38459
  }
38460
+ // ── timeline marks (the `chart.marks` model; see the port) ──
38461
+ setTimelineMarks(marks, groups) {
38462
+ this.scene.timelineMarks = marks;
38463
+ this.scene.markGroups = groups;
38464
+ this.scene.marksExpandedStack = null;
38465
+ this.markPopover?.close();
38466
+ this.scheduler?.invalidate(2 /* Chrome */);
38467
+ }
38468
+ onMarkClick(cb) {
38469
+ this.markClickCbs.add(cb);
38470
+ return () => this.markClickCbs.delete(cb);
38471
+ }
38472
+ /** Every group the lane knows: the defined ones, then those marks name without a definition. */
38473
+ markGroupsInUse() {
38474
+ return effectiveMarkGroups(this.scene.timelineMarks, this.scene.markGroups);
38475
+ }
37226
38476
  onViewportChange(cb) {
37227
38477
  this.viewportCbs.add(cb);
37228
38478
  return () => this.viewportCbs.delete(cb);
@@ -37276,7 +38526,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37276
38526
  this.zoomAnchorX = anchorX;
37277
38527
  this.panVelocity = 0;
37278
38528
  this.scrollTargetRO = null;
37279
- if (!this.animZoom) {
38529
+ if (!this.animZoom.on) {
37280
38530
  const v = this.clampViewport(barSpacing, this.anchoredRightOffset(barSpacing));
37281
38531
  this.coords.setViewport(v);
37282
38532
  this.targetBarSpacing = v.barSpacing;
@@ -37289,7 +38539,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37289
38539
  }
37290
38540
  /** Inertial pan: continue with a rightOffset velocity (logical units / ms) that decays. */
37291
38541
  fling(velocity) {
37292
- if (!this.animPan) return;
38542
+ if (!this.animPan.on) return;
37293
38543
  this.scrollTargetRO = null;
37294
38544
  this.panVelocity = velocity;
37295
38545
  this.animator.start();
@@ -37329,7 +38579,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37329
38579
  let active = false;
37330
38580
  const tbs = this.targetBarSpacing;
37331
38581
  if (Math.abs(barSpacing - tbs) > tbs * 1e-3) {
37332
- barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, ZOOM_TAU_MS));
38582
+ barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, this.animZoom.tau));
37333
38583
  rightOffset = this.anchoredRightOffset(barSpacing);
37334
38584
  active = true;
37335
38585
  } else if (barSpacing !== tbs) {
@@ -37339,13 +38589,14 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37339
38589
  const stopVel = FLING_STOP_PX / Math.max(1e-6, barSpacing * this.coords.spacingScale);
37340
38590
  if (Math.abs(this.panVelocity) > stopVel) {
37341
38591
  rightOffset += this.panVelocity * dtMs;
37342
- this.panVelocity *= Math.exp(-dtMs / FLING_TAU_MS);
38592
+ const tau = this.animPan.tau;
38593
+ this.panVelocity = tau > 0 ? this.panVelocity * Math.exp(-dtMs / tau) : 0;
37343
38594
  if (Math.abs(this.panVelocity) <= stopVel) this.panVelocity = 0;
37344
38595
  else active = true;
37345
38596
  }
37346
38597
  if (this.scrollTargetRO != null) {
37347
38598
  const target = this.scrollTargetRO;
37348
- const next2 = easeToward(rightOffset, target, dtMs, SCROLL_TO_TAU_MS);
38599
+ const next2 = easeToward(rightOffset, target, dtMs, this.animScroll.tau);
37349
38600
  if (Math.abs(next2 - target) < 1e-3) {
37350
38601
  rightOffset = target;
37351
38602
  this.scrollTargetRO = null;
@@ -37373,6 +38624,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37373
38624
  * through Math.log), not a non-linear jump. */
37374
38625
  easeScales(dtMs) {
37375
38626
  let moving = false;
38627
+ const tau = this.animAutoscale.tau;
37376
38628
  for (const pane of this.scene.panes.values()) {
37377
38629
  const t = pane.scaleTarget;
37378
38630
  const s = pane.scale;
@@ -37380,8 +38632,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37380
38632
  const lt0 = Math.log(t.min);
37381
38633
  const lt1 = Math.log(t.max);
37382
38634
  const lspan = Math.max(1e-9, Math.abs(lt1 - lt0));
37383
- const n0 = easeToward(Math.log(s.min), lt0, dtMs, SCALE_TAU_MS);
37384
- const n1 = easeToward(Math.log(s.max), lt1, dtMs, SCALE_TAU_MS);
38635
+ const n0 = easeToward(Math.log(s.min), lt0, dtMs, tau);
38636
+ const n1 = easeToward(Math.log(s.max), lt1, dtMs, tau);
37385
38637
  if (Math.abs(n0 - lt0) <= lspan * 1e-3 && Math.abs(n1 - lt1) <= lspan * 1e-3) {
37386
38638
  pane.scale = { min: t.min, max: t.max, log: true };
37387
38639
  } else {
@@ -37391,8 +38643,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37391
38643
  continue;
37392
38644
  }
37393
38645
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
37394
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
37395
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
38646
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
38647
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
37396
38648
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
37397
38649
  nmin = t.min;
37398
38650
  nmax = t.max;
@@ -37405,8 +38657,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37405
38657
  const t = sl.scaleTarget;
37406
38658
  const s = sl.scale;
37407
38659
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
37408
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
37409
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
38660
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
38661
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
37410
38662
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
37411
38663
  nmin = t.min;
37412
38664
  nmax = t.max;
@@ -37427,6 +38679,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37427
38679
  this.scene.crosshair = null;
37428
38680
  this.hoverSeparatorY = null;
37429
38681
  this.lastPointer = null;
38682
+ if (this.marksExpandedBy === "hover") this.setMarksExpanded(null);
38683
+ this.setMarkHover(null);
37430
38684
  this.scheduler.invalidate(1 /* Cursor */);
37431
38685
  this.hoverLogical = null;
37432
38686
  const empty = { time: null, price: null, paneKind: null, values: /* @__PURE__ */ new Map(), ohlc: null };
@@ -37438,6 +38692,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37438
38692
  this.lastPointer = inData ? { x, y } : null;
37439
38693
  this.hoverSeparatorY = x >= 0 && y >= 0 && y <= this.coords.height ? this.separatorHoverY(y) : null;
37440
38694
  this.scheduler.invalidate(1 /* Cursor */);
38695
+ this.setMarksExpanded(inData ? this.chrome.markStackAt(x, y) : null);
38696
+ this.setMarkHover(inData ? this.chrome.markGlyphAt(x, y)?.cluster.key ?? null : null);
37441
38697
  const logical = Math.round(this.coords.xToLogical(x));
37442
38698
  const onBar = logical >= 0 && logical < this.coords.barCount;
37443
38699
  const time = onBar ? this.coords.logicalToTime(logical) : null;
@@ -37467,6 +38723,76 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37467
38723
  const onBar = logical >= 0 && logical < this.coords.barCount;
37468
38724
  for (const cb of this.clickCbs) cb({ time: onBar ? this.coords.logicalToTime(logical) : null, price: null });
37469
38725
  }
38726
+ /**
38727
+ * Fan out (or collapse, with null) a multi-group mark stack; repaints the chrome tier when
38728
+ * it changes. A stack whose glyph holds the open popup stays fanned — the pointer leaving
38729
+ * the plot for the popup must not bury the glyph under the deck (which would close it).
38730
+ */
38731
+ setMarksExpanded(stack, by = "hover") {
38732
+ if (this.scene.marksExpandedStack === stack) return;
38733
+ if (stack === null && this.markPopover?.key) {
38734
+ const open2 = this.chrome.markGlyphByKey(this.markPopover.key);
38735
+ if (open2 && open2.stack === this.scene.marksExpandedStack) return;
38736
+ }
38737
+ this.scene.marksExpandedStack = stack;
38738
+ this.marksExpandedBy = by;
38739
+ this.scheduler?.invalidate(2 /* Chrome */);
38740
+ }
38741
+ /**
38742
+ * A click on the mark lane: a collapsed deck fans out (the touch path — a mouse already
38743
+ * fanned it by hovering), a glyph reports its cluster (`onMarkClick`) and opens the popup
38744
+ * when any of its marks carries content. True when the click landed on the lane.
38745
+ */
38746
+ handleMarkClick(x, y) {
38747
+ const glyph = this.chrome.markGlyphAt(x, y);
38748
+ if (!glyph) {
38749
+ if (this.scene.marksExpandedStack !== null && this.chrome.markStackAt(x, y) === null) this.setMarksExpanded(null);
38750
+ return false;
38751
+ }
38752
+ if (glyph.decked) {
38753
+ this.setMarksExpanded(glyph.stack, "tap");
38754
+ return true;
38755
+ }
38756
+ const marks = glyph.cluster.marks;
38757
+ const first2 = marks[0];
38758
+ const event = { id: first2.id, ids: marks.map((m) => m.id), time: first2.time, ...glyph.cluster.group !== void 0 ? { group: glyph.cluster.group } : {} };
38759
+ for (const cb of this.markClickCbs) cb(event);
38760
+ if (marks.some((m) => m.content !== void 0)) {
38761
+ this.markPopover?.open(glyph.cluster, { x: glyph.x, y: glyph.y, size: glyph.size });
38762
+ } else {
38763
+ this.scene.marksFlash = { key: glyph.cluster.key, until: frameNow() + MARK_FLASH_MS };
38764
+ this.syncMarkPulse();
38765
+ }
38766
+ return true;
38767
+ }
38768
+ /** After a chrome frame: keep the open mark popup on its glyph, or close it once the glyph is gone. */
38769
+ trackMarkPopover() {
38770
+ const key = this.markPopover?.key;
38771
+ if (!key) return;
38772
+ const g = this.chrome.markGlyphByKey(key);
38773
+ this.markPopover.track(g && !(g.decked && g.depth !== 0) ? { x: g.x, y: g.y, size: g.size } : null);
38774
+ }
38775
+ /** The lane glyph under the pointer — it swells once as the pointer lands (a rAF-driven chrome repaint for the pulse's duration). */
38776
+ setMarkHover(key) {
38777
+ if (this.scene.marksHoverKey === key) return;
38778
+ this.scene.marksHoverKey = key;
38779
+ this.scene.marksHoverSince = frameNow();
38780
+ this.scheduler?.invalidate(2 /* Chrome */);
38781
+ this.syncMarkPulse();
38782
+ }
38783
+ /** Run a chrome-tier repaint loop while a glyph's hover pulse or click flash plays; it stops itself once both are over. */
38784
+ syncMarkPulse() {
38785
+ if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
38786
+ const tick = () => {
38787
+ this.markPulseRaf = null;
38788
+ const now = frameNow();
38789
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
38790
+ this.scheduler?.invalidate(2 /* Chrome */);
38791
+ const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
38792
+ if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
38793
+ };
38794
+ this.markPulseRaf = requestAnimationFrame(tick);
38795
+ }
37470
38796
  paneAtY(y) {
37471
38797
  return this.paneNodeAtY(y);
37472
38798
  }
@@ -37858,6 +39184,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37858
39184
  } else if (repaintsChrome(level) && this.paintedData) {
37859
39185
  this.chrome.prepare(this.scene, this.coords, this.theme);
37860
39186
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
39187
+ this.trackMarkPopover();
37861
39188
  }
37862
39189
  this.crosshairLayer.render(this.scene, this.coords, this.theme, this.hoverSeparatorY, this.externalCrossPx());
37863
39190
  if (!repaintsData(level) && this.paintedData) this.repaintCursorLayers();
@@ -37873,7 +39200,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37873
39200
  const lp = this.layerPane(l.def.id) ?? pane;
37874
39201
  if (lp.collapsed) continue;
37875
39202
  l.instance.render(this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs));
37876
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
39203
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
37877
39204
  }
37878
39205
  }
37879
39206
  /** Blank one SDK layer canvas (a collapsed host pane suppresses the layer's painting). */
@@ -37938,7 +39265,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37938
39265
  const args = this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs);
37939
39266
  l.instance.render(args);
37940
39267
  if (lp === pane) folded = foldBaseModulation(folded, l.instance.modulateBase?.(args) ?? null);
37941
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
39268
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
37942
39269
  }
37943
39270
  if (folded) {
37944
39271
  if (folded.candleBodyScale != null) this.backend.candleBodyScale = clamp012(folded.candleBodyScale) || 0.01;
@@ -37957,6 +39284,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
37957
39284
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
37958
39285
  this.backend.render(this.scene, this.coords, this.theme);
37959
39286
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
39287
+ this.trackMarkPopover();
37960
39288
  this.userDrawings?.render();
37961
39289
  if (easeLive && liveActual) this.bars[li] = liveActual;
37962
39290
  this.paintedData = true;
@@ -38469,7 +39797,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
38469
39797
  const map2 = /* @__PURE__ */ new Map();
38470
39798
  for (const pane of this.scene.panes.values()) {
38471
39799
  if (!pane.collapsed) continue;
38472
- const models = this.scene.orderedIndicatorsForPane(pane.id);
39800
+ const models = this.scene.orderedIndicatorsForPane(pane.id).filter((m) => m.legend !== false);
38473
39801
  const merged = new Set(this.scene.ownScaleIndicatorsForPane(pane.id).map((m) => m.id));
38474
39802
  const master = models.find((m) => !merged.has(m.id)) ?? models[0];
38475
39803
  map2.set(pane.id, master?.id ?? null);
@@ -38747,6 +40075,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
38747
40075
  }
38748
40076
  var VolumeIndicator = class {
38749
40077
  constructor() {
40078
+ /** Null until start() — pre-start setInputs/resume must record without pushing. */
40079
+ this.ctx = null;
38750
40080
  this.inputs = {};
38751
40081
  }
38752
40082
  start(ctx, inputs) {
@@ -38763,13 +40093,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
38763
40093
  }
38764
40094
  setInputs(inputs) {
38765
40095
  this.inputs = inputs;
38766
- this.ctx.pushData(volumeLayerData(inputs));
40096
+ this.ctx?.pushData(volumeLayerData(inputs));
38767
40097
  }
38768
40098
  /** Hiding is a renderer-layer flag (set via `setIndicatorVisible`); no resources to free. */
38769
40099
  suspend() {
38770
40100
  }
38771
40101
  resume() {
38772
- this.ctx.pushData(volumeLayerData(this.inputs));
40102
+ this.ctx?.pushData(volumeLayerData(this.inputs));
38773
40103
  }
38774
40104
  stop() {
38775
40105
  }
@@ -38815,6 +40145,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
38815
40145
  }
38816
40146
  var VpvrIndicator = class {
38817
40147
  constructor() {
40148
+ /** Null until start() — pre-start setInputs/resume must record without pushing. */
40149
+ this.ctx = null;
38818
40150
  this.inputs = {};
38819
40151
  }
38820
40152
  start(ctx, inputs) {
@@ -38831,13 +40163,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
38831
40163
  }
38832
40164
  setInputs(inputs) {
38833
40165
  this.inputs = inputs;
38834
- this.ctx.pushData(vpvrLayerData(inputs));
40166
+ this.ctx?.pushData(vpvrLayerData(inputs));
38835
40167
  }
38836
40168
  /** Hiding is a renderer-layer flag (set via `setIndicatorVisible`); no resources to free. */
38837
40169
  suspend() {
38838
40170
  }
38839
40171
  resume() {
38840
- this.ctx.pushData(vpvrLayerData(this.inputs));
40172
+ this.ctx?.pushData(vpvrLayerData(this.inputs));
38841
40173
  }
38842
40174
  stop() {
38843
40175
  }
@@ -41289,6 +42621,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
41289
42621
  if (Object.keys(defaults2).length > 0) this.rendererControl.set(defaults2);
41290
42622
  this.panesControl = new PanesControl(this.orchestrator);
41291
42623
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
42624
+ this.marksControl = new MarksControl(this.orchestrator.marks);
41292
42625
  }
41293
42626
  /**
41294
42627
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -41507,6 +42840,17 @@ ${overlayScrollbarCss(".vela-sd-pane")}
41507
42840
  get drawings() {
41508
42841
  return this.drawingsControl;
41509
42842
  }
42843
+ /**
42844
+ * The chart's timeline-marks control surface: host events pinned to a bar and shown
42845
+ * as glyphs on a lane above the time axis, each opening a popup on click —
42846
+ * `chart.marks.add({ id, time, glyph, title, content })`, `chart.marks.set(list)`,
42847
+ * `chart.marks.defineGroup({ id, label })`. Marks are data, not user state: re-supply
42848
+ * them on `market:changed`. On a renderer without the `timelineMarks` capability the
42849
+ * model still fills but nothing paints (`chart.marks.supported`).
42850
+ */
42851
+ get marks() {
42852
+ return this.marksControl;
42853
+ }
41510
42854
  on(event, handler) {
41511
42855
  return this.orchestrator.events.on(event, handler);
41512
42856
  }
@@ -42952,6 +44296,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
42952
44296
  exports.INVALID = INVALID;
42953
44297
  exports.LIGHT_THEME = LIGHT_THEME;
42954
44298
  exports.MARKER = MARKER;
44299
+ exports.MarksControl = MarksControl;
42955
44300
  exports.MultiProviderFeed = MultiProviderFeed;
42956
44301
  exports.NEUTRAL = NEUTRAL;
42957
44302
  exports.NativeRenderer = NativeRenderer;
@@ -42963,6 +44308,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
42963
44308
  exports.SESSION_PRE = SESSION_PRE;
42964
44309
  exports.SLATE = SLATE;
42965
44310
  exports.SLATE_DEEP = SLATE_DEEP;
44311
+ exports.SecondClock = SecondClock;
42966
44312
  exports.TRADE_EXIT = TRADE_EXIT;
42967
44313
  exports.TRADE_LONG = TRADE_LONG;
42968
44314
  exports.TRADE_SHORT = TRADE_SHORT;