@luxalgo/vela 0.6.21 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/{DataProvider-Cb-2lC5O.d.ts → DataProvider-Bu9E5Zh5.d.ts} +1 -1
  2. package/dist/{DataProvider-UtWw2CgJ.d.cts → DataProvider-Ck2qyS_9.d.cts} +1 -1
  3. package/dist/{chunk-BRPFYKTT.js → chunk-A347YL2P.js} +30 -7
  4. package/dist/{chunk-TRZQQUTR.js → chunk-A4G64KVF.js} +1442 -126
  5. package/dist/{chunk-T5Z5YUCF.js → chunk-CZRNTHD6.js} +24 -2
  6. package/dist/{chunk-XCBJU674.js → chunk-UHXG5J7C.js} +1 -1
  7. package/dist/{contributions-D8HdlKmp.d.cts → contributions-BjQ7hyIx.d.ts} +108 -3
  8. package/dist/{contributions-FqIWhN4p.d.ts → contributions-Cv2Mutvn.d.cts} +108 -3
  9. package/dist/index.cjs +1465 -125
  10. package/dist/index.d.cts +76 -20
  11. package/dist/index.d.ts +76 -20
  12. package/dist/index.js +2 -2
  13. package/dist/{options-BCRmYALw.d.cts → options-ex2_gtKp.d.cts} +200 -12
  14. package/dist/{options-BCRmYALw.d.ts → options-ex2_gtKp.d.ts} +200 -12
  15. package/dist/{plugin-B4mnNAeh.d.ts → plugin-CiyhU7pi.d.ts} +3 -3
  16. package/dist/{plugin-Ccupy_BV.d.cts → plugin-absNH7Yn.d.cts} +3 -3
  17. package/dist/plugin.d.cts +4 -4
  18. package/dist/plugin.d.ts +4 -4
  19. package/dist/providers/binance.d.cts +2 -2
  20. package/dist/providers/binance.d.ts +2 -2
  21. package/dist/providers/coinbase.d.cts +2 -2
  22. package/dist/providers/coinbase.d.ts +2 -2
  23. package/dist/providers/hyperliquid.d.cts +2 -2
  24. package/dist/providers/hyperliquid.d.ts +2 -2
  25. package/dist/{statusline-model-CCP1zm_Z.d.ts → statusline-model-BXoU4Kua.d.ts} +8 -4
  26. package/dist/{statusline-model-E7CMw5pQ.d.cts → statusline-model-cRhPBLPO.d.cts} +8 -4
  27. package/dist/ui.cjs +24 -2
  28. package/dist/ui.d.cts +9 -3
  29. package/dist/ui.d.ts +9 -3
  30. package/dist/ui.js +2 -2
  31. package/dist/vela.global.js +1465 -125
  32. package/dist/vela.global.min.js +77 -53
  33. package/dist/widget.cjs +1490 -129
  34. package/dist/widget.d.cts +6 -6
  35. package/dist/widget.d.ts +6 -6
  36. package/dist/widget.js +5 -5
  37. package/dist/workspace.cjs +1490 -129
  38. package/dist/workspace.d.cts +8 -5
  39. package/dist/workspace.d.ts +8 -5
  40. package/dist/workspace.js +4 -4
  41. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -26,19 +26,48 @@ var menu__namespace = /*#__PURE__*/_interopNamespace(menu);
26
26
  var dialog__namespace = /*#__PURE__*/_interopNamespace(dialog);
27
27
 
28
28
  // src/core/options.ts
29
+ var ZOOM_EASE_DEFAULT_MS = 70;
30
+ var PAN_INERTIA_DEFAULT_MS = 110;
31
+ var SCROLL_EASE_DEFAULT_MS = 130;
32
+ var AUTOSCALE_EASE_DEFAULT_MS = 80;
29
33
  var LIVE_BAR_EASE_DEFAULT_MS = 90;
30
- var LIVE_BAR_EASE_MAX_MS = 1e3;
31
- function resolveLiveBarEaseMs(value) {
32
- if (value === true) return LIVE_BAR_EASE_DEFAULT_MS;
34
+ var INTRO_DURATION_DEFAULT_MS = 650;
35
+ var ANIMATION_EASE_MAX_MS = 1e3;
36
+ var LIVE_BAR_EASE_MAX_MS = ANIMATION_EASE_MAX_MS;
37
+ var INTRO_DURATION_MAX_MS = 5e3;
38
+ function resolveEaseMs(value, defaultMs, maxMs = ANIMATION_EASE_MAX_MS) {
39
+ if (value === true) return defaultMs;
33
40
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
34
- return Math.min(value, LIVE_BAR_EASE_MAX_MS);
41
+ return Math.min(value, maxMs);
42
+ }
43
+ function resolveLiveBarEaseMs(value) {
44
+ return resolveEaseMs(value, LIVE_BAR_EASE_DEFAULT_MS, LIVE_BAR_EASE_MAX_MS);
45
+ }
46
+ function resolveIntro(value) {
47
+ const off = { style: false, duration: 0 };
48
+ if (value === true) return { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
49
+ if (value === "settle" || value === "grow") return { style: value, duration: INTRO_DURATION_DEFAULT_MS };
50
+ if (value && typeof value === "object") {
51
+ const o = value;
52
+ const d = o.duration;
53
+ return {
54
+ style: o.style === "grow" ? "grow" : "settle",
55
+ duration: typeof d === "number" && Number.isFinite(d) && d > 0 ? Math.min(d, INTRO_DURATION_MAX_MS) : INTRO_DURATION_DEFAULT_MS
56
+ };
57
+ }
58
+ return off;
35
59
  }
36
60
  function resolveAnimations(animations) {
37
- if (typeof animations === "boolean") return { animZoom: animations, animPan: animations, animLiveBar: 0 };
61
+ if (animations === false) return { animZoom: 0, animPan: 0, animScroll: 0, animAutoscale: 0, animLiveBar: 0, animIntro: { style: false, duration: 0 } };
62
+ const cfg = animations === true || animations == null ? {} : animations;
63
+ const animPan = resolveEaseMs(cfg.pan ?? true, PAN_INERTIA_DEFAULT_MS);
38
64
  return {
39
- animZoom: animations?.zoom ?? true,
40
- animPan: animations?.pan ?? true,
41
- animLiveBar: resolveLiveBarEaseMs(animations?.liveBar)
65
+ animZoom: resolveEaseMs(cfg.zoom ?? true, ZOOM_EASE_DEFAULT_MS),
66
+ animPan,
67
+ animScroll: cfg.scroll === void 0 ? animPan > 0 ? SCROLL_EASE_DEFAULT_MS : 0 : resolveEaseMs(cfg.scroll, SCROLL_EASE_DEFAULT_MS),
68
+ animAutoscale: resolveEaseMs(cfg.autoscale ?? true, AUTOSCALE_EASE_DEFAULT_MS),
69
+ animLiveBar: resolveLiveBarEaseMs(cfg.liveBar),
70
+ animIntro: resolveIntro(cfg.intro ?? true)
42
71
  };
43
72
  }
44
73
 
@@ -7081,6 +7110,93 @@ var DrawingController = class {
7081
7110
  }
7082
7111
  };
7083
7112
 
7113
+ // src/core/marks/MarksController.ts
7114
+ var MarksController = class {
7115
+ constructor(renderer, events) {
7116
+ this.renderer = renderer;
7117
+ /** Insertion-ordered — the order a cluster falls back to for equal times. */
7118
+ this.marks = /* @__PURE__ */ new Map();
7119
+ this.groups = /* @__PURE__ */ new Map();
7120
+ this.subs = [];
7121
+ this.enabled = !!renderer.capabilities.timelineMarks && typeof renderer.setTimelineMarks === "function";
7122
+ if (this.enabled && renderer.onMarkClick) this.subs.push(renderer.onMarkClick((e) => events.emit("mark:click", e)));
7123
+ }
7124
+ /** Whether the active renderer paints timeline marks. */
7125
+ get supported() {
7126
+ return this.enabled;
7127
+ }
7128
+ /** Add (or replace, by id) one mark. */
7129
+ add(mark) {
7130
+ this.marks.set(mark.id, validateMark(mark));
7131
+ this.sync();
7132
+ }
7133
+ /** Replace the whole set — a market switch. */
7134
+ set(marks) {
7135
+ this.marks.clear();
7136
+ for (const m of marks) this.marks.set(m.id, validateMark(m));
7137
+ this.sync();
7138
+ }
7139
+ remove(id) {
7140
+ const had = this.marks.delete(id);
7141
+ if (had) this.sync();
7142
+ return had;
7143
+ }
7144
+ clear() {
7145
+ if (this.marks.size === 0) return;
7146
+ this.marks.clear();
7147
+ this.sync();
7148
+ }
7149
+ /** Every mark, in insertion order (shallow copies — mutating one changes nothing). */
7150
+ all() {
7151
+ return [...this.marks.values()].map((m) => ({ ...m, glyph: { ...m.glyph } }));
7152
+ }
7153
+ /** Define (or replace) a group's presentation — its settings label and default visibility. */
7154
+ defineGroup(group) {
7155
+ if (!group || typeof group.id !== "string" || group.id.length === 0) throw new Error("[vela] marks.defineGroup: `id` must be a non-empty string");
7156
+ if (typeof group.label !== "string") throw new Error(`[vela] marks.defineGroup: group "${group.id}" needs a string \`label\``);
7157
+ this.groups.set(group.id, { ...group });
7158
+ this.sync();
7159
+ }
7160
+ /** The defined groups, in definition order. */
7161
+ groupDefinitions() {
7162
+ return [...this.groups.values()].map((g) => ({ ...g }));
7163
+ }
7164
+ /**
7165
+ * Show or hide one group's marks. The choice lives in the renderer's cosmetic config
7166
+ * (the `marks` feature) — what the settings dialog's Events checkboxes edit and what
7167
+ * a persisted chart restores — so it warns + no-ops on a renderer without it.
7168
+ */
7169
+ setGroupVisible(id, visible) {
7170
+ if (!this.renderer.features.includes("marks")) {
7171
+ console.warn(`[vela] renderer "${this.renderer.name}" does not paint timeline marks \u2014 setGroupVisible ignored.`);
7172
+ return;
7173
+ }
7174
+ this.renderer.applyFeature("marks", { groups: { [id]: visible } });
7175
+ }
7176
+ /** A group's effective visibility: the user's (persisted) choice, else the group's declared default, else visible. */
7177
+ isGroupVisible(id) {
7178
+ const state = this.renderer.readFeature("marks");
7179
+ const chosen = state?.groups?.[id];
7180
+ if (typeof chosen === "boolean") return chosen;
7181
+ return this.groups.get(id)?.visible !== false;
7182
+ }
7183
+ destroy() {
7184
+ for (const unsub of this.subs) unsub();
7185
+ this.subs.length = 0;
7186
+ }
7187
+ sync() {
7188
+ if (!this.enabled) return;
7189
+ this.renderer.setTimelineMarks([...this.marks.values()], [...this.groups.values()]);
7190
+ }
7191
+ };
7192
+ function validateMark(mark) {
7193
+ if (!mark || typeof mark !== "object") throw new Error("[vela] marks: a mark must be an object");
7194
+ if (typeof mark.id !== "string" || mark.id.length === 0) throw new Error("[vela] marks: `id` must be a non-empty string");
7195
+ if (typeof mark.time !== "number" || !Number.isFinite(mark.time)) throw new Error(`[vela] marks: mark "${mark.id}" needs a finite epoch-ms \`time\``);
7196
+ 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\``);
7197
+ return { ...mark, glyph: { ...mark.glyph } };
7198
+ }
7199
+
7084
7200
  // src/data/timeframe.ts
7085
7201
  var NAMED_TF_MS = { D: 864e5, W: 6048e5, M: 2592e6 };
7086
7202
  function timeframeToMs(timeframe) {
@@ -7759,6 +7875,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
7759
7875
  marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
7760
7876
  });
7761
7877
  this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
7878
+ this.marks = new MarksController(this.renderer, this.events);
7762
7879
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7763
7880
  this.endLoad();
7764
7881
  this.events.emit("data:unresolved", info);
@@ -8623,6 +8740,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8623
8740
  record.session = void 0;
8624
8741
  record.native?.instance.suspend();
8625
8742
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, false);
8743
+ else if (record.native) this.mountHiddenNativeRow(id, record);
8626
8744
  } else {
8627
8745
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, true);
8628
8746
  record.pendingStructural = true;
@@ -8710,6 +8828,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8710
8828
  this.unresolvedUnsub = null;
8711
8829
  this.feed.destroy?.();
8712
8830
  this.drawings.destroy();
8831
+ this.marks.destroy();
8713
8832
  this.renderer.destroy();
8714
8833
  this.events.clear();
8715
8834
  }
@@ -8823,6 +8942,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8823
8942
  overlay: d.overlay,
8824
8943
  paneHint: d.paneHint,
8825
8944
  native: { type: record.native.type },
8945
+ ...d.legend === false ? { legend: false } : {},
8826
8946
  ...out.paneAxis != null ? { paneAxis: out.paneAxis } : {},
8827
8947
  series: out.series ?? [],
8828
8948
  fills: out.fills ?? [],
@@ -8845,9 +8965,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
8845
8965
  * over it in place (`pendingStructural`), clears the spinner, and only THEN fires
8846
8966
  * `indicator:added`/`ready` — so event semantics and `inspect()` (which skips
8847
8967
  * loading records) still mean "the indicator produced output".
8968
+ *
8969
+ * A HIDDEN record mounts too — dimmed, no spinner (its session never starts while
8970
+ * hidden, so nothing is computing and no model will ever arrive to mount the row
8971
+ * later). Without this an indicator ADDED hidden (a restored ledger/ext entry) had
8972
+ * no legend row at all: invisible AND unreachable — the eye that unhides it never
8973
+ * existed. The hidden mount announces immediately for the same reason: the "first
8974
+ * computed model" that normally announces cannot come until the indicator is shown,
8975
+ * and host UIs (object tree, landing watchers) must know it exists NOW.
8848
8976
  */
8849
8977
  mountLoadingPlaceholder(id, record) {
8850
- if (record.renderHandle || record.hidden || !record.prepared) return;
8978
+ if (record.renderHandle || !record.prepared) return;
8851
8979
  const meta = record.prepared.meta;
8852
8980
  const model = {
8853
8981
  id,
@@ -8871,8 +8999,34 @@ var EngineOrchestrator = class _EngineOrchestrator {
8871
8999
  this.ensurePaneFor(paneId);
8872
9000
  record.renderHandle = this.renderer.mountIndicator(model);
8873
9001
  record.pendingStructural = true;
9002
+ if (record.hidden) {
9003
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
9004
+ this.announce(record, this.handles.get(id));
9005
+ return;
9006
+ }
8874
9007
  this.setLoading(record, true);
8875
9008
  }
9009
+ /**
9010
+ * Mount the legend row for a NATIVE indicator that is being hidden BEFORE it ever
9011
+ * started (a restored-hidden ledger entry: `startNativeIndicator` bails on hidden
9012
+ * records, so no model — and therefore no row — would ever mount). The native
9013
+ * counterpart of {@link mountLoadingPlaceholder}'s hidden branch: an empty model
9014
+ * carries the title + inputs schema, the renderer marks the row hidden, and the
9015
+ * announce makes the indicator visible to host UIs. Showing later STARTS the
9016
+ * instance (the `started` flag path) and its first emit remounts over this row.
9017
+ */
9018
+ mountHiddenNativeRow(id, record) {
9019
+ if (record.renderHandle || !record.native) return;
9020
+ const model = this.buildNativeModel(record, {});
9021
+ const paneId = this.routePane(id, model, record.options ?? {});
9022
+ this.placeModel(model, id, paneId);
9023
+ record.model = model;
9024
+ this.ensurePaneFor(paneId);
9025
+ record.renderHandle = this.renderer.mountIndicator(model);
9026
+ record.pendingStructural = true;
9027
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
9028
+ this.announce(record, this.handles.get(id));
9029
+ }
8876
9030
  /** Flip the record's loading state and reflect it in the legend row (spinner on/off). */
8877
9031
  setLoading(record, loading) {
8878
9032
  record.loading = loading;
@@ -8907,6 +9061,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8907
9061
  for (const r of this.registry.all()) {
8908
9062
  const model = r.model;
8909
9063
  if (!model) continue;
9064
+ if (model.legend === false) continue;
8910
9065
  const paneId = model.paneId ?? "price";
8911
9066
  if (!byPane.has(paneId)) byPane.set(paneId, []);
8912
9067
  byPane.get(paneId).push({
@@ -9482,6 +9637,16 @@ var RendererControl = class {
9482
9637
  this.renderer.setLayoutMode?.(mode);
9483
9638
  return this;
9484
9639
  }
9640
+ /**
9641
+ * Drive the renderer's time-of-day chrome (the countdown-to-bar-close chip) from the
9642
+ * host's own second pulse, so it ticks in step with a host clock display instead of
9643
+ * on a separate timer that can read a different second. `null` hands the pulse back
9644
+ * to the renderer. Silent no-op on a renderer without time-of-day chrome.
9645
+ */
9646
+ setWallClock(clock) {
9647
+ this.renderer.setWallClock?.(clock);
9648
+ return this;
9649
+ }
9485
9650
  };
9486
9651
 
9487
9652
  // src/core/renderer-defaults.ts
@@ -10374,6 +10539,95 @@ var DrawingsControl = class {
10374
10539
  }
10375
10540
  };
10376
10541
 
10542
+ // src/core/MarksControl.ts
10543
+ var MarksControl = class {
10544
+ constructor(ctrl) {
10545
+ this.ctrl = ctrl;
10546
+ }
10547
+ /** Whether the active renderer paints timeline marks. */
10548
+ get supported() {
10549
+ return this.ctrl.supported;
10550
+ }
10551
+ /** Add one mark; an existing id is replaced in place. */
10552
+ add(mark) {
10553
+ this.ctrl.add(mark);
10554
+ return this;
10555
+ }
10556
+ /** Replace the whole set (a market switch). */
10557
+ set(marks) {
10558
+ this.ctrl.set(marks);
10559
+ return this;
10560
+ }
10561
+ remove(id) {
10562
+ this.ctrl.remove(id);
10563
+ return this;
10564
+ }
10565
+ clear() {
10566
+ this.ctrl.clear();
10567
+ return this;
10568
+ }
10569
+ /** Every mark, in insertion order. */
10570
+ all() {
10571
+ return this.ctrl.all();
10572
+ }
10573
+ /**
10574
+ * Define a visibility group's presentation: the label of its checkbox in chart
10575
+ * settings (the Events tab) and its default visibility. Marks may name a group
10576
+ * that was never defined — it then shows its capitalized id.
10577
+ */
10578
+ defineGroup(group) {
10579
+ this.ctrl.defineGroup(group);
10580
+ return this;
10581
+ }
10582
+ /** The defined groups, in definition order. */
10583
+ groups() {
10584
+ return this.ctrl.groupDefinitions();
10585
+ }
10586
+ /** Show or hide one group's marks — the same switch as the settings checkbox, persisted with the chart's config. */
10587
+ setGroupVisible(id, visible = true) {
10588
+ this.ctrl.setGroupVisible(id, visible);
10589
+ return this;
10590
+ }
10591
+ /** A group's effective visibility (the user's choice, else the group's declared default). */
10592
+ isGroupVisible(id) {
10593
+ return this.ctrl.isGroupVisible(id);
10594
+ }
10595
+ };
10596
+
10597
+ // src/core/util/wall-clock.ts
10598
+ var BOUNDARY_SLACK_MS = 5;
10599
+ var SecondClock = class _SecondClock {
10600
+ constructor(now = () => Date.now()) {
10601
+ this.now = now;
10602
+ this.subs = /* @__PURE__ */ new Set();
10603
+ this.timer = null;
10604
+ }
10605
+ onTick(cb) {
10606
+ this.subs.add(cb);
10607
+ if (this.timer == null) this.arm();
10608
+ return () => {
10609
+ this.subs.delete(cb);
10610
+ if (this.subs.size === 0 && this.timer != null) {
10611
+ clearTimeout(this.timer);
10612
+ this.timer = null;
10613
+ }
10614
+ };
10615
+ }
10616
+ /** Milliseconds from `now` to just past the next second boundary. */
10617
+ static delayToNextSecond(now) {
10618
+ const intoSecond = (now % 1e3 + 1e3) % 1e3;
10619
+ return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
10620
+ }
10621
+ arm() {
10622
+ this.timer = setTimeout(() => {
10623
+ this.timer = null;
10624
+ const now = this.now();
10625
+ for (const cb of this.subs) cb(now);
10626
+ if (this.subs.size > 0) this.arm();
10627
+ }, _SecondClock.delayToNextSecond(this.now()));
10628
+ }
10629
+ };
10630
+
10377
10631
  // src/core/color.ts
10378
10632
  function parseRgb(color) {
10379
10633
  const s = color.trim();
@@ -11034,7 +11288,7 @@ function intersectRects(a, b) {
11034
11288
  return { left, top, right, bottom, width: right - left, height: bottom - top };
11035
11289
  }
11036
11290
  function placePopover(a) {
11037
- let left = a.align === "end" ? a.trigger.right - a.pop.width : a.trigger.left;
11291
+ 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;
11038
11292
  const below = a.trigger.bottom + a.gap;
11039
11293
  const above = a.trigger.top - a.pop.height - a.gap;
11040
11294
  const fitsBelow = below + a.pop.height <= a.clamp.bottom;
@@ -11106,6 +11360,8 @@ var Popover = class {
11106
11360
  this.onKey = null;
11107
11361
  this.onReflow = null;
11108
11362
  this.shown = false;
11363
+ /** The pending removal of a fading-out shell; a show() that reuses the shell cancels it. */
11364
+ this.leaveTimer = null;
11109
11365
  const doc = opts.trigger.ownerDocument;
11110
11366
  injectStyles(POPOVER_STYLE_ID, POPOVER_CSS, doc);
11111
11367
  this.trigger = opts.trigger;
@@ -11113,6 +11369,7 @@ var Popover = class {
11113
11369
  this.ctrl = popoverController(opts);
11114
11370
  this.boundary = opts.boundary ?? "viewport";
11115
11371
  this.theme = opts.theme;
11372
+ this.fadeMs = Math.max(0, opts.fadeMs ?? 0);
11116
11373
  this.el = doc.createElement("div");
11117
11374
  this.el.className = "vela-popover vela-ui-layer" + (opts.className ? ` ${opts.className}` : "");
11118
11375
  this.el.dataset.position = this.ctrl.position;
@@ -11136,11 +11393,21 @@ var Popover = class {
11136
11393
  return;
11137
11394
  }
11138
11395
  if (open && open !== this) open.hide();
11396
+ if (this.leaveTimer !== null) {
11397
+ clearTimeout(this.leaveTimer);
11398
+ this.leaveTimer = null;
11399
+ }
11139
11400
  ensureUIHost(this.el, this.theme);
11401
+ if (this.fadeMs > 0) {
11402
+ this.el.style.transition = `opacity ${this.fadeMs}ms ease`;
11403
+ this.el.style.opacity = "0";
11404
+ this.el.style.pointerEvents = "";
11405
+ }
11140
11406
  this.host.appendChild(this.el);
11141
11407
  this.shown = true;
11142
11408
  open = this;
11143
11409
  this.place();
11410
+ if (this.fadeMs > 0) this.el.style.opacity = "1";
11144
11411
  const onOutside = (ev) => {
11145
11412
  const t = ev.target;
11146
11413
  if (this.el.contains(t) || this.trigger.contains(t)) return;
@@ -11173,7 +11440,16 @@ var Popover = class {
11173
11440
  this.onOutside = null;
11174
11441
  this.onKey = null;
11175
11442
  this.onReflow = null;
11176
- this.el.remove();
11443
+ if (this.fadeMs > 0) {
11444
+ this.el.style.opacity = "0";
11445
+ this.el.style.pointerEvents = "none";
11446
+ this.leaveTimer = setTimeout(() => {
11447
+ this.leaveTimer = null;
11448
+ this.el.remove();
11449
+ }, this.fadeMs);
11450
+ } else {
11451
+ this.el.remove();
11452
+ }
11177
11453
  this.shown = false;
11178
11454
  if (open === this) open = null;
11179
11455
  this.ctrl.onClose?.();
@@ -15380,6 +15656,8 @@ var NATIVE_CAPABILITIES = {
15380
15656
  // canvas-painted into the owning indicator's interleave slice
15381
15657
  trades: true,
15382
15658
  // strategy order-fill markers (arrows + labels + fill-price ticks)
15659
+ timelineMarks: true,
15660
+ // host events on a lane above the time axis (glyphs + detail popup)
15383
15661
  inputsUI: true
15384
15662
  // reuses the DOM InputsUI
15385
15663
  };
@@ -15652,9 +15930,12 @@ function mergeConfig(base, patch) {
15652
15930
  const gh = asObject(grid.horzLines);
15653
15931
  const cross = asObject(p.crosshair);
15654
15932
  const ps = asObject(p.priceScale);
15933
+ const anim = asObject(p.animations);
15655
15934
  const panes = asObject(p.panes);
15656
15935
  const trades = asObject(p.trades);
15657
15936
  const ts = asObject(p.timeScale);
15937
+ const marks = asObject(p.marks);
15938
+ const markGroups = asObject(marks.groups);
15658
15939
  const candles = asObject(p.candles);
15659
15940
  const bars = asObject(p.bars);
15660
15941
  const line = asObject(p.line);
@@ -15701,6 +15982,12 @@ function mergeConfig(base, patch) {
15701
15982
  countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown,
15702
15983
  animateLastPrice: isBool(ps.animateLastPrice) ? ps.animateLastPrice : base.priceScale.animateLastPrice
15703
15984
  },
15985
+ animations: {
15986
+ zoom: isBool(anim.zoom) ? anim.zoom : base.animations.zoom,
15987
+ pan: isBool(anim.pan) ? anim.pan : base.animations.pan,
15988
+ autoscale: isBool(anim.autoscale) ? anim.autoscale : base.animations.autoscale,
15989
+ intro: isBool(anim.intro) ? anim.intro : base.animations.intro
15990
+ },
15704
15991
  panes: {
15705
15992
  separatorColor: isColor(panes.separatorColor) ? panes.separatorColor : base.panes.separatorColor
15706
15993
  },
@@ -15715,6 +16002,15 @@ function mergeConfig(base, patch) {
15715
16002
  timeScale: {
15716
16003
  timezone: typeof ts.timezone === "string" && ts.timezone ? ts.timezone : base.timeScale.timezone
15717
16004
  },
16005
+ marks: {
16006
+ visible: isBool(marks.visible) ? marks.visible : base.marks.visible,
16007
+ // Additive like `stacking.series`: a patch names only the groups it carries, so a
16008
+ // choice stored for a group the host has not registered yet survives verbatim.
16009
+ groups: {
16010
+ ...base.marks.groups,
16011
+ ...Object.fromEntries(Object.entries(markGroups).filter(([, v]) => isBool(v)))
16012
+ }
16013
+ },
15718
16014
  candles: {
15719
16015
  upColor: isColor(candles.upColor) ? candles.upColor : base.candles.upColor,
15720
16016
  downColor: isColor(candles.downColor) ? candles.downColor : base.candles.downColor,
@@ -17233,6 +17529,31 @@ var Animator = class {
17233
17529
  }
17234
17530
  }
17235
17531
  };
17532
+ var EaseSetting = class {
17533
+ /** `defaultMs` is what the on/off switch restores when nothing else was configured;
17534
+ * `initialMs` (default: `defaultMs`) is the starting value — 0 for a motion that
17535
+ * ships off. */
17536
+ constructor(defaultMs, initialMs = defaultMs) {
17537
+ this.onMs = defaultMs;
17538
+ this.ms = initialMs;
17539
+ }
17540
+ /** The active time-constant; 0 when off. */
17541
+ get tau() {
17542
+ return this.ms;
17543
+ }
17544
+ get on() {
17545
+ return this.ms > 0;
17546
+ }
17547
+ /** Set the time-constant (0 = off). A non-zero value becomes what `toggle(true)` restores. */
17548
+ set(ms) {
17549
+ this.ms = ms;
17550
+ if (ms > 0) this.onMs = ms;
17551
+ }
17552
+ /** On/off only — the duration stays the last one configured. */
17553
+ toggle(on) {
17554
+ this.ms = on ? this.onMs : 0;
17555
+ }
17556
+ };
17236
17557
  function easeToward(current, target, dtMs, tauMs) {
17237
17558
  if (tauMs <= 0) return target;
17238
17559
  return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
@@ -17956,6 +18277,25 @@ function drawTextLines(ctx, lines, x, firstY, step, color) {
17956
18277
  for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], x, firstY + i * step);
17957
18278
  }
17958
18279
 
18280
+ // src/renderers/shared/marks-state.ts
18281
+ function defaultMarksState() {
18282
+ return { visible: true, groups: {} };
18283
+ }
18284
+ function mergeMarksState(base, patch) {
18285
+ if (typeof patch === "boolean") return { visible: patch, groups: { ...base.groups } };
18286
+ const p = patch && typeof patch === "object" ? patch : {};
18287
+ const g = p.groups && typeof p.groups === "object" ? p.groups : {};
18288
+ const groups = { ...base.groups };
18289
+ for (const [id, v] of Object.entries(g)) if (typeof v === "boolean") groups[id] = v;
18290
+ return { visible: typeof p.visible === "boolean" ? p.visible : base.visible, groups };
18291
+ }
18292
+ function markGroupVisible(state, groupId, groups) {
18293
+ if (groupId === void 0) return true;
18294
+ const chosen = state.groups[groupId];
18295
+ if (typeof chosen === "boolean") return chosen;
18296
+ return groups.find((g) => g.id === groupId)?.visible !== false;
18297
+ }
18298
+
17959
18299
  // src/renderers/native/core/SceneGraph.ts
17960
18300
  var SceneGraph = class {
17961
18301
  constructor() {
@@ -18019,6 +18359,20 @@ var SceneGraph = class {
18019
18359
  /** Strategy trade-marker display (the `tradeMarkers` feature): master toggle, the
18020
18360
  * two text lines, and the palette. Trade markers always paint on the price pane. */
18021
18361
  this.tradeMarkers = defaultTradeMarkersState();
18362
+ /** Timeline-mark display (the `marks` feature): the lane's master toggle + per-group visibility. */
18363
+ this.marks = defaultMarksState();
18364
+ /** The host's timeline marks + group definitions (`setTimelineMarks`), painted on the lane above the time axis. */
18365
+ this.timelineMarks = [];
18366
+ this.markGroups = [];
18367
+ /** The mark stack (bar index) fanned out by hover or tap, if any. */
18368
+ this.marksExpandedStack = null;
18369
+ /** The lane glyph (cluster key) under the pointer — it pulses — and when the hover began (frame-clock ms). */
18370
+ this.marksHoverKey = null;
18371
+ this.marksHoverSince = 0;
18372
+ /** The cluster whose popup is open: its glyph paints filled ("active"). */
18373
+ this.marksActiveKey = null;
18374
+ /** A content-less click's brief filled flash — the cluster key and the frame-clock time it ends. */
18375
+ this.marksFlash = null;
18022
18376
  /** Renderer-owned shaded time bands (session highlighting), behind grid + data. */
18023
18377
  this.highlights = [];
18024
18378
  /** Pre/post-market bands pushed by the host (`sessionZones` feature); null ⇒ no sessions. */
@@ -19867,6 +20221,315 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
19867
20221
  return out;
19868
20222
  }
19869
20223
 
20224
+ // src/renderers/native/chrome/countdown.ts
20225
+ function countdownText(barOpen, barMs, now) {
20226
+ if (!(barMs > 0)) return null;
20227
+ const remaining = barOpen + barMs - now;
20228
+ if (remaining <= 0) return null;
20229
+ return formatCountdown(remaining);
20230
+ }
20231
+ function formatCountdown(ms) {
20232
+ const total = Math.max(0, Math.ceil(ms / 1e3));
20233
+ const s = total % 60;
20234
+ const m = Math.floor(total / 60) % 60;
20235
+ const h = Math.floor(total / 3600);
20236
+ const pad = (v) => String(v).padStart(2, "0");
20237
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
20238
+ }
20239
+
20240
+ // src/renderers/native/chrome/contrast.ts
20241
+ function tagTextColor(bg, over) {
20242
+ const [r, g, b, a] = parseColor(bg);
20243
+ let R = r;
20244
+ let G = g;
20245
+ let B = b;
20246
+ if (a < 1) {
20247
+ const [or, og, ob] = parseColor(over);
20248
+ R = r * a + or * (1 - a);
20249
+ G = g * a + og * (1 - a);
20250
+ B = b * a + ob * (1 - a);
20251
+ }
20252
+ const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
20253
+ const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
20254
+ return L >= 0.4 ? "#000000" : "#ffffff";
20255
+ }
20256
+
20257
+ // src/renderers/native/chrome/marks/layout.ts
20258
+ var MARK_GLYPH_PX = 16;
20259
+ var MARK_CLUSTER_PX = 20;
20260
+ var MARK_LANE_INSET = 4;
20261
+ var MARK_DECK_STEP = 3;
20262
+ var MARK_FAN_GAP = 4;
20263
+ var MARK_HIT_PAD = 3;
20264
+ var MARK_FAN_HOLD = 8;
20265
+ function snapMarkBar(time, barTimes, intervalMs2) {
20266
+ const n = barTimes.length;
20267
+ if (n === 0 || !(intervalMs2 > 0) || !Number.isFinite(time)) return null;
20268
+ if (time < barTimes[0]) return null;
20269
+ const last = barTimes[n - 1];
20270
+ if (time >= last + intervalMs2) return n - 1 + Math.floor((time - last) / intervalMs2);
20271
+ let lo = 0;
20272
+ let hi = n - 1;
20273
+ while (lo < hi) {
20274
+ const mid = lo + hi + 1 >> 1;
20275
+ if (barTimes[mid] <= time) lo = mid;
20276
+ else hi = mid - 1;
20277
+ }
20278
+ if (time < barTimes[lo] + intervalMs2) return lo;
20279
+ return lo + 1;
20280
+ }
20281
+ function clusterMarks(marks, barTimes, intervalMs2, hidden) {
20282
+ const byKey = /* @__PURE__ */ new Map();
20283
+ marks.forEach((m, seq) => {
20284
+ if (m.group !== void 0 && hidden(m.group)) return;
20285
+ const bar = snapMarkBar(m.time, barTimes, intervalMs2);
20286
+ if (bar === null) return;
20287
+ const key = `${bar}|${m.group ?? ""}`;
20288
+ let c = byKey.get(key);
20289
+ if (!c) {
20290
+ c = { key, bar, group: m.group, marks: [], seq: [] };
20291
+ byKey.set(key, c);
20292
+ }
20293
+ c.marks.push(m);
20294
+ c.seq.push(seq);
20295
+ });
20296
+ const out = [];
20297
+ for (const c of byKey.values()) {
20298
+ 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);
20299
+ out.push({ key: c.key, bar: c.bar, group: c.group, marks: order.map((o) => o.m) });
20300
+ }
20301
+ return out;
20302
+ }
20303
+ function groupRank(groups, clusters) {
20304
+ const rank = /* @__PURE__ */ new Map();
20305
+ groups.forEach((g, i) => rank.set(g.id, i));
20306
+ for (const c of clusters) {
20307
+ if (c.group !== void 0 && !rank.has(c.group)) rank.set(c.group, rank.size);
20308
+ }
20309
+ return (group) => group === void 0 ? Number.MAX_SAFE_INTEGER : rank.get(group) ?? Number.MAX_SAFE_INTEGER - 1;
20310
+ }
20311
+ function layoutMarkLane(input) {
20312
+ const clusters = clusterMarks(input.marks, input.barTimes, input.intervalMs, input.hidden);
20313
+ const rankOf = groupRank(input.groups, clusters);
20314
+ const byBar = /* @__PURE__ */ new Map();
20315
+ for (const c of clusters) {
20316
+ const list = byBar.get(c.bar);
20317
+ if (list) list.push(c);
20318
+ else byBar.set(c.bar, [c]);
20319
+ }
20320
+ const glyphs = [];
20321
+ const stacks = /* @__PURE__ */ new Map();
20322
+ for (const [bar, list] of byBar) {
20323
+ const x = input.xOf(bar);
20324
+ if (!Number.isFinite(x) || x < -MARK_CLUSTER_PX || x > input.dataW + MARK_CLUSTER_PX) continue;
20325
+ list.sort((a, b) => rankOf(a.group) - rankOf(b.group));
20326
+ const multi = list.length > 1;
20327
+ const expanded = multi && input.expanded === bar;
20328
+ const decked = multi && !expanded;
20329
+ const placed = [];
20330
+ const deckSize = list[0].marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
20331
+ let bottom = input.axisY - MARK_LANE_INSET;
20332
+ list.forEach((cluster, depth) => {
20333
+ const size = decked ? deckSize : cluster.marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
20334
+ let y;
20335
+ if (expanded) {
20336
+ y = bottom - size / 2;
20337
+ bottom -= size + MARK_FAN_GAP;
20338
+ } else {
20339
+ y = input.axisY - MARK_LANE_INSET - size / 2 - depth * MARK_DECK_STEP;
20340
+ }
20341
+ placed.push({ cluster, x, y, size, stack: bar, depth, decked });
20342
+ });
20343
+ for (let i = placed.length - 1; i >= 0; i--) glyphs.push(placed[i]);
20344
+ stacks.set(bar, placed);
20345
+ }
20346
+ return { glyphs, stacks };
20347
+ }
20348
+ function markGlyphAt(layout, x, y) {
20349
+ for (let i = layout.glyphs.length - 1; i >= 0; i--) {
20350
+ const g = layout.glyphs[i];
20351
+ if (g.decked && g.depth !== 0) continue;
20352
+ const r = g.size / 2 + MARK_HIT_PAD;
20353
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return g;
20354
+ }
20355
+ return null;
20356
+ }
20357
+ function markStackAt(layout, x, y) {
20358
+ for (const [bar, placed] of layout.stacks) {
20359
+ for (const g of placed) {
20360
+ const r = g.size / 2 + MARK_HIT_PAD;
20361
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return bar;
20362
+ }
20363
+ if (placed.length > 1 && !placed[0].decked) {
20364
+ const top = placed[placed.length - 1];
20365
+ const base = placed[0];
20366
+ const r = Math.max(top.size, base.size) / 2 + MARK_HIT_PAD;
20367
+ 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;
20368
+ }
20369
+ }
20370
+ return null;
20371
+ }
20372
+ function clusterTooltip(cluster, groups) {
20373
+ const first = cluster.marks[0];
20374
+ if (!first) return null;
20375
+ if (cluster.marks.length === 1) return first.tooltip ?? first.title ?? null;
20376
+ const label = cluster.group !== void 0 ? markGroupLabel(cluster.group, groups) : first.title ?? first.tooltip ?? "Marks";
20377
+ return `${label} \xB7 ${cluster.marks.length}`;
20378
+ }
20379
+ function markGroupLabel(groupId, groups) {
20380
+ const def = groups.find((g) => g.id === groupId);
20381
+ if (def) return def.label;
20382
+ return groupId.charAt(0).toUpperCase() + groupId.slice(1);
20383
+ }
20384
+ function effectiveMarkGroups(marks, groups) {
20385
+ const out = groups.map((g) => ({ ...g }));
20386
+ const seen = new Set(out.map((g) => g.id));
20387
+ for (const m of marks) {
20388
+ if (m.group === void 0 || seen.has(m.group)) continue;
20389
+ seen.add(m.group);
20390
+ out.push({ id: m.group, label: markGroupLabel(m.group, groups) });
20391
+ }
20392
+ return out;
20393
+ }
20394
+
20395
+ // src/renderers/native/chrome/marks/paint.ts
20396
+ var MARK_PULSE_MS = 360;
20397
+ var MARK_PULSE_AMPLITUDE = 0.1;
20398
+ var ACTIVE_INK = "#ffffff";
20399
+ function pulseScale(elapsedMs) {
20400
+ if (!(elapsedMs > 0) || elapsedMs >= MARK_PULSE_MS) return 1;
20401
+ return 1 + MARK_PULSE_AMPLITUDE * Math.sin(Math.PI * elapsedMs / MARK_PULSE_MS);
20402
+ }
20403
+ function paintMarkLane(ctx, layout, deps) {
20404
+ if (layout.glyphs.length === 0) return;
20405
+ ctx.save();
20406
+ ctx.setLineDash([]);
20407
+ ctx.textAlign = "center";
20408
+ ctx.textBaseline = "middle";
20409
+ for (const g of layout.glyphs) {
20410
+ const mark = g.cluster.marks[0];
20411
+ if (!mark) continue;
20412
+ const key = g.cluster.key;
20413
+ const color = mark.glyph.color;
20414
+ const active = key === deps.activeKey || key === deps.flashKey;
20415
+ const size = g.size * (key === deps.hoverKey ? pulseScale(deps.nowMs - deps.hoverSince) : 1);
20416
+ if (g.depth === 0) {
20417
+ const sx = Math.round(g.x) + 0.5;
20418
+ ctx.lineWidth = 1;
20419
+ ctx.strokeStyle = deps.stemColor;
20420
+ ctx.beginPath();
20421
+ ctx.moveTo(sx, g.y + g.size / 2);
20422
+ ctx.lineTo(sx, deps.axisY);
20423
+ ctx.stroke();
20424
+ }
20425
+ const shape = mark.glyph.shape ?? "circle";
20426
+ const center = traceShape(ctx, shape, g.x, g.y, size);
20427
+ ctx.lineWidth = 4;
20428
+ ctx.strokeStyle = deps.background;
20429
+ ctx.stroke();
20430
+ ctx.fillStyle = active ? color : deps.background;
20431
+ ctx.fill();
20432
+ ctx.lineWidth = 1.5;
20433
+ ctx.strokeStyle = color;
20434
+ ctx.stroke();
20435
+ const ink = active ? ACTIVE_INK : color;
20436
+ const symbolPx = Math.round(size * 0.62);
20437
+ if (mark.glyph.icon) {
20438
+ const img = deps.icons.get(mark.glyph.icon, ink, symbolPx, deps.dpr);
20439
+ if (img) ctx.drawImage(img, center.x - symbolPx / 2, center.y - symbolPx / 2, symbolPx, symbolPx);
20440
+ } else if (mark.glyph.letter) {
20441
+ ctx.fillStyle = ink;
20442
+ ctx.font = `600 ${Math.round(size * 0.58)}px ${deps.fontFamily}`;
20443
+ ctx.fillText(mark.glyph.letter.slice(0, 2), center.x, center.y + 0.5);
20444
+ }
20445
+ }
20446
+ ctx.restore();
20447
+ }
20448
+ function traceShape(ctx, shape, x, y, size) {
20449
+ const r = size / 2;
20450
+ ctx.beginPath();
20451
+ switch (shape) {
20452
+ case "square": {
20453
+ const c = Math.min(3, r / 2);
20454
+ roundedRect(ctx, x - r, y - r, size, size, c);
20455
+ return { x, y };
20456
+ }
20457
+ case "diamond":
20458
+ ctx.moveTo(x, y - r);
20459
+ ctx.lineTo(x + r, y);
20460
+ ctx.lineTo(x, y + r);
20461
+ ctx.lineTo(x - r, y);
20462
+ ctx.closePath();
20463
+ return { x, y };
20464
+ case "pin": {
20465
+ const hr = r * 0.82;
20466
+ const hy = y - r + hr;
20467
+ ctx.arc(x, hy, hr, Math.PI * 0.75, Math.PI * 0.25, false);
20468
+ ctx.lineTo(x, y + r);
20469
+ ctx.closePath();
20470
+ return { x, y: hy };
20471
+ }
20472
+ case "circle":
20473
+ default:
20474
+ ctx.arc(x, y, r, 0, Math.PI * 2);
20475
+ return { x, y };
20476
+ }
20477
+ }
20478
+ function roundedRect(ctx, x, y, w, h, radius) {
20479
+ ctx.moveTo(x + radius, y);
20480
+ ctx.lineTo(x + w - radius, y);
20481
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
20482
+ ctx.lineTo(x + w, y + h - radius);
20483
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
20484
+ ctx.lineTo(x + radius, y + h);
20485
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
20486
+ ctx.lineTo(x, y + radius);
20487
+ ctx.quadraticCurveTo(x, y, x + radius, y);
20488
+ ctx.closePath();
20489
+ }
20490
+ var MarkIconRaster = class {
20491
+ constructor(onReady) {
20492
+ this.onReady = onReady;
20493
+ this.cache = /* @__PURE__ */ new Map();
20494
+ }
20495
+ get(icon2, ink, px, dpr) {
20496
+ const key = `${icon2}|${ink}|${px}|${dpr}`;
20497
+ if (this.cache.has(key)) {
20498
+ const img2 = this.cache.get(key);
20499
+ return img2 && img2.complete && img2.naturalWidth > 0 ? img2 : null;
20500
+ }
20501
+ const markup = iconMarkup(icon2);
20502
+ if (!markup || typeof document === "undefined" || typeof Image === "undefined" || typeof XMLSerializer === "undefined") {
20503
+ this.cache.set(key, null);
20504
+ return null;
20505
+ }
20506
+ const svg = standaloneSvg(markup, ink, Math.max(1, Math.ceil(px * dpr)));
20507
+ if (!svg) {
20508
+ this.cache.set(key, null);
20509
+ return null;
20510
+ }
20511
+ const img = new Image();
20512
+ img.onload = () => this.onReady();
20513
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
20514
+ this.cache.set(key, img);
20515
+ return null;
20516
+ }
20517
+ clear() {
20518
+ this.cache.clear();
20519
+ }
20520
+ };
20521
+ function standaloneSvg(markup, ink, px) {
20522
+ const tpl = document.createElement("template");
20523
+ tpl.innerHTML = markup;
20524
+ const svg = tpl.content.firstElementChild;
20525
+ if (!svg || svg.tagName.toLowerCase() !== "svg") return null;
20526
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
20527
+ svg.setAttribute("width", String(px));
20528
+ svg.setAttribute("height", String(px));
20529
+ svg.setAttribute("color", ink);
20530
+ return new XMLSerializer().serializeToString(svg);
20531
+ }
20532
+
19870
20533
  // src/renderers/native/chrome/ChromeRenderer.ts
19871
20534
  var ChromeRenderer = class {
19872
20535
  constructor() {
@@ -19876,11 +20539,40 @@ var ChromeRenderer = class {
19876
20539
  this.axisTextColor = DARK_THEME.textColor;
19877
20540
  // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
19878
20541
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
20542
+ /** The timeline-mark lane as laid out by the last frame — what hover/click hit-test against. */
20543
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
20544
+ /** Registry icons rasterized for the lane; the owner is asked for a chrome repaint when one lands. */
20545
+ this.markIcons = new MarkIconRaster(() => this.onMarkIconReady?.());
20546
+ this.onMarkIconReady = null;
20547
+ /** Bar open times of the current series, rebuilt only when the array or its length changes (a live tick keeps both). */
20548
+ this.barTimesSrc = null;
20549
+ this.barTimesCache = [];
19879
20550
  }
19880
20551
  mount(canvas) {
19881
20552
  this.canvas = canvas;
19882
20553
  this.ctx = canvas.getContext("2d");
19883
20554
  }
20555
+ /** Where to ask for a chrome repaint when a lane icon finishes rasterizing. */
20556
+ setMarkIconReady(cb) {
20557
+ this.onMarkIconReady = cb;
20558
+ }
20559
+ /** The interactive mark glyph under a plot point (last frame's layout), or null. */
20560
+ markGlyphAt(x, y) {
20561
+ return markGlyphAt(this.markLayout, x, y);
20562
+ }
20563
+ /** The mark stack (bar index) whose glyphs — or the gaps of its fan — cover a plot point. */
20564
+ markStackAt(x, y) {
20565
+ return markStackAt(this.markLayout, x, y);
20566
+ }
20567
+ /** A glyph of the last frame by its cluster key — how an open popup follows its anchor. */
20568
+ markGlyphByKey(key) {
20569
+ return this.markLayout.glyphs.find((g) => g.cluster.key === key) ?? null;
20570
+ }
20571
+ /** Hover text of the mark glyph under a plot point, or null. */
20572
+ markTooltipAt(x, y, groups) {
20573
+ const g = this.markGlyphAt(x, y);
20574
+ return g ? clusterTooltip(g.cluster, groups) : null;
20575
+ }
19884
20576
  /** Wire the drawing coordinate resolvers + theme (call once per frame before use). */
19885
20577
  prepare(scene, coords, theme) {
19886
20578
  this.drawScene.setDeps({
@@ -19929,6 +20621,7 @@ var ChromeRenderer = class {
19929
20621
  const panes = scene.orderedPanes();
19930
20622
  if (coords.barCount === 0) {
19931
20623
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
20624
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
19932
20625
  return;
19933
20626
  }
19934
20627
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
@@ -19942,6 +20635,46 @@ var ChromeRenderer = class {
19942
20635
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
19943
20636
  this.drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane);
19944
20637
  this.drawTimeAxis(ctx, scene, coords, theme, dataW, dataH, fullH);
20638
+ this.drawMarkLane(ctx, scene, coords, theme, dataW, dataH);
20639
+ }
20640
+ /** The timeline-mark lane — after the axis, so the tokens read over the plot's bottom edge. */
20641
+ drawMarkLane(ctx, scene, coords, theme, dataW, dataH) {
20642
+ if (!scene.marks.visible || scene.timelineMarks.length === 0) {
20643
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
20644
+ return;
20645
+ }
20646
+ this.markLayout = layoutMarkLane({
20647
+ marks: scene.timelineMarks,
20648
+ groups: scene.markGroups,
20649
+ hidden: (groupId) => !markGroupVisible(scene.marks, groupId, scene.markGroups),
20650
+ barTimes: this.barTimes(scene),
20651
+ intervalMs: coords.barInterval,
20652
+ xOf: (bar) => coords.logicalToX(bar),
20653
+ axisY: dataH,
20654
+ dataW,
20655
+ expanded: scene.marksExpandedStack
20656
+ });
20657
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
20658
+ paintMarkLane(ctx, this.markLayout, {
20659
+ axisY: dataH,
20660
+ background: theme.background,
20661
+ stemColor: scene.style.borderColor ?? theme.borderColor,
20662
+ fontFamily: theme.fontFamily,
20663
+ dpr: coords.dpr,
20664
+ icons: this.markIcons,
20665
+ hoverKey: scene.marksHoverKey,
20666
+ hoverSince: scene.marksHoverSince,
20667
+ activeKey: scene.marksActiveKey,
20668
+ flashKey: scene.marksFlash && scene.marksFlash.until > nowMs ? scene.marksFlash.key : null,
20669
+ nowMs
20670
+ });
20671
+ }
20672
+ barTimes(scene) {
20673
+ if (this.barTimesSrc !== scene.bars || this.barTimesCache.length !== scene.bars.length) {
20674
+ this.barTimesSrc = scene.bars;
20675
+ this.barTimesCache = scene.bars.map((b) => b.time);
20676
+ }
20677
+ return this.barTimesCache;
19945
20678
  }
19946
20679
  destroy() {
19947
20680
  this.canvas = null;
@@ -20057,7 +20790,8 @@ var ChromeRenderer = class {
20057
20790
  * - the countdown-to-bar-close chip (`showCountdown`).
20058
20791
  * When the label and countdown are both on they merge into one stacked block (countdown
20059
20792
  * under the label, text flushed left); a lone label or countdown is centered on the
20060
- * price level with centered text. The countdown ticks once per second (repaint scheduled).
20793
+ * price level with centered text. The countdown repaints on the renderer's second pulse
20794
+ * and disappears once the bar has closed, until the next bar arrives.
20061
20795
  */
20062
20796
  drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane) {
20063
20797
  const n = scene.bars.length;
@@ -20077,17 +20811,16 @@ var ChromeRenderer = class {
20077
20811
  ctx.stroke();
20078
20812
  setDash2(ctx, "solid");
20079
20813
  }
20080
- const interval = coords.barInterval;
20081
- const showCountdown = scene.showCountdown && interval > 0;
20814
+ const cdText = scene.showCountdown ? countdownText(last.time, coords.barInterval, Date.now()) : null;
20815
+ const showCountdown = cdText !== null;
20082
20816
  const showLabel = scene.showPriceLabel;
20083
20817
  if (!showLabel && !showCountdown) return;
20084
20818
  const priceText = formatAxisValue(pricePane.scale, pricePane.bounds.height, last.close, percentScaleFor(scene, pricePane), scene.priceMintick);
20085
- const cdText = showCountdown ? formatCountdown(last.time + interval - Date.now()) : "";
20086
20819
  const PAD = 8;
20087
20820
  const x = dataW + 1;
20088
20821
  const textColor = tagTextColor(color, theme.background);
20089
20822
  ctx.textBaseline = "middle";
20090
- if (showLabel && showCountdown) {
20823
+ if (showLabel && cdText !== null) {
20091
20824
  const w2 = Math.max(ctx.measureText(priceText).width, ctx.measureText(cdText).width) + PAD;
20092
20825
  const top = y - 8;
20093
20826
  const tx = x + PAD / 2;
@@ -20100,7 +20833,7 @@ var ChromeRenderer = class {
20100
20833
  ctx.textAlign = "start";
20101
20834
  return;
20102
20835
  }
20103
- const text = showLabel ? priceText : cdText;
20836
+ const text = showLabel ? priceText : cdText ?? "";
20104
20837
  const w = ctx.measureText(text).width + PAD;
20105
20838
  ctx.fillStyle = color;
20106
20839
  ctx.fillRect(x, y - 8, w, 16);
@@ -20171,29 +20904,6 @@ function setDash2(ctx, style) {
20171
20904
  else if (style === "dotted") ctx.setLineDash([2, 3]);
20172
20905
  else ctx.setLineDash([]);
20173
20906
  }
20174
- function tagTextColor(bg, over) {
20175
- const [r, g, b, a] = parseColor(bg);
20176
- let R = r;
20177
- let G = g;
20178
- let B = b;
20179
- if (a < 1) {
20180
- const [or, og, ob] = parseColor(over);
20181
- R = r * a + or * (1 - a);
20182
- G = g * a + og * (1 - a);
20183
- B = b * a + ob * (1 - a);
20184
- }
20185
- const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
20186
- const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
20187
- return L >= 0.4 ? "#000000" : "#ffffff";
20188
- }
20189
- function formatCountdown(ms) {
20190
- const total = Math.max(0, Math.floor(ms / 1e3));
20191
- const s = total % 60;
20192
- const m = Math.floor(total / 60) % 60;
20193
- const h = Math.floor(total / 3600);
20194
- const pad = (v) => String(v).padStart(2, "0");
20195
- return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
20196
- }
20197
20907
 
20198
20908
  // src/renderers/native/chrome/LabelTooltip.ts
20199
20909
  var HOVER_DELAY_MS = 350;
@@ -20476,6 +21186,11 @@ function tzMenuLabel(zone, location) {
20476
21186
  function settingsIdSlug(label) {
20477
21187
  return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
20478
21188
  }
21189
+ var MARKS_SETTINGS_ID = "events";
21190
+ var MARKS_GROUPS_SETTINGS_ID = "events.groups";
21191
+ function markGroupSettingsId(groupId) {
21192
+ return `${MARKS_GROUPS_SETTINGS_ID}.${settingsIdSlug(groupId)}`;
21193
+ }
20479
21194
  function settingsIdHidden(id, hidden) {
20480
21195
  if (hidden.size === 0) return false;
20481
21196
  let path = id;
@@ -20569,7 +21284,11 @@ var BUILTIN_SETTINGS_IDS = [
20569
21284
  "symbol.style.baseline.base-level",
20570
21285
  "symbol.style.baseline.width",
20571
21286
  "symbol.animation",
21287
+ "symbol.animation.zoom",
21288
+ "symbol.animation.pan",
21289
+ "symbol.animation.autoscale",
20572
21290
  "symbol.animation.price-changes",
21291
+ "symbol.animation.intro",
20573
21292
  "symbol.timezone",
20574
21293
  "scales",
20575
21294
  "scales.price-scale",
@@ -20595,8 +21314,13 @@ var BUILTIN_SETTINGS_IDS = [
20595
21314
  "canvas.grid.horizontal",
20596
21315
  "canvas.theme"
20597
21316
  ];
20598
- function settingsIdCatalog(hostSections) {
21317
+ function settingsIdCatalog(hostSections, markGroups = []) {
20599
21318
  const ids = new Set(BUILTIN_SETTINGS_IDS);
21319
+ if (markGroups.length > 0) {
21320
+ ids.add(MARKS_SETTINGS_ID);
21321
+ ids.add(MARKS_GROUPS_SETTINGS_ID);
21322
+ for (const g of markGroups) ids.add(markGroupSettingsId(g.id));
21323
+ }
20600
21324
  for (const def of chartTypes()) {
20601
21325
  if (hasOwnCandlePaint(def.id)) {
20602
21326
  const style = `symbol.style.${def.id}`;
@@ -20636,7 +21360,7 @@ function styleLabel(id) {
20636
21360
  return chartType(id)?.label ?? BUILTIN_STYLE_LABELS[id] ?? id;
20637
21361
  }
20638
21362
  var SD_STYLE_ID = "vela-settings-controls";
20639
- var SD_STYLE_REV = "5";
21363
+ var SD_STYLE_REV = "6";
20640
21364
  var SETTINGS_BORDER = "var(--vela-border)";
20641
21365
  function ensureControlStyles() {
20642
21366
  if (typeof document === "undefined") return;
@@ -20712,7 +21436,15 @@ ${overlayScrollbarCss(".vela-sd-pane")}
20712
21436
  .vela-sd-mobile .vela-select-trigger,.vela-sd-mobile .vela-num input,.vela-sd-mobile .vela-width-field{height:34px;}
20713
21437
  .vela-sd-mobile .vela-sd-close{width:40px;height:40px;}
20714
21438
  .vela-sd-mobile .vela-sd-btn{height:38px;}
20715
- .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}`;
21439
+ .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}
21440
+ /* The wrap rule above is for ROW LABELS only: a select's closed value must keep its
21441
+ single-line ellipsis, or a long option wraps to several lines inside the 34px
21442
+ trigger and spills over the rows around it. Three classes so it outranks the
21443
+ two-classes-plus-element selector above. The kit's fixed 100px column is a desktop
21444
+ alignment device; on mobile the trigger hugs its value instead (the grid's control
21445
+ column is max-content), capped so a long option still ellipsizes before the label. */
21446
+ .vela-sd-mobile .vela-select-trigger .vela-select-label{white-space:nowrap !important;}
21447
+ .vela-sd-mobile .vela-select:not([data-fill]){width:auto;min-width:100px;max-width:min(220px,55vw);}`;
20716
21448
  if (!existing) document.head.appendChild(st);
20717
21449
  }
20718
21450
  var SettingsDialog = class {
@@ -20727,6 +21459,9 @@ var SettingsDialog = class {
20727
21459
  this.config = null;
20728
21460
  this.syncTypeTabs = null;
20729
21461
  this.hostSections = [];
21462
+ /** The timeline-mark groups (defined + named by marks) — one checkbox each on the Events tab. */
21463
+ this.markGroups = [];
21464
+ this.markGroupVisible = () => true;
20730
21465
  /** The Canvas → Theme row: current app theme + where a pick is raised. The row is a
20731
21466
  * host callback, NOT a config patch — the app theme stays out of the persisted
20732
21467
  * `ChartConfig`, so exported templates never carry it. */
@@ -20751,6 +21486,11 @@ var SettingsDialog = class {
20751
21486
  setHostSections(sections) {
20752
21487
  this.hostSections = sections;
20753
21488
  }
21489
+ /** The timeline-mark groups and their current visibility — the Events tab's rows on next open. */
21490
+ setMarkGroups(groups, visible) {
21491
+ this.markGroups = groups;
21492
+ this.markGroupVisible = visible;
21493
+ }
20754
21494
  /** Replace the visibility policy — an open dialog rebuilds in place to honor it. */
20755
21495
  setHiddenSettings(ids) {
20756
21496
  const next = new Set(ids);
@@ -20924,12 +21664,36 @@ var SettingsDialog = class {
20924
21664
  }
20925
21665
  showActive(config.series.style);
20926
21666
  body.append(sid(this.sectionTitle("Animation"), "symbol.animation"));
21667
+ body.append(sid(this.boolRow(
21668
+ "Animate zoom",
21669
+ config.animations.zoom,
21670
+ (v) => this.emit({ animations: { zoom: v } }),
21671
+ this.hint("Glide the chart to each zoom step instead of jumping.")
21672
+ ), "symbol.animation.zoom"));
21673
+ body.append(sid(this.boolRow(
21674
+ "Pan momentum",
21675
+ config.animations.pan,
21676
+ (v) => this.emit({ animations: { pan: v } }),
21677
+ this.hint("Keep gliding briefly after a drag release, and ease scroll-to-latest and keyboard pans.")
21678
+ ), "symbol.animation.pan"));
21679
+ body.append(sid(this.boolRow(
21680
+ "Animate price scale",
21681
+ config.animations.autoscale,
21682
+ (v) => this.emit({ animations: { autoscale: v } }),
21683
+ this.hint("Glide the price scale to its new range while zooming or panning.")
21684
+ ), "symbol.animation.autoscale"));
20927
21685
  body.append(sid(this.boolRow(
20928
21686
  "Animate price changes",
20929
21687
  config.priceScale.animateLastPrice,
20930
21688
  (v) => this.emit({ priceScale: { animateLastPrice: v } }),
20931
21689
  this.hint("Glide the live bar to each new price instead of snapping.")
20932
21690
  ), "symbol.animation.price-changes"));
21691
+ body.append(sid(this.boolRow(
21692
+ "Reveal on load",
21693
+ config.animations.intro,
21694
+ (v) => this.emit({ animations: { intro: v } }),
21695
+ this.hint("Draw the candles in when a chart first loads. Takes effect on the next load.")
21696
+ ), "symbol.animation.intro"));
20933
21697
  body.append(sid(this.sectionTitle("Time zone"), "symbol.timezone"));
20934
21698
  body.append(sid(this.selectRowLabeled("Time zone", normalizeTimezone(config.timeScale.timezone), timezoneOptions(config.timeScale.timezone), (v) => this.emit({ timeScale: { timezone: v } })), "symbol.timezone"));
20935
21699
  const renderHostSections = (placement) => {
@@ -20999,6 +21763,13 @@ var SettingsDialog = class {
20999
21763
  body.append(sid(this.sectionTitle("Theme"), "canvas.theme"));
21000
21764
  body.append(sid(this.selectRow("Color theme", tc.current === "dark" ? "Dark" : "Light", ["Dark", "Light"], (v) => tc.onSelect(v === "Dark" ? "dark" : "light")), "canvas.theme"));
21001
21765
  }
21766
+ if (this.markGroups.length > 0) {
21767
+ body.append(sid(this.section("Events"), MARKS_SETTINGS_ID));
21768
+ body.append(sid(this.sectionTitle("Visible events"), MARKS_GROUPS_SETTINGS_ID));
21769
+ for (const g of this.markGroups) {
21770
+ body.append(sid(this.boolRow(g.label, this.markGroupVisible(g.id), (v) => this.emit({ marks: { groups: { [g.id]: v } } })), markGroupSettingsId(g.id)));
21771
+ }
21772
+ }
21002
21773
  renderChartTypeSections("end");
21003
21774
  renderHostSections("end");
21004
21775
  if (this.hiddenSettings.size > 0) {
@@ -27203,6 +27974,383 @@ function expandScaleByPixels(scale, heightPx, abovePx, belowPx) {
27203
27974
  return { ...scale, min: scale.min - belowPx * perPx, max: scale.max + abovePx * perPx };
27204
27975
  }
27205
27976
 
27977
+ // src/ui/sanitize-html.ts
27978
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
27979
+ "a",
27980
+ "abbr",
27981
+ "b",
27982
+ "blockquote",
27983
+ "br",
27984
+ "code",
27985
+ "dd",
27986
+ "del",
27987
+ "div",
27988
+ "dl",
27989
+ "dt",
27990
+ "em",
27991
+ "h1",
27992
+ "h2",
27993
+ "h3",
27994
+ "h4",
27995
+ "h5",
27996
+ "h6",
27997
+ "hr",
27998
+ "i",
27999
+ "img",
28000
+ "ins",
28001
+ "kbd",
28002
+ "li",
28003
+ "mark",
28004
+ "ol",
28005
+ "p",
28006
+ "pre",
28007
+ "q",
28008
+ "s",
28009
+ "small",
28010
+ "span",
28011
+ "strong",
28012
+ "sub",
28013
+ "sup",
28014
+ "table",
28015
+ "tbody",
28016
+ "td",
28017
+ "tfoot",
28018
+ "th",
28019
+ "thead",
28020
+ "tr",
28021
+ "u",
28022
+ "ul"
28023
+ ]);
28024
+ var DROPPED_TAGS = /* @__PURE__ */ new Set([
28025
+ "script",
28026
+ "style",
28027
+ "iframe",
28028
+ "frame",
28029
+ "frameset",
28030
+ "object",
28031
+ "embed",
28032
+ "applet",
28033
+ "form",
28034
+ "input",
28035
+ "textarea",
28036
+ "button",
28037
+ "select",
28038
+ "option",
28039
+ "link",
28040
+ "meta",
28041
+ "base",
28042
+ "svg",
28043
+ "math",
28044
+ "template",
28045
+ "noscript",
28046
+ "audio",
28047
+ "video",
28048
+ "canvas",
28049
+ "dialog",
28050
+ "head",
28051
+ "title"
28052
+ ]);
28053
+ var ALLOWED_ATTRS = {
28054
+ a: /* @__PURE__ */ new Set(["href"]),
28055
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height"]),
28056
+ td: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
28057
+ th: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
28058
+ ol: /* @__PURE__ */ new Set(["start"])
28059
+ };
28060
+ var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "vbscript", "data", "file", "blob"]);
28061
+ function tagDisposition(tag) {
28062
+ const t = tag.toLowerCase();
28063
+ if (DROPPED_TAGS.has(t)) return "drop";
28064
+ return ALLOWED_TAGS.has(t) ? "keep" : "unwrap";
28065
+ }
28066
+ function attributeAllowed(tag, name) {
28067
+ const n = name.toLowerCase();
28068
+ if (n.startsWith("on") || n === "style") return false;
28069
+ if (n === "title") return true;
28070
+ return ALLOWED_ATTRS[tag.toLowerCase()]?.has(n) ?? false;
28071
+ }
28072
+ function safeUrl(value, absoluteOnly = false) {
28073
+ let url = "";
28074
+ for (const ch of value) if (ch.charCodeAt(0) > 32) url += ch;
28075
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(url);
28076
+ const scheme = m ? m[1].toLowerCase() : null;
28077
+ if (scheme !== null && BLOCKED_SCHEMES.has(scheme)) return null;
28078
+ if (absoluteOnly && scheme !== "http" && scheme !== "https") return null;
28079
+ return url;
28080
+ }
28081
+ var ELEMENT_NODE = 1;
28082
+ var TEXT_NODE = 3;
28083
+ function sanitizeHtml(html, doc) {
28084
+ const tpl = doc.createElement("template");
28085
+ tpl.innerHTML = html;
28086
+ const out = doc.createDocumentFragment();
28087
+ copyChildren(tpl.content, out, doc);
28088
+ return out;
28089
+ }
28090
+ function copyChildren(from, to, doc) {
28091
+ for (const child of Array.from(from.childNodes)) {
28092
+ if (child.nodeType === TEXT_NODE) {
28093
+ to.appendChild(doc.createTextNode(child.textContent ?? ""));
28094
+ continue;
28095
+ }
28096
+ if (child.nodeType !== ELEMENT_NODE) continue;
28097
+ const el = child;
28098
+ const tag = el.tagName.toLowerCase();
28099
+ const disposition = tagDisposition(tag);
28100
+ if (disposition === "drop") continue;
28101
+ if (disposition === "unwrap") {
28102
+ copyChildren(el, to, doc);
28103
+ continue;
28104
+ }
28105
+ const clean = doc.createElement(tag);
28106
+ for (const attr of Array.from(el.attributes)) {
28107
+ const name = attr.name.toLowerCase();
28108
+ if (!attributeAllowed(tag, name)) continue;
28109
+ let value = attr.value;
28110
+ if (name === "href" || name === "src") {
28111
+ const safe = safeUrl(value, name === "src");
28112
+ if (safe === null) continue;
28113
+ value = safe;
28114
+ }
28115
+ clean.setAttribute(name, value);
28116
+ }
28117
+ if (tag === "a") {
28118
+ clean.setAttribute("target", "_blank");
28119
+ clean.setAttribute("rel", "noopener noreferrer");
28120
+ }
28121
+ copyChildren(el, clean, doc);
28122
+ to.appendChild(clean);
28123
+ }
28124
+ }
28125
+
28126
+ // src/renderers/native/chrome/marks/MarkPopover.ts
28127
+ var MARKS_STYLE_ID = "vela-marks-popover";
28128
+ var MARKS_CSS = `
28129
+ .vela-marks-panel { padding: 0; gap: 0; min-width: 220px; max-width: 320px; max-height: 320px; overflow-y: auto; overscroll-behavior: contain; }
28130
+ .vela-marks-section { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; }
28131
+ .vela-marks-section + .vela-marks-section { border-top: 1px solid var(--vela-border); }
28132
+ .vela-marks-field { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
28133
+ .vela-marks-field-label { color: var(--vela-fg-muted); }
28134
+ .vela-marks-field-value { color: var(--vela-fg-bright); text-align: right; font-variant-numeric: tabular-nums; }
28135
+ .vela-marks-html { color: var(--vela-fg); line-height: 1.45; overflow-wrap: anywhere; }
28136
+ .vela-marks-html p { margin: 0 0 6px; }
28137
+ .vela-marks-html p:last-child { margin-bottom: 0; }
28138
+ .vela-marks-html a { color: var(--vela-accent); }
28139
+ .vela-marks-html img { max-width: 100%; height: auto; }
28140
+ .vela-marks-html table { border-collapse: collapse; }
28141
+ .vela-marks-html td, .vela-marks-html th { padding: 2px 6px; border: 1px solid var(--vela-border); }
28142
+ .vela-marks-html pre, .vela-marks-html code { font-family: var(--vela-font-mono, monospace); font-size: 0.92em; }
28143
+ .vela-marks-loading, .vela-marks-error { color: var(--vela-fg-muted); font-style: italic; }
28144
+ `;
28145
+ var MarkPopover = class {
28146
+ constructor(deps) {
28147
+ this.deps = deps;
28148
+ this.pop = null;
28149
+ this.openKey = null;
28150
+ /** Bumped per open/close — a lazy content resolving after its popup went away is dropped. */
28151
+ this.generation = 0;
28152
+ const doc = deps.plot.ownerDocument;
28153
+ injectStyles(CALLOUT_STYLE_ID, CALLOUT_CSS, doc);
28154
+ injectStyles(MARKS_STYLE_ID, MARKS_CSS, doc);
28155
+ this.anchor = doc.createElement("div");
28156
+ this.anchor.className = "vela-marks-anchor";
28157
+ Object.assign(this.anchor.style, { position: "absolute", pointerEvents: "none", left: "0", top: "0", width: "0", height: "0" });
28158
+ deps.plot.appendChild(this.anchor);
28159
+ }
28160
+ /** The cluster key the open popup belongs to, or null. */
28161
+ get key() {
28162
+ return this.openKey;
28163
+ }
28164
+ open(cluster, rect) {
28165
+ this.close();
28166
+ this.place(rect);
28167
+ const gen = ++this.generation;
28168
+ this.openKey = cluster.key;
28169
+ this.pop = new Popover({
28170
+ trigger: this.anchor,
28171
+ host: this.deps.host(),
28172
+ theme: this.deps.theme(),
28173
+ gap: 8,
28174
+ align: "center",
28175
+ // centered on the glyph
28176
+ fadeMs: 120,
28177
+ // a short, discreet fade in and out
28178
+ className: "vela-marks-pop",
28179
+ content: (body) => this.build(body, cluster, gen),
28180
+ onClose: () => {
28181
+ if (this.generation === gen) {
28182
+ this.openKey = null;
28183
+ this.pop = null;
28184
+ this.deps.onOpenChange?.(null);
28185
+ }
28186
+ }
28187
+ });
28188
+ this.pop.show();
28189
+ this.deps.onOpenChange?.(cluster.key);
28190
+ }
28191
+ /** Follow the anchor glyph after a repaint; `null` (glyph gone — hidden, scrolled off, marks replaced) closes. */
28192
+ track(rect) {
28193
+ if (!this.pop) return;
28194
+ if (!rect) {
28195
+ this.close();
28196
+ return;
28197
+ }
28198
+ this.place(rect);
28199
+ this.pop.reposition();
28200
+ }
28201
+ close() {
28202
+ const pop = this.pop;
28203
+ const wasOpen = this.openKey !== null;
28204
+ this.pop = null;
28205
+ this.openKey = null;
28206
+ this.generation++;
28207
+ pop?.destroy();
28208
+ if (wasOpen) this.deps.onOpenChange?.(null);
28209
+ }
28210
+ destroy() {
28211
+ this.close();
28212
+ this.anchor.remove();
28213
+ }
28214
+ place(rect) {
28215
+ Object.assign(this.anchor.style, {
28216
+ left: `${rect.x - rect.size / 2}px`,
28217
+ top: `${rect.y - rect.size / 2}px`,
28218
+ width: `${rect.size}px`,
28219
+ height: `${rect.size}px`
28220
+ });
28221
+ }
28222
+ build(body, cluster, gen) {
28223
+ const doc = body.ownerDocument;
28224
+ const root = doc.createElement("div");
28225
+ root.className = "vela-callout-panel vela-marks-panel";
28226
+ const pending = [];
28227
+ for (const mark of cluster.marks) {
28228
+ const section = doc.createElement("section");
28229
+ section.className = "vela-marks-section";
28230
+ if (mark.title) {
28231
+ const title = doc.createElement("div");
28232
+ title.className = "vela-callout-title";
28233
+ title.textContent = mark.title;
28234
+ section.appendChild(title);
28235
+ }
28236
+ const content = mark.content;
28237
+ if (typeof content === "function") {
28238
+ const slot = doc.createElement("div");
28239
+ slot.className = "vela-marks-loading";
28240
+ slot.textContent = "Loading\u2026";
28241
+ section.appendChild(slot);
28242
+ pending.push({ el: section, resolve: () => this.resolveLazy(content, slot, gen) });
28243
+ } else if (content !== void 0) {
28244
+ this.renderContent(section, content);
28245
+ }
28246
+ if (section.childElementCount > 0) root.appendChild(section);
28247
+ }
28248
+ body.appendChild(root);
28249
+ if (pending.length > 0) scheduleLazy(root, pending);
28250
+ }
28251
+ resolveLazy(source, slot, gen) {
28252
+ let result;
28253
+ try {
28254
+ result = Promise.resolve(source());
28255
+ } catch (err) {
28256
+ result = Promise.reject(err instanceof Error ? err : new Error(String(err)));
28257
+ }
28258
+ void result.then(
28259
+ (content) => {
28260
+ if (gen !== this.generation) return;
28261
+ const section = slot.parentElement;
28262
+ if (!section) return;
28263
+ slot.remove();
28264
+ this.renderContent(section, content);
28265
+ this.pop?.reposition();
28266
+ },
28267
+ () => {
28268
+ if (gen !== this.generation) return;
28269
+ slot.className = "vela-marks-error";
28270
+ slot.textContent = "Couldn\u2019t load this entry.";
28271
+ }
28272
+ );
28273
+ }
28274
+ renderContent(section, content) {
28275
+ const doc = section.ownerDocument;
28276
+ if (!content || typeof content !== "object") return;
28277
+ if ("text" in content) {
28278
+ const text = doc.createElement("div");
28279
+ text.className = "vela-callout-text";
28280
+ text.textContent = String(content.text);
28281
+ section.appendChild(text);
28282
+ return;
28283
+ }
28284
+ if ("html" in content) {
28285
+ const html = doc.createElement("div");
28286
+ html.className = "vela-marks-html";
28287
+ html.appendChild(sanitizeHtml(String(content.html), doc));
28288
+ section.appendChild(html);
28289
+ return;
28290
+ }
28291
+ if ("panel" in content && content.panel && Array.isArray(content.panel.items)) {
28292
+ let actions = null;
28293
+ for (const item of content.panel.items) {
28294
+ if (!item || typeof item !== "object") continue;
28295
+ if (item.type === "button") {
28296
+ if (!actions) {
28297
+ actions = doc.createElement("div");
28298
+ actions.className = "vela-callout-actions";
28299
+ section.appendChild(actions);
28300
+ }
28301
+ const btn2 = doc.createElement("button");
28302
+ btn2.type = "button";
28303
+ btn2.className = "vela-callout-btn" + (item.primary ? " vela-callout-btn-primary" : "");
28304
+ btn2.textContent = item.label;
28305
+ btn2.addEventListener("click", () => {
28306
+ item.run();
28307
+ if (item.close !== false) this.close();
28308
+ });
28309
+ actions.appendChild(btn2);
28310
+ continue;
28311
+ }
28312
+ actions = null;
28313
+ if (item.type === "text") {
28314
+ const text = doc.createElement("div");
28315
+ text.className = "vela-callout-text";
28316
+ text.textContent = item.text;
28317
+ section.appendChild(text);
28318
+ } else if (item.type === "field") {
28319
+ const row = doc.createElement("div");
28320
+ row.className = "vela-marks-field";
28321
+ const label = doc.createElement("span");
28322
+ label.className = "vela-marks-field-label";
28323
+ label.textContent = item.label;
28324
+ const value = doc.createElement("span");
28325
+ value.className = "vela-marks-field-value";
28326
+ value.textContent = item.value;
28327
+ row.append(label, value);
28328
+ section.appendChild(row);
28329
+ }
28330
+ }
28331
+ }
28332
+ }
28333
+ };
28334
+ function scheduleLazy(scroller, pending) {
28335
+ if (typeof IntersectionObserver === "undefined") {
28336
+ for (const p of pending) p.resolve();
28337
+ return;
28338
+ }
28339
+ const io = new IntersectionObserver(
28340
+ (entries) => {
28341
+ for (const e of entries) {
28342
+ if (!e.isIntersecting) continue;
28343
+ const p = pending.find((q) => q.el === e.target);
28344
+ if (!p) continue;
28345
+ io.unobserve(e.target);
28346
+ p.resolve();
28347
+ }
28348
+ },
28349
+ { root: scroller }
28350
+ );
28351
+ for (const p of pending) io.observe(p.el);
28352
+ }
28353
+
27206
28354
  // src/renderers/native/core/manualScale.ts
27207
28355
  function rescaleAround(start, factor) {
27208
28356
  if (start.log && start.min > 0 && start.max > start.min) {
@@ -27931,11 +29079,12 @@ var SCROLL_BTN_BOTTOM = TIME_AXIS_H + 14;
27931
29079
  var SCROLL_BTN_PROXIMITY_PX = 120;
27932
29080
  var MIN_VISIBLE_BARS = 2;
27933
29081
  var ZOOM_OUT_MARGIN_BARS = 6;
27934
- var ZOOM_TAU_MS = 70;
27935
- var SCALE_TAU_MS = 80;
27936
- var FLING_TAU_MS = 110;
27937
- var SCROLL_TO_TAU_MS = 130;
29082
+ var INTRO_MODEL_FADE_MS = 350;
27938
29083
  var FLING_STOP_PX = 0.02;
29084
+ var MARK_FLASH_MS = 220;
29085
+ function frameNow() {
29086
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
29087
+ }
27939
29088
  var PRICE_SCALE_K = 4e-3;
27940
29089
  var KEY_ZOOM_STEP = 0.2;
27941
29090
  var SEPARATOR_HIT_PX = 4;
@@ -27984,9 +29133,20 @@ var NativeRenderer = class {
27984
29133
  this.indicatorSlices = new IndicatorDrawingSlices();
27985
29134
  /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
27986
29135
  this.labelTooltip = null;
29136
+ /** The timeline-mark popup (a kit Popover anchored on a lane glyph); null before mount. */
29137
+ this.markPopover = null;
29138
+ /** How the fanned mark stack was opened: a hover folds when the pointer leaves, a tap only on a tap elsewhere. */
29139
+ this.marksExpandedBy = "hover";
29140
+ /** The rAF loop keeping the chrome repainting while a lane glyph pulses (hover) or flashes (click); null when idle. */
29141
+ this.markPulseRaf = null;
29142
+ this.markClickCbs = /* @__PURE__ */ new Set();
27987
29143
  this.crosshairLayer = new CrosshairRenderer();
27988
- /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
27989
- this.countdownTimer = null;
29144
+ /** The second pulse the countdown-to-bar-close chip ticks on: the host's (`setWallClock`)
29145
+ * when one is wired, else the renderer's own second-aligned clock. */
29146
+ this.hostClock = null;
29147
+ this.ownClock = null;
29148
+ /** Live subscription to the pulse while the countdown is on; null when off. */
29149
+ this.countdownUnsub = null;
27990
29150
  this.symbolPicker = null;
27991
29151
  /** Indicator titles (the legend rows) shown — held here so a remount re-applies it. */
27992
29152
  this.indicatorTitlesOn = true;
@@ -28004,19 +29164,25 @@ var NativeRenderer = class {
28004
29164
  /** Drawings layer self-serves Ctrl+Z/Y (see the `historyChords` feature). */
28005
29165
  this.historyChordsEnabled = true;
28006
29166
  this.liveRegion = null;
28007
- // ── animation state (eased zoom + inertial pan + live-bar glide) ──
28008
- this.animZoom = true;
28009
- this.animPan = true;
28010
- this.animLiveBarMs = 0;
28011
- // forming-bar OHLC glide time-constant; 0 = each tick snaps
28012
- this.animLiveBarOnMs = LIVE_BAR_EASE_DEFAULT_MS;
28013
- // the duration the settings-dialog on/off toggle restores (last non-zero value configured)
29167
+ // ── animation state: one ease time-constant per motion (0 = off), each remembering the
29168
+ // host's duration so the config's on/off switches restore it (see EaseSetting) ──
29169
+ this.animZoom = new EaseSetting(ZOOM_EASE_DEFAULT_MS);
29170
+ // wheel-zoom glide
29171
+ this.animPan = new EaseSetting(PAN_INERTIA_DEFAULT_MS);
29172
+ // inertial-pan velocity decay
29173
+ this.animScroll = new EaseSetting(SCROLL_EASE_DEFAULT_MS);
29174
+ // scroll-to-latest / panBy glide
29175
+ this.animAutoscale = new EaseSetting(AUTOSCALE_EASE_DEFAULT_MS);
29176
+ // autoscale glide during zoom/fling
29177
+ this.animLiveBar = new EaseSetting(LIVE_BAR_EASE_DEFAULT_MS, 0);
29178
+ // forming-bar OHLC glide; ships off
28014
29179
  // Brand default candles.
28015
29180
  this.candleUp = BULLISH;
28016
29181
  this.candleDown = BEARISH;
28017
29182
  // ── intro reveal (plays once when candles first appear) ──
28018
- this.introStyle = "settle";
28019
- // 'grow' | 'settle' | '' (off)
29183
+ this.intro = { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
29184
+ this.introOnStyle = "settle";
29185
+ // the style the config's on/off switch restores
28020
29186
  this.introPlayed = false;
28021
29187
  this.introRaf = null;
28022
29188
  /** The load affordance (three pulsing dots) — up while the host reports a bar load in
@@ -28135,7 +29301,7 @@ var NativeRenderer = class {
28135
29301
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
28136
29302
  this.priceStyleCbs = /* @__PURE__ */ new Set();
28137
29303
  this.name = "native";
28138
- 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"];
29304
+ 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"];
28139
29305
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
28140
29306
  * so moving onto the button doesn't count as leaving). */
28141
29307
  this.onScrollProximityMove = (e) => {
@@ -28167,9 +29333,12 @@ var NativeRenderer = class {
28167
29333
  this.scene.showPriceLine = opts.currentPriceLine;
28168
29334
  this.scene.logScale = opts.logScale;
28169
29335
  this.backendMode = opts.nativeBackend;
28170
- this.animZoom = opts.animZoom;
28171
- this.animPan = opts.animPan;
28172
- this.setLiveBarEase(resolveLiveBarEaseMs(opts.animLiveBar));
29336
+ this.animZoom.set(opts.animZoom);
29337
+ this.animPan.set(opts.animPan);
29338
+ this.animScroll.set(opts.animScroll);
29339
+ this.animAutoscale.set(opts.animAutoscale);
29340
+ this.animLiveBar.set(opts.animLiveBar);
29341
+ this.setIntro(opts.animIntro);
28173
29342
  this.glowAmount = opts.glow;
28174
29343
  this.candleUp = opts.upColor;
28175
29344
  this.candleDown = opts.downColor;
@@ -28213,20 +29382,29 @@ var NativeRenderer = class {
28213
29382
  if (this.backend && "glow" in this.backend) this.backend.glow = this.glowAmount;
28214
29383
  break;
28215
29384
  case "animZoom":
28216
- this.animZoom = Boolean(value);
29385
+ this.animZoom.set(resolveEaseMs(value, ZOOM_EASE_DEFAULT_MS));
28217
29386
  return;
28218
29387
  // affects the next interaction only — nothing to repaint
28219
- case "animPan":
28220
- this.animPan = Boolean(value);
29388
+ case "animPan": {
29389
+ const ms = resolveEaseMs(value, PAN_INERTIA_DEFAULT_MS);
29390
+ this.animPan.set(ms);
29391
+ this.animScroll.toggle(ms > 0);
29392
+ return;
29393
+ }
29394
+ case "animScroll":
29395
+ this.animScroll.set(resolveEaseMs(value, SCROLL_EASE_DEFAULT_MS));
28221
29396
  return;
29397
+ case "animAutoscale":
29398
+ this.animAutoscale.set(resolveEaseMs(value, AUTOSCALE_EASE_DEFAULT_MS));
29399
+ return;
29400
+ // a glide in flight finishes at the new rate (or snaps at 0)
28222
29401
  case "animLiveBar":
28223
- this.setLiveBarEase(resolveLiveBarEaseMs(value));
29402
+ this.animLiveBar.set(resolveLiveBarEaseMs(value));
28224
29403
  return;
28225
29404
  // affects the next tick only; a glide in flight finishes at the new rate (or snaps at 0)
28226
29405
  case "intro": {
28227
- const s = value === false || value === "none" || value === "off" || value == null ? "" : String(value);
28228
- this.introStyle = s;
28229
- if (s) this.playIntro(s);
29406
+ this.setIntro(resolveIntro(value));
29407
+ if (this.intro.style) this.playIntro();
28230
29408
  return;
28231
29409
  }
28232
29410
  case "zoomAnchor":
@@ -28298,6 +29476,10 @@ var NativeRenderer = class {
28298
29476
  case "tradeMarkers":
28299
29477
  this.scene.tradeMarkers = mergeTradeMarkersState(this.scene.tradeMarkers, value);
28300
29478
  break;
29479
+ case "marks":
29480
+ this.scene.marks = mergeMarksState(this.scene.marks, value);
29481
+ this.markPopover?.close();
29482
+ break;
28301
29483
  case "keyboard":
28302
29484
  this.setKeyboardEnabled(Boolean(value));
28303
29485
  return;
@@ -28356,13 +29538,17 @@ var NativeRenderer = class {
28356
29538
  case "glow":
28357
29539
  return this.glowAmount;
28358
29540
  case "animZoom":
28359
- return this.animZoom;
29541
+ return this.animZoom.tau;
28360
29542
  case "animPan":
28361
- return this.animPan;
29543
+ return this.animPan.tau;
29544
+ case "animScroll":
29545
+ return this.animScroll.tau;
29546
+ case "animAutoscale":
29547
+ return this.animAutoscale.tau;
28362
29548
  case "animLiveBar":
28363
- return this.animLiveBarMs;
29549
+ return this.animLiveBar.tau;
28364
29550
  case "intro":
28365
- return this.introStyle;
29551
+ return this.intro.style;
28366
29552
  case "zoomAnchor":
28367
29553
  return this.zoomAnchorMode;
28368
29554
  case "axisDrag":
@@ -28405,6 +29591,8 @@ var NativeRenderer = class {
28405
29591
  }
28406
29592
  case "tradeMarkers":
28407
29593
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
29594
+ case "marks":
29595
+ return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
28408
29596
  case "keyboard":
28409
29597
  return this.keyboardEnabled;
28410
29598
  case "historyChords":
@@ -28530,7 +29718,13 @@ var NativeRenderer = class {
28530
29718
  currentPriceLine: this.scene.showPriceLine,
28531
29719
  priceLabel: this.scene.showPriceLabel,
28532
29720
  countdown: this.scene.showCountdown,
28533
- animateLastPrice: this.animLiveBarMs > 0
29721
+ animateLastPrice: this.animLiveBar.on
29722
+ },
29723
+ animations: {
29724
+ zoom: this.animZoom.on,
29725
+ pan: this.animPan.on,
29726
+ autoscale: this.animAutoscale.on,
29727
+ intro: this.intro.style !== false
28534
29728
  },
28535
29729
  panes: { separatorColor: s.separatorColor ?? t.borderColor },
28536
29730
  trades: {
@@ -28542,6 +29736,7 @@ var NativeRenderer = class {
28542
29736
  exitColor: this.scene.tradeMarkers.colors.exit
28543
29737
  },
28544
29738
  timeScale: { timezone: this.scene.timezone },
29739
+ marks: { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } },
28545
29740
  candles: {
28546
29741
  upColor: this.candleUp,
28547
29742
  downColor: this.candleDown,
@@ -28637,7 +29832,12 @@ var NativeRenderer = class {
28637
29832
  this.scene.showPriceLabel = next.priceScale.priceLabel;
28638
29833
  this.scene.showCountdown = next.priceScale.countdown;
28639
29834
  this.syncCountdownTimer();
28640
- this.animLiveBarMs = next.priceScale.animateLastPrice ? this.animLiveBarOnMs : 0;
29835
+ this.animLiveBar.toggle(next.priceScale.animateLastPrice);
29836
+ this.animZoom.toggle(next.animations.zoom);
29837
+ this.animPan.toggle(next.animations.pan);
29838
+ this.animScroll.toggle(next.animations.pan);
29839
+ this.animAutoscale.toggle(next.animations.autoscale);
29840
+ this.intro = { style: next.animations.intro ? this.introOnStyle : false, duration: this.intro.duration || INTRO_DURATION_DEFAULT_MS };
28641
29841
  s.separatorColor = keepInherit(s.separatorColor, next.panes.separatorColor, prevTheme.borderColor);
28642
29842
  this.scene.tradeMarkers = {
28643
29843
  visible: next.trades.visible,
@@ -28646,6 +29846,7 @@ var NativeRenderer = class {
28646
29846
  colors: { long: next.trades.longColor, short: next.trades.shortColor, exit: next.trades.exitColor }
28647
29847
  };
28648
29848
  this.scene.timezone = next.timeScale.timezone;
29849
+ this.scene.marks = { visible: next.marks.visible, groups: { ...next.marks.groups } };
28649
29850
  this.candleUp = next.candles.upColor;
28650
29851
  this.candleDown = next.candles.downColor;
28651
29852
  s.candle = {
@@ -28778,6 +29979,7 @@ var NativeRenderer = class {
28778
29979
  }
28779
29980
  this.settingsDialog.setTheme(this.theme);
28780
29981
  this.settingsDialog.setHostSections(this.hostSettingsSections);
29982
+ this.settingsDialog.setMarkGroups(this.markGroupsInUse(), (id) => markGroupVisible(this.scene.marks, id, this.scene.markGroups));
28781
29983
  this.settingsDialog.setHiddenSettings(this.hiddenSettings);
28782
29984
  this.syncThemeControl();
28783
29985
  this.settingsDialog.toggle(
@@ -28868,10 +30070,10 @@ var NativeRenderer = class {
28868
30070
  this.glideRightOffset(ZOOM_OUT_MARGIN_BARS);
28869
30071
  }
28870
30072
  /** Ease rightOffset to `target` at constant zoom (see animTick's scroll glide);
28871
- * instant when pan animation is off. Shared by scroll-to-latest and panBy. */
30073
+ * instant when the scroll glide is off. Shared by scroll-to-latest and panBy. */
28872
30074
  glideRightOffset(target) {
28873
30075
  const vp = this.coords.getViewport();
28874
- if (!this.animPan) {
30076
+ if (!this.animScroll.on) {
28875
30077
  this.applyViewport({ barSpacing: vp.barSpacing, rightOffset: target });
28876
30078
  return;
28877
30079
  }
@@ -28936,20 +30138,21 @@ var NativeRenderer = class {
28936
30138
  * full size, eased, with a left→right stagger so the chart draws itself; `settle`
28937
30139
  * adds an ease-out-back overshoot. Autoscale stays on the real bars so the frame
28938
30140
  * never moves. Re-callable, so styles can be compared live from the console.
30141
+ * Style and sweep duration come from the resolved `intro` setting.
28939
30142
  */
28940
- playIntro(style) {
30143
+ playIntro() {
28941
30144
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
28942
30145
  this.introRaf = null;
30146
+ const { style, duration } = this.intro;
28943
30147
  const real = this.bars;
28944
30148
  const n = real.length;
28945
- if (n === 0) return;
30149
+ if (n === 0 || !style) return;
28946
30150
  this.computeScales();
28947
30151
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
28948
30152
  this.modelAlpha = 0;
28949
- const DURATION = 650;
28950
30153
  const start = performance.now();
28951
30154
  const step = (now) => {
28952
- const p = Math.min(1, (now - start) / DURATION);
30155
+ const p = Math.min(1, (now - start) / duration);
28953
30156
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
28954
30157
  this.paintData();
28955
30158
  if (p < 1) {
@@ -28963,10 +30166,10 @@ var NativeRenderer = class {
28963
30166
  }
28964
30167
  /** After the candle reveal, fade the indicator models (series/fills/…) from hidden to full. */
28965
30168
  fadeInModels() {
28966
- const FADE = 350;
30169
+ const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
28967
30170
  const start = performance.now();
28968
30171
  const step = (now) => {
28969
- this.modelAlpha = Math.min(1, (now - start) / FADE);
30172
+ this.modelAlpha = Math.min(1, (now - start) / fade);
28970
30173
  this.paintData();
28971
30174
  if (this.modelAlpha < 1) {
28972
30175
  this.introRaf = requestAnimationFrame(step);
@@ -29081,7 +30284,8 @@ var NativeRenderer = class {
29081
30284
  zoomTo: (target, anchorLogical, anchorX) => this.zoomTo(target, anchorLogical, anchorX),
29082
30285
  fling: (v) => this.fling(v),
29083
30286
  onPointerMove: (x, y) => this.handlePointerMove(x, y),
29084
- onClick: (x) => {
30287
+ onClick: (x, y) => {
30288
+ if (this.handleMarkClick(x, y)) return;
29085
30289
  this.userDrawings?.deselect();
29086
30290
  this.handleClick(x);
29087
30291
  },
@@ -29110,7 +30314,7 @@ var NativeRenderer = class {
29110
30314
  drawingsPointerDown: (x, y, snap, shift2, mod) => this.userDrawings?.pointerDown(x, y, snap, shift2, mod),
29111
30315
  drawingsPointerMove: (x, y, snap, shift2, mod) => this.userDrawings?.pointerMove(x, y, snap, shift2, mod),
29112
30316
  drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
29113
- drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
30317
+ drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? (this.chrome.markGlyphAt(x, y) ? "pointer" : null),
29114
30318
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
29115
30319
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
29116
30320
  });
@@ -29126,8 +30330,18 @@ var NativeRenderer = class {
29126
30330
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
29127
30331
  this.labelTooltip = new LabelTooltip(this.plot, {
29128
30332
  theme: () => this.chromeTheme(),
29129
- lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
30333
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y) ?? this.chrome.markTooltipAt(x, y, this.markGroupsInUse())
30334
+ });
30335
+ this.markPopover = new MarkPopover({
30336
+ plot: this.plot,
30337
+ host: () => this.dialogHost ?? this.plot,
30338
+ theme: () => this.chromeTheme(),
30339
+ onOpenChange: (key) => {
30340
+ this.scene.marksActiveKey = key;
30341
+ this.scheduler?.invalidate(2 /* Chrome */);
30342
+ }
29130
30343
  });
30344
+ this.chrome.setMarkIconReady(() => this.scheduler?.invalidate(2 /* Chrome */));
29131
30345
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
29132
30346
  projector: () => this.drawingProjector(),
29133
30347
  dpr: () => this.coords.dpr,
@@ -29342,29 +30556,40 @@ var NativeRenderer = class {
29342
30556
  resize() {
29343
30557
  this.syncSize();
29344
30558
  }
29345
- /** Run a 1 Hz repaint pump while the countdown chip is on (so it ticks); stop it otherwise.
29346
- * Chrome tier: only the chip's wall-clock text moves — an idle chart must not recompute
30559
+ /** Drive the countdown chip from the host's second pulse (`null` the renderer's own). */
30560
+ setWallClock(clock) {
30561
+ if (clock === this.hostClock) return;
30562
+ this.hostClock = clock;
30563
+ if (this.countdownUnsub != null) {
30564
+ this.countdownUnsub();
30565
+ this.countdownUnsub = null;
30566
+ }
30567
+ this.syncCountdownTimer();
30568
+ }
30569
+ /** Subscribe to the second pulse while the countdown chip is on (so it ticks); unsubscribe
30570
+ * otherwise. Chrome tier: only the chip's text moves — an idle chart must not recompute
29347
30571
  * scales or repaint the geometry/volume/VPVR/SDK layers once a second (that cost
29348
30572
  * multiplies by the cell count in a multi-chart workspace). */
29349
30573
  syncCountdownTimer() {
29350
30574
  if (this.scene.showCountdown) {
29351
- if (this.countdownTimer == null) {
29352
- this.countdownTimer = setInterval(() => {
30575
+ if (this.countdownUnsub == null) {
30576
+ const clock = this.hostClock ?? (this.ownClock ?? (this.ownClock = new SecondClock()));
30577
+ this.countdownUnsub = clock.onTick(() => {
29353
30578
  if (this.scene.showCountdown && this.scene.bars.length > 0) this.scheduler?.invalidate(2 /* Chrome */);
29354
- }, 1e3);
30579
+ });
29355
30580
  }
29356
- } else if (this.countdownTimer != null) {
29357
- clearInterval(this.countdownTimer);
29358
- this.countdownTimer = null;
30581
+ } else if (this.countdownUnsub != null) {
30582
+ this.countdownUnsub();
30583
+ this.countdownUnsub = null;
29359
30584
  }
29360
30585
  }
29361
30586
  destroy() {
29362
30587
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
29363
30588
  this.loadingEl?.remove();
29364
30589
  this.loadingEl = null;
29365
- if (this.countdownTimer != null) {
29366
- clearInterval(this.countdownTimer);
29367
- this.countdownTimer = null;
30590
+ if (this.countdownUnsub != null) {
30591
+ this.countdownUnsub();
30592
+ this.countdownUnsub = null;
29368
30593
  }
29369
30594
  this.scheduler?.destroy();
29370
30595
  this.animator?.stop();
@@ -29389,6 +30614,11 @@ var NativeRenderer = class {
29389
30614
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
29390
30615
  this.labelTooltip?.destroy();
29391
30616
  this.labelTooltip = null;
30617
+ this.markPopover?.destroy();
30618
+ this.markPopover = null;
30619
+ this.chrome.setMarkIconReady(null);
30620
+ if (this.markPulseRaf !== null) cancelAnimationFrame(this.markPulseRaf);
30621
+ this.markPulseRaf = null;
29392
30622
  this.scrollButton?.remove();
29393
30623
  this.scrollButton = null;
29394
30624
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -29447,8 +30677,8 @@ var NativeRenderer = class {
29447
30677
  }
29448
30678
  if (!this.introPlayed && this.bars.length > 0) {
29449
30679
  this.introPlayed = true;
29450
- if (this.introStyle) {
29451
- this.playIntro(this.introStyle);
30680
+ if (this.intro.style) {
30681
+ this.playIntro();
29452
30682
  return;
29453
30683
  }
29454
30684
  }
@@ -29459,7 +30689,7 @@ var NativeRenderer = class {
29459
30689
  const last = this.bars[n - 1];
29460
30690
  if (last && bar.time === last.time) {
29461
30691
  this.bars[n - 1] = bar;
29462
- if (this.animLiveBarMs <= 0 || this.liveEaseTime !== bar.time) {
30692
+ if (!this.animLiveBar.on || this.liveEaseTime !== bar.time) {
29463
30693
  this.syncLiveEase(bar);
29464
30694
  } else {
29465
30695
  this.animator.start();
@@ -29475,11 +30705,11 @@ var NativeRenderer = class {
29475
30705
  this.scene.bars = this.bars;
29476
30706
  this.scheduler.invalidate(4 /* Full */);
29477
30707
  }
29478
- /** Set the live-bar glide duration (0 = off). A non-zero value is also remembered as
29479
- * what the config's on/off toggle (`priceScale.animateLastPrice`) switches back on to. */
29480
- setLiveBarEase(ms) {
29481
- this.animLiveBarMs = ms;
29482
- if (ms > 0) this.animLiveBarOnMs = ms;
30708
+ /** Set the reveal (style + duration). A non-off style is also remembered as what the
30709
+ * config's on/off toggle (`animations.intro`) switches back on to. */
30710
+ setIntro(next) {
30711
+ this.intro = next;
30712
+ if (next.style) this.introOnStyle = next.style;
29483
30713
  }
29484
30714
  /** Snap the eased forming-bar state to `bar` — no glide (a fresh bar or the first tick of one). */
29485
30715
  syncLiveEase(bar) {
@@ -29493,7 +30723,7 @@ var NativeRenderer = class {
29493
30723
  const target = this.bars[this.bars.length - 1];
29494
30724
  if (!target || this.liveEaseTime !== target.time) return false;
29495
30725
  const eps = Math.max(1e-9, Math.abs(target.close) * 1e-6);
29496
- const tau = this.animLiveBarMs;
30726
+ const tau = this.animLiveBar.tau;
29497
30727
  const nh = easeToward(this.liveEaseHigh, target.high, dtMs, tau);
29498
30728
  const nl = easeToward(this.liveEaseLow, target.low, dtMs, tau);
29499
30729
  const nc = easeToward(this.liveEaseClose, target.close, dtMs, tau);
@@ -29633,10 +30863,12 @@ var NativeRenderer = class {
29633
30863
  this.refreshAnchorOffset(model);
29634
30864
  if (model.native && this.extLayers.some((l) => l.def.id === model.native.type)) this.scene.assignIndicatorZTop(model.id);
29635
30865
  else this.scene.assignIndicatorZ(model.id);
29636
- this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
29637
- native: !!model.native,
29638
- ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
29639
- });
30866
+ if (model.legend !== false) {
30867
+ this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
30868
+ native: !!model.native,
30869
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
30870
+ });
30871
+ }
29640
30872
  if (model.native?.type === "volume") {
29641
30873
  this.volumeActive = true;
29642
30874
  this.volumeHidden = false;
@@ -29783,7 +31015,7 @@ var NativeRenderer = class {
29783
31015
  this.settingsDialog?.setHiddenSettings(this.hiddenSettings);
29784
31016
  }
29785
31017
  listSettingsIds() {
29786
- return settingsIdCatalog(this.hostSettingsSections);
31018
+ return settingsIdCatalog(this.hostSettingsSections, this.markGroupsInUse());
29787
31019
  }
29788
31020
  onChartTypeSettingsChange(cb) {
29789
31021
  this.chartTypeSettingsCbs.add(cb);
@@ -29809,6 +31041,22 @@ var NativeRenderer = class {
29809
31041
  this.axisLongPressCbs.add(cb);
29810
31042
  return () => this.axisLongPressCbs.delete(cb);
29811
31043
  }
31044
+ // ── timeline marks (the `chart.marks` model; see the port) ──
31045
+ setTimelineMarks(marks, groups) {
31046
+ this.scene.timelineMarks = marks;
31047
+ this.scene.markGroups = groups;
31048
+ this.scene.marksExpandedStack = null;
31049
+ this.markPopover?.close();
31050
+ this.scheduler?.invalidate(2 /* Chrome */);
31051
+ }
31052
+ onMarkClick(cb) {
31053
+ this.markClickCbs.add(cb);
31054
+ return () => this.markClickCbs.delete(cb);
31055
+ }
31056
+ /** Every group the lane knows: the defined ones, then those marks name without a definition. */
31057
+ markGroupsInUse() {
31058
+ return effectiveMarkGroups(this.scene.timelineMarks, this.scene.markGroups);
31059
+ }
29812
31060
  onViewportChange(cb) {
29813
31061
  this.viewportCbs.add(cb);
29814
31062
  return () => this.viewportCbs.delete(cb);
@@ -29862,7 +31110,7 @@ var NativeRenderer = class {
29862
31110
  this.zoomAnchorX = anchorX;
29863
31111
  this.panVelocity = 0;
29864
31112
  this.scrollTargetRO = null;
29865
- if (!this.animZoom) {
31113
+ if (!this.animZoom.on) {
29866
31114
  const v = this.clampViewport(barSpacing, this.anchoredRightOffset(barSpacing));
29867
31115
  this.coords.setViewport(v);
29868
31116
  this.targetBarSpacing = v.barSpacing;
@@ -29875,7 +31123,7 @@ var NativeRenderer = class {
29875
31123
  }
29876
31124
  /** Inertial pan: continue with a rightOffset velocity (logical units / ms) that decays. */
29877
31125
  fling(velocity) {
29878
- if (!this.animPan) return;
31126
+ if (!this.animPan.on) return;
29879
31127
  this.scrollTargetRO = null;
29880
31128
  this.panVelocity = velocity;
29881
31129
  this.animator.start();
@@ -29915,7 +31163,7 @@ var NativeRenderer = class {
29915
31163
  let active = false;
29916
31164
  const tbs = this.targetBarSpacing;
29917
31165
  if (Math.abs(barSpacing - tbs) > tbs * 1e-3) {
29918
- barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, ZOOM_TAU_MS));
31166
+ barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, this.animZoom.tau));
29919
31167
  rightOffset = this.anchoredRightOffset(barSpacing);
29920
31168
  active = true;
29921
31169
  } else if (barSpacing !== tbs) {
@@ -29925,13 +31173,14 @@ var NativeRenderer = class {
29925
31173
  const stopVel = FLING_STOP_PX / Math.max(1e-6, barSpacing * this.coords.spacingScale);
29926
31174
  if (Math.abs(this.panVelocity) > stopVel) {
29927
31175
  rightOffset += this.panVelocity * dtMs;
29928
- this.panVelocity *= Math.exp(-dtMs / FLING_TAU_MS);
31176
+ const tau = this.animPan.tau;
31177
+ this.panVelocity = tau > 0 ? this.panVelocity * Math.exp(-dtMs / tau) : 0;
29929
31178
  if (Math.abs(this.panVelocity) <= stopVel) this.panVelocity = 0;
29930
31179
  else active = true;
29931
31180
  }
29932
31181
  if (this.scrollTargetRO != null) {
29933
31182
  const target = this.scrollTargetRO;
29934
- const next = easeToward(rightOffset, target, dtMs, SCROLL_TO_TAU_MS);
31183
+ const next = easeToward(rightOffset, target, dtMs, this.animScroll.tau);
29935
31184
  if (Math.abs(next - target) < 1e-3) {
29936
31185
  rightOffset = target;
29937
31186
  this.scrollTargetRO = null;
@@ -29959,6 +31208,7 @@ var NativeRenderer = class {
29959
31208
  * through Math.log), not a non-linear jump. */
29960
31209
  easeScales(dtMs) {
29961
31210
  let moving = false;
31211
+ const tau = this.animAutoscale.tau;
29962
31212
  for (const pane of this.scene.panes.values()) {
29963
31213
  const t = pane.scaleTarget;
29964
31214
  const s = pane.scale;
@@ -29966,8 +31216,8 @@ var NativeRenderer = class {
29966
31216
  const lt0 = Math.log(t.min);
29967
31217
  const lt1 = Math.log(t.max);
29968
31218
  const lspan = Math.max(1e-9, Math.abs(lt1 - lt0));
29969
- const n0 = easeToward(Math.log(s.min), lt0, dtMs, SCALE_TAU_MS);
29970
- const n1 = easeToward(Math.log(s.max), lt1, dtMs, SCALE_TAU_MS);
31219
+ const n0 = easeToward(Math.log(s.min), lt0, dtMs, tau);
31220
+ const n1 = easeToward(Math.log(s.max), lt1, dtMs, tau);
29971
31221
  if (Math.abs(n0 - lt0) <= lspan * 1e-3 && Math.abs(n1 - lt1) <= lspan * 1e-3) {
29972
31222
  pane.scale = { min: t.min, max: t.max, log: true };
29973
31223
  } else {
@@ -29977,8 +31227,8 @@ var NativeRenderer = class {
29977
31227
  continue;
29978
31228
  }
29979
31229
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
29980
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
29981
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
31230
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
31231
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
29982
31232
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
29983
31233
  nmin = t.min;
29984
31234
  nmax = t.max;
@@ -29991,8 +31241,8 @@ var NativeRenderer = class {
29991
31241
  const t = sl.scaleTarget;
29992
31242
  const s = sl.scale;
29993
31243
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
29994
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
29995
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
31244
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
31245
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
29996
31246
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
29997
31247
  nmin = t.min;
29998
31248
  nmax = t.max;
@@ -30013,6 +31263,8 @@ var NativeRenderer = class {
30013
31263
  this.scene.crosshair = null;
30014
31264
  this.hoverSeparatorY = null;
30015
31265
  this.lastPointer = null;
31266
+ if (this.marksExpandedBy === "hover") this.setMarksExpanded(null);
31267
+ this.setMarkHover(null);
30016
31268
  this.scheduler.invalidate(1 /* Cursor */);
30017
31269
  this.hoverLogical = null;
30018
31270
  const empty = { time: null, price: null, paneKind: null, values: /* @__PURE__ */ new Map(), ohlc: null };
@@ -30024,6 +31276,8 @@ var NativeRenderer = class {
30024
31276
  this.lastPointer = inData ? { x, y } : null;
30025
31277
  this.hoverSeparatorY = x >= 0 && y >= 0 && y <= this.coords.height ? this.separatorHoverY(y) : null;
30026
31278
  this.scheduler.invalidate(1 /* Cursor */);
31279
+ this.setMarksExpanded(inData ? this.chrome.markStackAt(x, y) : null);
31280
+ this.setMarkHover(inData ? this.chrome.markGlyphAt(x, y)?.cluster.key ?? null : null);
30027
31281
  const logical = Math.round(this.coords.xToLogical(x));
30028
31282
  const onBar = logical >= 0 && logical < this.coords.barCount;
30029
31283
  const time = onBar ? this.coords.logicalToTime(logical) : null;
@@ -30053,6 +31307,76 @@ var NativeRenderer = class {
30053
31307
  const onBar = logical >= 0 && logical < this.coords.barCount;
30054
31308
  for (const cb of this.clickCbs) cb({ time: onBar ? this.coords.logicalToTime(logical) : null, price: null });
30055
31309
  }
31310
+ /**
31311
+ * Fan out (or collapse, with null) a multi-group mark stack; repaints the chrome tier when
31312
+ * it changes. A stack whose glyph holds the open popup stays fanned — the pointer leaving
31313
+ * the plot for the popup must not bury the glyph under the deck (which would close it).
31314
+ */
31315
+ setMarksExpanded(stack, by = "hover") {
31316
+ if (this.scene.marksExpandedStack === stack) return;
31317
+ if (stack === null && this.markPopover?.key) {
31318
+ const open2 = this.chrome.markGlyphByKey(this.markPopover.key);
31319
+ if (open2 && open2.stack === this.scene.marksExpandedStack) return;
31320
+ }
31321
+ this.scene.marksExpandedStack = stack;
31322
+ this.marksExpandedBy = by;
31323
+ this.scheduler?.invalidate(2 /* Chrome */);
31324
+ }
31325
+ /**
31326
+ * A click on the mark lane: a collapsed deck fans out (the touch path — a mouse already
31327
+ * fanned it by hovering), a glyph reports its cluster (`onMarkClick`) and opens the popup
31328
+ * when any of its marks carries content. True when the click landed on the lane.
31329
+ */
31330
+ handleMarkClick(x, y) {
31331
+ const glyph = this.chrome.markGlyphAt(x, y);
31332
+ if (!glyph) {
31333
+ if (this.scene.marksExpandedStack !== null && this.chrome.markStackAt(x, y) === null) this.setMarksExpanded(null);
31334
+ return false;
31335
+ }
31336
+ if (glyph.decked) {
31337
+ this.setMarksExpanded(glyph.stack, "tap");
31338
+ return true;
31339
+ }
31340
+ const marks = glyph.cluster.marks;
31341
+ const first = marks[0];
31342
+ const event = { id: first.id, ids: marks.map((m) => m.id), time: first.time, ...glyph.cluster.group !== void 0 ? { group: glyph.cluster.group } : {} };
31343
+ for (const cb of this.markClickCbs) cb(event);
31344
+ if (marks.some((m) => m.content !== void 0)) {
31345
+ this.markPopover?.open(glyph.cluster, { x: glyph.x, y: glyph.y, size: glyph.size });
31346
+ } else {
31347
+ this.scene.marksFlash = { key: glyph.cluster.key, until: frameNow() + MARK_FLASH_MS };
31348
+ this.syncMarkPulse();
31349
+ }
31350
+ return true;
31351
+ }
31352
+ /** After a chrome frame: keep the open mark popup on its glyph, or close it once the glyph is gone. */
31353
+ trackMarkPopover() {
31354
+ const key = this.markPopover?.key;
31355
+ if (!key) return;
31356
+ const g = this.chrome.markGlyphByKey(key);
31357
+ this.markPopover.track(g && !(g.decked && g.depth !== 0) ? { x: g.x, y: g.y, size: g.size } : null);
31358
+ }
31359
+ /** The lane glyph under the pointer — it swells once as the pointer lands (a rAF-driven chrome repaint for the pulse's duration). */
31360
+ setMarkHover(key) {
31361
+ if (this.scene.marksHoverKey === key) return;
31362
+ this.scene.marksHoverKey = key;
31363
+ this.scene.marksHoverSince = frameNow();
31364
+ this.scheduler?.invalidate(2 /* Chrome */);
31365
+ this.syncMarkPulse();
31366
+ }
31367
+ /** Run a chrome-tier repaint loop while a glyph's hover pulse or click flash plays; it stops itself once both are over. */
31368
+ syncMarkPulse() {
31369
+ if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
31370
+ const tick = () => {
31371
+ this.markPulseRaf = null;
31372
+ const now = frameNow();
31373
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
31374
+ this.scheduler?.invalidate(2 /* Chrome */);
31375
+ const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
31376
+ if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
31377
+ };
31378
+ this.markPulseRaf = requestAnimationFrame(tick);
31379
+ }
30056
31380
  paneAtY(y) {
30057
31381
  return this.paneNodeAtY(y);
30058
31382
  }
@@ -30444,6 +31768,7 @@ var NativeRenderer = class {
30444
31768
  } else if (repaintsChrome(level) && this.paintedData) {
30445
31769
  this.chrome.prepare(this.scene, this.coords, this.theme);
30446
31770
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
31771
+ this.trackMarkPopover();
30447
31772
  }
30448
31773
  this.crosshairLayer.render(this.scene, this.coords, this.theme, this.hoverSeparatorY, this.externalCrossPx());
30449
31774
  if (!repaintsData(level) && this.paintedData) this.repaintCursorLayers();
@@ -30459,7 +31784,7 @@ var NativeRenderer = class {
30459
31784
  const lp = this.layerPane(l.def.id) ?? pane;
30460
31785
  if (lp.collapsed) continue;
30461
31786
  l.instance.render(this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs));
30462
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
31787
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
30463
31788
  }
30464
31789
  }
30465
31790
  /** Blank one SDK layer canvas (a collapsed host pane suppresses the layer's painting). */
@@ -30524,7 +31849,7 @@ var NativeRenderer = class {
30524
31849
  const args = this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs);
30525
31850
  l.instance.render(args);
30526
31851
  if (lp === pane) folded = foldBaseModulation(folded, l.instance.modulateBase?.(args) ?? null);
30527
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
31852
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
30528
31853
  }
30529
31854
  if (folded) {
30530
31855
  if (folded.candleBodyScale != null) this.backend.candleBodyScale = clamp012(folded.candleBodyScale) || 0.01;
@@ -30543,6 +31868,7 @@ var NativeRenderer = class {
30543
31868
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
30544
31869
  this.backend.render(this.scene, this.coords, this.theme);
30545
31870
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
31871
+ this.trackMarkPopover();
30546
31872
  this.userDrawings?.render();
30547
31873
  if (easeLive && liveActual) this.bars[li] = liveActual;
30548
31874
  this.paintedData = true;
@@ -31055,7 +32381,7 @@ var NativeRenderer = class {
31055
32381
  const map2 = /* @__PURE__ */ new Map();
31056
32382
  for (const pane of this.scene.panes.values()) {
31057
32383
  if (!pane.collapsed) continue;
31058
- const models = this.scene.orderedIndicatorsForPane(pane.id);
32384
+ const models = this.scene.orderedIndicatorsForPane(pane.id).filter((m) => m.legend !== false);
31059
32385
  const merged = new Set(this.scene.ownScaleIndicatorsForPane(pane.id).map((m) => m.id));
31060
32386
  const master = models.find((m) => !merged.has(m.id)) ?? models[0];
31061
32387
  map2.set(pane.id, master?.id ?? null);
@@ -33879,6 +35205,7 @@ var Vela = class {
33879
35205
  if (Object.keys(defaults2).length > 0) this.rendererControl.set(defaults2);
33880
35206
  this.panesControl = new PanesControl(this.orchestrator);
33881
35207
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
35208
+ this.marksControl = new MarksControl(this.orchestrator.marks);
33882
35209
  }
33883
35210
  /**
33884
35211
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -34097,6 +35424,17 @@ var Vela = class {
34097
35424
  get drawings() {
34098
35425
  return this.drawingsControl;
34099
35426
  }
35427
+ /**
35428
+ * The chart's timeline-marks control surface: host events pinned to a bar and shown
35429
+ * as glyphs on a lane above the time axis, each opening a popup on click —
35430
+ * `chart.marks.add({ id, time, glyph, title, content })`, `chart.marks.set(list)`,
35431
+ * `chart.marks.defineGroup({ id, label })`. Marks are data, not user state: re-supply
35432
+ * them on `market:changed`. On a renderer without the `timelineMarks` capability the
35433
+ * model still fills but nothing paints (`chart.marks.supported`).
35434
+ */
35435
+ get marks() {
35436
+ return this.marksControl;
35437
+ }
34100
35438
  on(event, handler) {
34101
35439
  return this.orchestrator.events.on(event, handler);
34102
35440
  }
@@ -34309,6 +35647,7 @@ exports.INFO = INFO;
34309
35647
  exports.INVALID = INVALID;
34310
35648
  exports.LIGHT_THEME = LIGHT_THEME;
34311
35649
  exports.MARKER = MARKER;
35650
+ exports.MarksControl = MarksControl;
34312
35651
  exports.MultiProviderFeed = MultiProviderFeed;
34313
35652
  exports.NEUTRAL = NEUTRAL;
34314
35653
  exports.NativeRenderer = NativeRenderer;
@@ -34320,6 +35659,7 @@ exports.SESSION_POST = SESSION_POST;
34320
35659
  exports.SESSION_PRE = SESSION_PRE;
34321
35660
  exports.SLATE = SLATE;
34322
35661
  exports.SLATE_DEEP = SLATE_DEEP;
35662
+ exports.SecondClock = SecondClock;
34323
35663
  exports.TRADE_EXIT = TRADE_EXIT;
34324
35664
  exports.TRADE_LONG = TRADE_LONG;
34325
35665
  exports.TRADE_SHORT = TRADE_SHORT;