@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
@@ -1,6 +1,6 @@
1
1
  import { deserializeDrawing, chartTypes, rendererLayers, foldBaseModulation, registerChartType, rendererDefaults, getDrawingType, settingsRowVisible, normalizeSettingsRow, Magnifier, TextLabel, Callout, resetDrawingSettings, registerNativeIndicator, chartType, getNativeIndicator, nativeIndicatorDescriptors, drawingTypes, settingsRowValueKeys, formatDuration, RegressionChannel, FixedRangeVolumeProfile, DEFAULT_DRAWING_COLOR, SegmentDrawing, ArrowMark, GlyphStamp, RadialFib, FibSpiral, DedekindTessellation, MachFigure, GannSquare, FibRatios, MeasureBox, PositionTool, AnchoredVwap, PatternDrawing, Comment, PriceNote, Signpost, Note, PriceLabel, GANN_SQUARE_ARCS, magnifierTimeframeLabel, lineSegmentIntersection, MAGNIFIER_TIMEFRAME_OPTIONS, GLYPH_OPTIONS, STAMP_SIZE_OPTIONS, LINE_STYLE_OPTIONS, DEDEKIND_CURVATURE_OPTIONS, MACH_NUMBER_OPTIONS, MACH_WAVE_COUNT_OPTIONS, TEXT_SIZE_OPTIONS, createDrawing, inputVisible, tickerModifierIds, CalloutBase, DIRECTION_OPTIONS, stableSeriesId } from './chunk-IFJJPXSV.js';
2
- import { themeTokens, Dialog, closeOpenPopovers, closeWidthPopover, fieldSection, buildFieldControl, fieldSeparator, fieldGrid, fieldRow, blendOver, splitColor, CalloutBubble, Menu, TextArea, Popover, NumberInput, buildColorPicker, isPopoverOpen, eventDismissedPopover, toggleSelectList, openPopoverTrigger, fieldGridColumns, FIELD_GAP_PX, STATIC_TOKENS } from './chunk-T5Z5YUCF.js';
3
- import { icon, iconAt, withAlpha, WARNING, ACCENT, NEUTRAL, BULLISH, CATEGORICAL, SERIES_LINE, BEARISH, isDarkColor, INFO, CHIP_PLATE, CROSSHAIR, TRADE_EXIT, TRADE_SHORT, TRADE_LONG, overlayScrollbarCss, SLATE_DEEP, VALID, INVALID, SLATE, FIELD_FOCUS_CSS, FIELD_FOCUS_RING, svg24, svg24Solid } from './chunk-CAFCLMPF.js';
2
+ import { themeTokens, Dialog, closeOpenPopovers, closeWidthPopover, fieldSection, buildFieldControl, fieldSeparator, fieldGrid, fieldRow, CALLOUT_STYLE_ID, CALLOUT_CSS, Popover, blendOver, splitColor, CalloutBubble, Menu, TextArea, NumberInput, buildColorPicker, isPopoverOpen, eventDismissedPopover, toggleSelectList, openPopoverTrigger, fieldGridColumns, FIELD_GAP_PX, STATIC_TOKENS } from './chunk-CZRNTHD6.js';
3
+ import { icon, iconAt, withAlpha, WARNING, ACCENT, NEUTRAL, BULLISH, CATEGORICAL, SERIES_LINE, BEARISH, isDarkColor, INFO, injectStyles, CHIP_PLATE, CROSSHAIR, TRADE_EXIT, TRADE_SHORT, TRADE_LONG, iconMarkup, overlayScrollbarCss, SLATE_DEEP, VALID, INVALID, SLATE, FIELD_FOCUS_CSS, FIELD_FOCUS_RING, svg24, svg24Solid } from './chunk-CAFCLMPF.js';
4
4
 
5
5
  // src/core/events/EventBus.ts
6
6
  var TypedEventBus = class {
@@ -608,6 +608,16 @@ var RendererControl = class {
608
608
  this.renderer.setLayoutMode?.(mode);
609
609
  return this;
610
610
  }
611
+ /**
612
+ * Drive the renderer's time-of-day chrome (the countdown-to-bar-close chip) from the
613
+ * host's own second pulse, so it ticks in step with a host clock display instead of
614
+ * on a separate timer that can read a different second. `null` hands the pulse back
615
+ * to the renderer. Silent no-op on a renderer without time-of-day chrome.
616
+ */
617
+ setWallClock(clock) {
618
+ this.renderer.setWallClock?.(clock);
619
+ return this;
620
+ }
611
621
  };
612
622
 
613
623
  // src/data/BarStore.ts
@@ -1691,6 +1701,95 @@ var DrawingsControl = class {
1691
1701
  }
1692
1702
  };
1693
1703
 
1704
+ // src/core/MarksControl.ts
1705
+ var MarksControl = class {
1706
+ constructor(ctrl) {
1707
+ this.ctrl = ctrl;
1708
+ }
1709
+ /** Whether the active renderer paints timeline marks. */
1710
+ get supported() {
1711
+ return this.ctrl.supported;
1712
+ }
1713
+ /** Add one mark; an existing id is replaced in place. */
1714
+ add(mark) {
1715
+ this.ctrl.add(mark);
1716
+ return this;
1717
+ }
1718
+ /** Replace the whole set (a market switch). */
1719
+ set(marks) {
1720
+ this.ctrl.set(marks);
1721
+ return this;
1722
+ }
1723
+ remove(id) {
1724
+ this.ctrl.remove(id);
1725
+ return this;
1726
+ }
1727
+ clear() {
1728
+ this.ctrl.clear();
1729
+ return this;
1730
+ }
1731
+ /** Every mark, in insertion order. */
1732
+ all() {
1733
+ return this.ctrl.all();
1734
+ }
1735
+ /**
1736
+ * Define a visibility group's presentation: the label of its checkbox in chart
1737
+ * settings (the Events tab) and its default visibility. Marks may name a group
1738
+ * that was never defined — it then shows its capitalized id.
1739
+ */
1740
+ defineGroup(group) {
1741
+ this.ctrl.defineGroup(group);
1742
+ return this;
1743
+ }
1744
+ /** The defined groups, in definition order. */
1745
+ groups() {
1746
+ return this.ctrl.groupDefinitions();
1747
+ }
1748
+ /** Show or hide one group's marks — the same switch as the settings checkbox, persisted with the chart's config. */
1749
+ setGroupVisible(id, visible = true) {
1750
+ this.ctrl.setGroupVisible(id, visible);
1751
+ return this;
1752
+ }
1753
+ /** A group's effective visibility (the user's choice, else the group's declared default). */
1754
+ isGroupVisible(id) {
1755
+ return this.ctrl.isGroupVisible(id);
1756
+ }
1757
+ };
1758
+
1759
+ // src/core/util/wall-clock.ts
1760
+ var BOUNDARY_SLACK_MS = 5;
1761
+ var SecondClock = class _SecondClock {
1762
+ constructor(now = () => Date.now()) {
1763
+ this.now = now;
1764
+ this.subs = /* @__PURE__ */ new Set();
1765
+ this.timer = null;
1766
+ }
1767
+ onTick(cb) {
1768
+ this.subs.add(cb);
1769
+ if (this.timer == null) this.arm();
1770
+ return () => {
1771
+ this.subs.delete(cb);
1772
+ if (this.subs.size === 0 && this.timer != null) {
1773
+ clearTimeout(this.timer);
1774
+ this.timer = null;
1775
+ }
1776
+ };
1777
+ }
1778
+ /** Milliseconds from `now` to just past the next second boundary. */
1779
+ static delayToNextSecond(now) {
1780
+ const intoSecond = (now % 1e3 + 1e3) % 1e3;
1781
+ return 1e3 - intoSecond + BOUNDARY_SLACK_MS;
1782
+ }
1783
+ arm() {
1784
+ this.timer = setTimeout(() => {
1785
+ this.timer = null;
1786
+ const now = this.now();
1787
+ for (const cb of this.subs) cb(now);
1788
+ if (this.subs.size > 0) this.arm();
1789
+ }, _SecondClock.delayToNextSecond(this.now()));
1790
+ }
1791
+ };
1792
+
1694
1793
  // src/core/timezones.ts
1695
1794
  var TIMEZONES = [
1696
1795
  { value: "Etc/UTC", label: "UTC" },
@@ -3026,19 +3125,48 @@ function arrowHitHtml(svg) {
3026
3125
 
3027
3126
  // src/core/options.ts
3028
3127
  var normalizeSession = (v) => v === "regular" || v === "extended" ? v : void 0;
3128
+ var ZOOM_EASE_DEFAULT_MS = 70;
3129
+ var PAN_INERTIA_DEFAULT_MS = 110;
3130
+ var SCROLL_EASE_DEFAULT_MS = 130;
3131
+ var AUTOSCALE_EASE_DEFAULT_MS = 80;
3029
3132
  var LIVE_BAR_EASE_DEFAULT_MS = 90;
3030
- var LIVE_BAR_EASE_MAX_MS = 1e3;
3031
- function resolveLiveBarEaseMs(value) {
3032
- if (value === true) return LIVE_BAR_EASE_DEFAULT_MS;
3133
+ var INTRO_DURATION_DEFAULT_MS = 650;
3134
+ var ANIMATION_EASE_MAX_MS = 1e3;
3135
+ var LIVE_BAR_EASE_MAX_MS = ANIMATION_EASE_MAX_MS;
3136
+ var INTRO_DURATION_MAX_MS = 5e3;
3137
+ function resolveEaseMs(value, defaultMs, maxMs = ANIMATION_EASE_MAX_MS) {
3138
+ if (value === true) return defaultMs;
3033
3139
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
3034
- return Math.min(value, LIVE_BAR_EASE_MAX_MS);
3140
+ return Math.min(value, maxMs);
3141
+ }
3142
+ function resolveLiveBarEaseMs(value) {
3143
+ return resolveEaseMs(value, LIVE_BAR_EASE_DEFAULT_MS, LIVE_BAR_EASE_MAX_MS);
3144
+ }
3145
+ function resolveIntro(value) {
3146
+ const off = { style: false, duration: 0 };
3147
+ if (value === true) return { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
3148
+ if (value === "settle" || value === "grow") return { style: value, duration: INTRO_DURATION_DEFAULT_MS };
3149
+ if (value && typeof value === "object") {
3150
+ const o = value;
3151
+ const d = o.duration;
3152
+ return {
3153
+ style: o.style === "grow" ? "grow" : "settle",
3154
+ duration: typeof d === "number" && Number.isFinite(d) && d > 0 ? Math.min(d, INTRO_DURATION_MAX_MS) : INTRO_DURATION_DEFAULT_MS
3155
+ };
3156
+ }
3157
+ return off;
3035
3158
  }
3036
3159
  function resolveAnimations(animations) {
3037
- if (typeof animations === "boolean") return { animZoom: animations, animPan: animations, animLiveBar: 0 };
3160
+ if (animations === false) return { animZoom: 0, animPan: 0, animScroll: 0, animAutoscale: 0, animLiveBar: 0, animIntro: { style: false, duration: 0 } };
3161
+ const cfg = animations === true || animations == null ? {} : animations;
3162
+ const animPan = resolveEaseMs(cfg.pan ?? true, PAN_INERTIA_DEFAULT_MS);
3038
3163
  return {
3039
- animZoom: animations?.zoom ?? true,
3040
- animPan: animations?.pan ?? true,
3041
- animLiveBar: resolveLiveBarEaseMs(animations?.liveBar)
3164
+ animZoom: resolveEaseMs(cfg.zoom ?? true, ZOOM_EASE_DEFAULT_MS),
3165
+ animPan,
3166
+ animScroll: cfg.scroll === void 0 ? animPan > 0 ? SCROLL_EASE_DEFAULT_MS : 0 : resolveEaseMs(cfg.scroll, SCROLL_EASE_DEFAULT_MS),
3167
+ animAutoscale: resolveEaseMs(cfg.autoscale ?? true, AUTOSCALE_EASE_DEFAULT_MS),
3168
+ animLiveBar: resolveLiveBarEaseMs(cfg.liveBar),
3169
+ animIntro: resolveIntro(cfg.intro ?? true)
3042
3170
  };
3043
3171
  }
3044
3172
 
@@ -5080,6 +5208,8 @@ var NATIVE_CAPABILITIES = {
5080
5208
  // canvas-painted into the owning indicator's interleave slice
5081
5209
  trades: true,
5082
5210
  // strategy order-fill markers (arrows + labels + fill-price ticks)
5211
+ timelineMarks: true,
5212
+ // host events on a lane above the time axis (glyphs + detail popup)
5083
5213
  inputsUI: true
5084
5214
  // reuses the DOM InputsUI
5085
5215
  };
@@ -5352,9 +5482,12 @@ function mergeConfig(base, patch) {
5352
5482
  const gh = asObject(grid.horzLines);
5353
5483
  const cross = asObject(p.crosshair);
5354
5484
  const ps = asObject(p.priceScale);
5485
+ const anim = asObject(p.animations);
5355
5486
  const panes = asObject(p.panes);
5356
5487
  const trades = asObject(p.trades);
5357
5488
  const ts = asObject(p.timeScale);
5489
+ const marks = asObject(p.marks);
5490
+ const markGroups = asObject(marks.groups);
5358
5491
  const candles = asObject(p.candles);
5359
5492
  const bars = asObject(p.bars);
5360
5493
  const line = asObject(p.line);
@@ -5401,6 +5534,12 @@ function mergeConfig(base, patch) {
5401
5534
  countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown,
5402
5535
  animateLastPrice: isBool(ps.animateLastPrice) ? ps.animateLastPrice : base.priceScale.animateLastPrice
5403
5536
  },
5537
+ animations: {
5538
+ zoom: isBool(anim.zoom) ? anim.zoom : base.animations.zoom,
5539
+ pan: isBool(anim.pan) ? anim.pan : base.animations.pan,
5540
+ autoscale: isBool(anim.autoscale) ? anim.autoscale : base.animations.autoscale,
5541
+ intro: isBool(anim.intro) ? anim.intro : base.animations.intro
5542
+ },
5404
5543
  panes: {
5405
5544
  separatorColor: isColor(panes.separatorColor) ? panes.separatorColor : base.panes.separatorColor
5406
5545
  },
@@ -5415,6 +5554,15 @@ function mergeConfig(base, patch) {
5415
5554
  timeScale: {
5416
5555
  timezone: typeof ts.timezone === "string" && ts.timezone ? ts.timezone : base.timeScale.timezone
5417
5556
  },
5557
+ marks: {
5558
+ visible: isBool(marks.visible) ? marks.visible : base.marks.visible,
5559
+ // Additive like `stacking.series`: a patch names only the groups it carries, so a
5560
+ // choice stored for a group the host has not registered yet survives verbatim.
5561
+ groups: {
5562
+ ...base.marks.groups,
5563
+ ...Object.fromEntries(Object.entries(markGroups).filter(([, v]) => isBool(v)))
5564
+ }
5565
+ },
5418
5566
  candles: {
5419
5567
  upColor: isColor(candles.upColor) ? candles.upColor : base.candles.upColor,
5420
5568
  downColor: isColor(candles.downColor) ? candles.downColor : base.candles.downColor,
@@ -6933,6 +7081,31 @@ var Animator = class {
6933
7081
  }
6934
7082
  }
6935
7083
  };
7084
+ var EaseSetting = class {
7085
+ /** `defaultMs` is what the on/off switch restores when nothing else was configured;
7086
+ * `initialMs` (default: `defaultMs`) is the starting value — 0 for a motion that
7087
+ * ships off. */
7088
+ constructor(defaultMs, initialMs = defaultMs) {
7089
+ this.onMs = defaultMs;
7090
+ this.ms = initialMs;
7091
+ }
7092
+ /** The active time-constant; 0 when off. */
7093
+ get tau() {
7094
+ return this.ms;
7095
+ }
7096
+ get on() {
7097
+ return this.ms > 0;
7098
+ }
7099
+ /** Set the time-constant (0 = off). A non-zero value becomes what `toggle(true)` restores. */
7100
+ set(ms) {
7101
+ this.ms = ms;
7102
+ if (ms > 0) this.onMs = ms;
7103
+ }
7104
+ /** On/off only — the duration stays the last one configured. */
7105
+ toggle(on) {
7106
+ this.ms = on ? this.onMs : 0;
7107
+ }
7108
+ };
6936
7109
  function easeToward(current, target, dtMs, tauMs) {
6937
7110
  if (tauMs <= 0) return target;
6938
7111
  return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
@@ -7656,6 +7829,25 @@ function drawTextLines(ctx, lines, x, firstY, step, color) {
7656
7829
  for (let i = 0; i < lines.length; i += 1) ctx.fillText(lines[i], x, firstY + i * step);
7657
7830
  }
7658
7831
 
7832
+ // src/renderers/shared/marks-state.ts
7833
+ function defaultMarksState() {
7834
+ return { visible: true, groups: {} };
7835
+ }
7836
+ function mergeMarksState(base, patch) {
7837
+ if (typeof patch === "boolean") return { visible: patch, groups: { ...base.groups } };
7838
+ const p = patch && typeof patch === "object" ? patch : {};
7839
+ const g = p.groups && typeof p.groups === "object" ? p.groups : {};
7840
+ const groups = { ...base.groups };
7841
+ for (const [id, v] of Object.entries(g)) if (typeof v === "boolean") groups[id] = v;
7842
+ return { visible: typeof p.visible === "boolean" ? p.visible : base.visible, groups };
7843
+ }
7844
+ function markGroupVisible(state, groupId, groups) {
7845
+ if (groupId === void 0) return true;
7846
+ const chosen = state.groups[groupId];
7847
+ if (typeof chosen === "boolean") return chosen;
7848
+ return groups.find((g) => g.id === groupId)?.visible !== false;
7849
+ }
7850
+
7659
7851
  // src/renderers/native/core/SceneGraph.ts
7660
7852
  var SceneGraph = class {
7661
7853
  constructor() {
@@ -7719,6 +7911,20 @@ var SceneGraph = class {
7719
7911
  /** Strategy trade-marker display (the `tradeMarkers` feature): master toggle, the
7720
7912
  * two text lines, and the palette. Trade markers always paint on the price pane. */
7721
7913
  this.tradeMarkers = defaultTradeMarkersState();
7914
+ /** Timeline-mark display (the `marks` feature): the lane's master toggle + per-group visibility. */
7915
+ this.marks = defaultMarksState();
7916
+ /** The host's timeline marks + group definitions (`setTimelineMarks`), painted on the lane above the time axis. */
7917
+ this.timelineMarks = [];
7918
+ this.markGroups = [];
7919
+ /** The mark stack (bar index) fanned out by hover or tap, if any. */
7920
+ this.marksExpandedStack = null;
7921
+ /** The lane glyph (cluster key) under the pointer — it pulses — and when the hover began (frame-clock ms). */
7922
+ this.marksHoverKey = null;
7923
+ this.marksHoverSince = 0;
7924
+ /** The cluster whose popup is open: its glyph paints filled ("active"). */
7925
+ this.marksActiveKey = null;
7926
+ /** A content-less click's brief filled flash — the cluster key and the frame-clock time it ends. */
7927
+ this.marksFlash = null;
7722
7928
  /** Renderer-owned shaded time bands (session highlighting), behind grid + data. */
7723
7929
  this.highlights = [];
7724
7930
  /** Pre/post-market bands pushed by the host (`sessionZones` feature); null ⇒ no sessions. */
@@ -9567,6 +9773,315 @@ function timeTicks(fromMs, toMs, target = 8, offsetMs = 0) {
9567
9773
  return out;
9568
9774
  }
9569
9775
 
9776
+ // src/renderers/native/chrome/countdown.ts
9777
+ function countdownText(barOpen, barMs, now) {
9778
+ if (!(barMs > 0)) return null;
9779
+ const remaining = barOpen + barMs - now;
9780
+ if (remaining <= 0) return null;
9781
+ return formatCountdown(remaining);
9782
+ }
9783
+ function formatCountdown(ms) {
9784
+ const total = Math.max(0, Math.ceil(ms / 1e3));
9785
+ const s = total % 60;
9786
+ const m = Math.floor(total / 60) % 60;
9787
+ const h = Math.floor(total / 3600);
9788
+ const pad = (v) => String(v).padStart(2, "0");
9789
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
9790
+ }
9791
+
9792
+ // src/renderers/native/chrome/contrast.ts
9793
+ function tagTextColor(bg, over) {
9794
+ const [r, g, b, a] = parseColor(bg);
9795
+ let R = r;
9796
+ let G = g;
9797
+ let B = b;
9798
+ if (a < 1) {
9799
+ const [or, og, ob] = parseColor(over);
9800
+ R = r * a + or * (1 - a);
9801
+ G = g * a + og * (1 - a);
9802
+ B = b * a + ob * (1 - a);
9803
+ }
9804
+ const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
9805
+ const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
9806
+ return L >= 0.4 ? "#000000" : "#ffffff";
9807
+ }
9808
+
9809
+ // src/renderers/native/chrome/marks/layout.ts
9810
+ var MARK_GLYPH_PX = 16;
9811
+ var MARK_CLUSTER_PX = 20;
9812
+ var MARK_LANE_INSET = 4;
9813
+ var MARK_DECK_STEP = 3;
9814
+ var MARK_FAN_GAP = 4;
9815
+ var MARK_HIT_PAD = 3;
9816
+ var MARK_FAN_HOLD = 8;
9817
+ function snapMarkBar(time, barTimes, intervalMs2) {
9818
+ const n = barTimes.length;
9819
+ if (n === 0 || !(intervalMs2 > 0) || !Number.isFinite(time)) return null;
9820
+ if (time < barTimes[0]) return null;
9821
+ const last = barTimes[n - 1];
9822
+ if (time >= last + intervalMs2) return n - 1 + Math.floor((time - last) / intervalMs2);
9823
+ let lo = 0;
9824
+ let hi = n - 1;
9825
+ while (lo < hi) {
9826
+ const mid = lo + hi + 1 >> 1;
9827
+ if (barTimes[mid] <= time) lo = mid;
9828
+ else hi = mid - 1;
9829
+ }
9830
+ if (time < barTimes[lo] + intervalMs2) return lo;
9831
+ return lo + 1;
9832
+ }
9833
+ function clusterMarks(marks, barTimes, intervalMs2, hidden) {
9834
+ const byKey = /* @__PURE__ */ new Map();
9835
+ marks.forEach((m, seq) => {
9836
+ if (m.group !== void 0 && hidden(m.group)) return;
9837
+ const bar = snapMarkBar(m.time, barTimes, intervalMs2);
9838
+ if (bar === null) return;
9839
+ const key = `${bar}|${m.group ?? ""}`;
9840
+ let c = byKey.get(key);
9841
+ if (!c) {
9842
+ c = { key, bar, group: m.group, marks: [], seq: [] };
9843
+ byKey.set(key, c);
9844
+ }
9845
+ c.marks.push(m);
9846
+ c.seq.push(seq);
9847
+ });
9848
+ const out = [];
9849
+ for (const c of byKey.values()) {
9850
+ 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);
9851
+ out.push({ key: c.key, bar: c.bar, group: c.group, marks: order.map((o) => o.m) });
9852
+ }
9853
+ return out;
9854
+ }
9855
+ function groupRank(groups, clusters) {
9856
+ const rank = /* @__PURE__ */ new Map();
9857
+ groups.forEach((g, i) => rank.set(g.id, i));
9858
+ for (const c of clusters) {
9859
+ if (c.group !== void 0 && !rank.has(c.group)) rank.set(c.group, rank.size);
9860
+ }
9861
+ return (group) => group === void 0 ? Number.MAX_SAFE_INTEGER : rank.get(group) ?? Number.MAX_SAFE_INTEGER - 1;
9862
+ }
9863
+ function layoutMarkLane(input) {
9864
+ const clusters = clusterMarks(input.marks, input.barTimes, input.intervalMs, input.hidden);
9865
+ const rankOf = groupRank(input.groups, clusters);
9866
+ const byBar = /* @__PURE__ */ new Map();
9867
+ for (const c of clusters) {
9868
+ const list = byBar.get(c.bar);
9869
+ if (list) list.push(c);
9870
+ else byBar.set(c.bar, [c]);
9871
+ }
9872
+ const glyphs = [];
9873
+ const stacks = /* @__PURE__ */ new Map();
9874
+ for (const [bar, list] of byBar) {
9875
+ const x = input.xOf(bar);
9876
+ if (!Number.isFinite(x) || x < -MARK_CLUSTER_PX || x > input.dataW + MARK_CLUSTER_PX) continue;
9877
+ list.sort((a, b) => rankOf(a.group) - rankOf(b.group));
9878
+ const multi = list.length > 1;
9879
+ const expanded = multi && input.expanded === bar;
9880
+ const decked = multi && !expanded;
9881
+ const placed = [];
9882
+ const deckSize = list[0].marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
9883
+ let bottom = input.axisY - MARK_LANE_INSET;
9884
+ list.forEach((cluster, depth) => {
9885
+ const size = decked ? deckSize : cluster.marks.length > 1 ? MARK_CLUSTER_PX : MARK_GLYPH_PX;
9886
+ let y;
9887
+ if (expanded) {
9888
+ y = bottom - size / 2;
9889
+ bottom -= size + MARK_FAN_GAP;
9890
+ } else {
9891
+ y = input.axisY - MARK_LANE_INSET - size / 2 - depth * MARK_DECK_STEP;
9892
+ }
9893
+ placed.push({ cluster, x, y, size, stack: bar, depth, decked });
9894
+ });
9895
+ for (let i = placed.length - 1; i >= 0; i--) glyphs.push(placed[i]);
9896
+ stacks.set(bar, placed);
9897
+ }
9898
+ return { glyphs, stacks };
9899
+ }
9900
+ function markGlyphAt(layout, x, y) {
9901
+ for (let i = layout.glyphs.length - 1; i >= 0; i--) {
9902
+ const g = layout.glyphs[i];
9903
+ if (g.decked && g.depth !== 0) continue;
9904
+ const r = g.size / 2 + MARK_HIT_PAD;
9905
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return g;
9906
+ }
9907
+ return null;
9908
+ }
9909
+ function markStackAt(layout, x, y) {
9910
+ for (const [bar, placed] of layout.stacks) {
9911
+ for (const g of placed) {
9912
+ const r = g.size / 2 + MARK_HIT_PAD;
9913
+ if (Math.abs(x - g.x) <= r && Math.abs(y - g.y) <= r) return bar;
9914
+ }
9915
+ if (placed.length > 1 && !placed[0].decked) {
9916
+ const top = placed[placed.length - 1];
9917
+ const base = placed[0];
9918
+ const r = Math.max(top.size, base.size) / 2 + MARK_HIT_PAD;
9919
+ 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;
9920
+ }
9921
+ }
9922
+ return null;
9923
+ }
9924
+ function clusterTooltip(cluster, groups) {
9925
+ const first = cluster.marks[0];
9926
+ if (!first) return null;
9927
+ if (cluster.marks.length === 1) return first.tooltip ?? first.title ?? null;
9928
+ const label = cluster.group !== void 0 ? markGroupLabel(cluster.group, groups) : first.title ?? first.tooltip ?? "Marks";
9929
+ return `${label} \xB7 ${cluster.marks.length}`;
9930
+ }
9931
+ function markGroupLabel(groupId, groups) {
9932
+ const def = groups.find((g) => g.id === groupId);
9933
+ if (def) return def.label;
9934
+ return groupId.charAt(0).toUpperCase() + groupId.slice(1);
9935
+ }
9936
+ function effectiveMarkGroups(marks, groups) {
9937
+ const out = groups.map((g) => ({ ...g }));
9938
+ const seen = new Set(out.map((g) => g.id));
9939
+ for (const m of marks) {
9940
+ if (m.group === void 0 || seen.has(m.group)) continue;
9941
+ seen.add(m.group);
9942
+ out.push({ id: m.group, label: markGroupLabel(m.group, groups) });
9943
+ }
9944
+ return out;
9945
+ }
9946
+
9947
+ // src/renderers/native/chrome/marks/paint.ts
9948
+ var MARK_PULSE_MS = 360;
9949
+ var MARK_PULSE_AMPLITUDE = 0.1;
9950
+ var ACTIVE_INK = "#ffffff";
9951
+ function pulseScale(elapsedMs) {
9952
+ if (!(elapsedMs > 0) || elapsedMs >= MARK_PULSE_MS) return 1;
9953
+ return 1 + MARK_PULSE_AMPLITUDE * Math.sin(Math.PI * elapsedMs / MARK_PULSE_MS);
9954
+ }
9955
+ function paintMarkLane(ctx, layout, deps) {
9956
+ if (layout.glyphs.length === 0) return;
9957
+ ctx.save();
9958
+ ctx.setLineDash([]);
9959
+ ctx.textAlign = "center";
9960
+ ctx.textBaseline = "middle";
9961
+ for (const g of layout.glyphs) {
9962
+ const mark = g.cluster.marks[0];
9963
+ if (!mark) continue;
9964
+ const key = g.cluster.key;
9965
+ const color = mark.glyph.color;
9966
+ const active = key === deps.activeKey || key === deps.flashKey;
9967
+ const size = g.size * (key === deps.hoverKey ? pulseScale(deps.nowMs - deps.hoverSince) : 1);
9968
+ if (g.depth === 0) {
9969
+ const sx = Math.round(g.x) + 0.5;
9970
+ ctx.lineWidth = 1;
9971
+ ctx.strokeStyle = deps.stemColor;
9972
+ ctx.beginPath();
9973
+ ctx.moveTo(sx, g.y + g.size / 2);
9974
+ ctx.lineTo(sx, deps.axisY);
9975
+ ctx.stroke();
9976
+ }
9977
+ const shape = mark.glyph.shape ?? "circle";
9978
+ const center = traceShape(ctx, shape, g.x, g.y, size);
9979
+ ctx.lineWidth = 4;
9980
+ ctx.strokeStyle = deps.background;
9981
+ ctx.stroke();
9982
+ ctx.fillStyle = active ? color : deps.background;
9983
+ ctx.fill();
9984
+ ctx.lineWidth = 1.5;
9985
+ ctx.strokeStyle = color;
9986
+ ctx.stroke();
9987
+ const ink = active ? ACTIVE_INK : color;
9988
+ const symbolPx = Math.round(size * 0.62);
9989
+ if (mark.glyph.icon) {
9990
+ const img = deps.icons.get(mark.glyph.icon, ink, symbolPx, deps.dpr);
9991
+ if (img) ctx.drawImage(img, center.x - symbolPx / 2, center.y - symbolPx / 2, symbolPx, symbolPx);
9992
+ } else if (mark.glyph.letter) {
9993
+ ctx.fillStyle = ink;
9994
+ ctx.font = `600 ${Math.round(size * 0.58)}px ${deps.fontFamily}`;
9995
+ ctx.fillText(mark.glyph.letter.slice(0, 2), center.x, center.y + 0.5);
9996
+ }
9997
+ }
9998
+ ctx.restore();
9999
+ }
10000
+ function traceShape(ctx, shape, x, y, size) {
10001
+ const r = size / 2;
10002
+ ctx.beginPath();
10003
+ switch (shape) {
10004
+ case "square": {
10005
+ const c = Math.min(3, r / 2);
10006
+ roundedRect(ctx, x - r, y - r, size, size, c);
10007
+ return { x, y };
10008
+ }
10009
+ case "diamond":
10010
+ ctx.moveTo(x, y - r);
10011
+ ctx.lineTo(x + r, y);
10012
+ ctx.lineTo(x, y + r);
10013
+ ctx.lineTo(x - r, y);
10014
+ ctx.closePath();
10015
+ return { x, y };
10016
+ case "pin": {
10017
+ const hr = r * 0.82;
10018
+ const hy = y - r + hr;
10019
+ ctx.arc(x, hy, hr, Math.PI * 0.75, Math.PI * 0.25, false);
10020
+ ctx.lineTo(x, y + r);
10021
+ ctx.closePath();
10022
+ return { x, y: hy };
10023
+ }
10024
+ case "circle":
10025
+ default:
10026
+ ctx.arc(x, y, r, 0, Math.PI * 2);
10027
+ return { x, y };
10028
+ }
10029
+ }
10030
+ function roundedRect(ctx, x, y, w, h, radius) {
10031
+ ctx.moveTo(x + radius, y);
10032
+ ctx.lineTo(x + w - radius, y);
10033
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
10034
+ ctx.lineTo(x + w, y + h - radius);
10035
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
10036
+ ctx.lineTo(x + radius, y + h);
10037
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
10038
+ ctx.lineTo(x, y + radius);
10039
+ ctx.quadraticCurveTo(x, y, x + radius, y);
10040
+ ctx.closePath();
10041
+ }
10042
+ var MarkIconRaster = class {
10043
+ constructor(onReady) {
10044
+ this.onReady = onReady;
10045
+ this.cache = /* @__PURE__ */ new Map();
10046
+ }
10047
+ get(icon2, ink, px, dpr) {
10048
+ const key = `${icon2}|${ink}|${px}|${dpr}`;
10049
+ if (this.cache.has(key)) {
10050
+ const img2 = this.cache.get(key);
10051
+ return img2 && img2.complete && img2.naturalWidth > 0 ? img2 : null;
10052
+ }
10053
+ const markup = iconMarkup(icon2);
10054
+ if (!markup || typeof document === "undefined" || typeof Image === "undefined" || typeof XMLSerializer === "undefined") {
10055
+ this.cache.set(key, null);
10056
+ return null;
10057
+ }
10058
+ const svg = standaloneSvg(markup, ink, Math.max(1, Math.ceil(px * dpr)));
10059
+ if (!svg) {
10060
+ this.cache.set(key, null);
10061
+ return null;
10062
+ }
10063
+ const img = new Image();
10064
+ img.onload = () => this.onReady();
10065
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
10066
+ this.cache.set(key, img);
10067
+ return null;
10068
+ }
10069
+ clear() {
10070
+ this.cache.clear();
10071
+ }
10072
+ };
10073
+ function standaloneSvg(markup, ink, px) {
10074
+ const tpl = document.createElement("template");
10075
+ tpl.innerHTML = markup;
10076
+ const svg = tpl.content.firstElementChild;
10077
+ if (!svg || svg.tagName.toLowerCase() !== "svg") return null;
10078
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
10079
+ svg.setAttribute("width", String(px));
10080
+ svg.setAttribute("height", String(px));
10081
+ svg.setAttribute("color", ink);
10082
+ return new XMLSerializer().serializeToString(svg);
10083
+ }
10084
+
9570
10085
  // src/renderers/native/chrome/ChromeRenderer.ts
9571
10086
  var ChromeRenderer = class {
9572
10087
  constructor() {
@@ -9576,11 +10091,40 @@ var ChromeRenderer = class {
9576
10091
  this.axisTextColor = DARK_THEME.textColor;
9577
10092
  // Shared Pine-drawing renderer, used here for autoscale geometry only; widthCache persists.
9578
10093
  this.drawScene = new DrawingSceneRenderer({ timeToLogical: () => 0, barAt: () => null, theme: {} });
10094
+ /** The timeline-mark lane as laid out by the last frame — what hover/click hit-test against. */
10095
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
10096
+ /** Registry icons rasterized for the lane; the owner is asked for a chrome repaint when one lands. */
10097
+ this.markIcons = new MarkIconRaster(() => this.onMarkIconReady?.());
10098
+ this.onMarkIconReady = null;
10099
+ /** Bar open times of the current series, rebuilt only when the array or its length changes (a live tick keeps both). */
10100
+ this.barTimesSrc = null;
10101
+ this.barTimesCache = [];
9579
10102
  }
9580
10103
  mount(canvas) {
9581
10104
  this.canvas = canvas;
9582
10105
  this.ctx = canvas.getContext("2d");
9583
10106
  }
10107
+ /** Where to ask for a chrome repaint when a lane icon finishes rasterizing. */
10108
+ setMarkIconReady(cb) {
10109
+ this.onMarkIconReady = cb;
10110
+ }
10111
+ /** The interactive mark glyph under a plot point (last frame's layout), or null. */
10112
+ markGlyphAt(x, y) {
10113
+ return markGlyphAt(this.markLayout, x, y);
10114
+ }
10115
+ /** The mark stack (bar index) whose glyphs — or the gaps of its fan — cover a plot point. */
10116
+ markStackAt(x, y) {
10117
+ return markStackAt(this.markLayout, x, y);
10118
+ }
10119
+ /** A glyph of the last frame by its cluster key — how an open popup follows its anchor. */
10120
+ markGlyphByKey(key) {
10121
+ return this.markLayout.glyphs.find((g) => g.cluster.key === key) ?? null;
10122
+ }
10123
+ /** Hover text of the mark glyph under a plot point, or null. */
10124
+ markTooltipAt(x, y, groups) {
10125
+ const g = this.markGlyphAt(x, y);
10126
+ return g ? clusterTooltip(g.cluster, groups) : null;
10127
+ }
9584
10128
  /** Wire the drawing coordinate resolvers + theme (call once per frame before use). */
9585
10129
  prepare(scene, coords, theme) {
9586
10130
  this.drawScene.setDeps({
@@ -9629,6 +10173,7 @@ var ChromeRenderer = class {
9629
10173
  const panes = scene.orderedPanes();
9630
10174
  if (coords.barCount === 0) {
9631
10175
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
10176
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
9632
10177
  return;
9633
10178
  }
9634
10179
  const pricePane = panes.find((p) => p.kind === "price") ?? null;
@@ -9642,6 +10187,46 @@ var ChromeRenderer = class {
9642
10187
  this.drawPaneSeparators(ctx, scene, theme, fullW, panes);
9643
10188
  this.drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane);
9644
10189
  this.drawTimeAxis(ctx, scene, coords, theme, dataW, dataH, fullH);
10190
+ this.drawMarkLane(ctx, scene, coords, theme, dataW, dataH);
10191
+ }
10192
+ /** The timeline-mark lane — after the axis, so the tokens read over the plot's bottom edge. */
10193
+ drawMarkLane(ctx, scene, coords, theme, dataW, dataH) {
10194
+ if (!scene.marks.visible || scene.timelineMarks.length === 0) {
10195
+ this.markLayout = { glyphs: [], stacks: /* @__PURE__ */ new Map() };
10196
+ return;
10197
+ }
10198
+ this.markLayout = layoutMarkLane({
10199
+ marks: scene.timelineMarks,
10200
+ groups: scene.markGroups,
10201
+ hidden: (groupId) => !markGroupVisible(scene.marks, groupId, scene.markGroups),
10202
+ barTimes: this.barTimes(scene),
10203
+ intervalMs: coords.barInterval,
10204
+ xOf: (bar) => coords.logicalToX(bar),
10205
+ axisY: dataH,
10206
+ dataW,
10207
+ expanded: scene.marksExpandedStack
10208
+ });
10209
+ const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
10210
+ paintMarkLane(ctx, this.markLayout, {
10211
+ axisY: dataH,
10212
+ background: theme.background,
10213
+ stemColor: scene.style.borderColor ?? theme.borderColor,
10214
+ fontFamily: theme.fontFamily,
10215
+ dpr: coords.dpr,
10216
+ icons: this.markIcons,
10217
+ hoverKey: scene.marksHoverKey,
10218
+ hoverSince: scene.marksHoverSince,
10219
+ activeKey: scene.marksActiveKey,
10220
+ flashKey: scene.marksFlash && scene.marksFlash.until > nowMs ? scene.marksFlash.key : null,
10221
+ nowMs
10222
+ });
10223
+ }
10224
+ barTimes(scene) {
10225
+ if (this.barTimesSrc !== scene.bars || this.barTimesCache.length !== scene.bars.length) {
10226
+ this.barTimesSrc = scene.bars;
10227
+ this.barTimesCache = scene.bars.map((b) => b.time);
10228
+ }
10229
+ return this.barTimesCache;
9645
10230
  }
9646
10231
  destroy() {
9647
10232
  this.canvas = null;
@@ -9757,7 +10342,8 @@ var ChromeRenderer = class {
9757
10342
  * - the countdown-to-bar-close chip (`showCountdown`).
9758
10343
  * When the label and countdown are both on they merge into one stacked block (countdown
9759
10344
  * under the label, text flushed left); a lone label or countdown is centered on the
9760
- * price level with centered text. The countdown ticks once per second (repaint scheduled).
10345
+ * price level with centered text. The countdown repaints on the renderer's second pulse
10346
+ * and disappears once the bar has closed, until the next bar arrives.
9761
10347
  */
9762
10348
  drawPriceLineAndCountdown(ctx, scene, coords, theme, dataW, pricePane) {
9763
10349
  const n = scene.bars.length;
@@ -9777,17 +10363,16 @@ var ChromeRenderer = class {
9777
10363
  ctx.stroke();
9778
10364
  setDash2(ctx, "solid");
9779
10365
  }
9780
- const interval = coords.barInterval;
9781
- const showCountdown = scene.showCountdown && interval > 0;
10366
+ const cdText = scene.showCountdown ? countdownText(last.time, coords.barInterval, Date.now()) : null;
10367
+ const showCountdown = cdText !== null;
9782
10368
  const showLabel = scene.showPriceLabel;
9783
10369
  if (!showLabel && !showCountdown) return;
9784
10370
  const priceText = formatAxisValue(pricePane.scale, pricePane.bounds.height, last.close, percentScaleFor(scene, pricePane), scene.priceMintick);
9785
- const cdText = showCountdown ? formatCountdown(last.time + interval - Date.now()) : "";
9786
10371
  const PAD = 8;
9787
10372
  const x = dataW + 1;
9788
10373
  const textColor = tagTextColor(color, theme.background);
9789
10374
  ctx.textBaseline = "middle";
9790
- if (showLabel && showCountdown) {
10375
+ if (showLabel && cdText !== null) {
9791
10376
  const w2 = Math.max(ctx.measureText(priceText).width, ctx.measureText(cdText).width) + PAD;
9792
10377
  const top = y - 8;
9793
10378
  const tx = x + PAD / 2;
@@ -9800,7 +10385,7 @@ var ChromeRenderer = class {
9800
10385
  ctx.textAlign = "start";
9801
10386
  return;
9802
10387
  }
9803
- const text = showLabel ? priceText : cdText;
10388
+ const text = showLabel ? priceText : cdText ?? "";
9804
10389
  const w = ctx.measureText(text).width + PAD;
9805
10390
  ctx.fillStyle = color;
9806
10391
  ctx.fillRect(x, y - 8, w, 16);
@@ -9871,29 +10456,6 @@ function setDash2(ctx, style) {
9871
10456
  else if (style === "dotted") ctx.setLineDash([2, 3]);
9872
10457
  else ctx.setLineDash([]);
9873
10458
  }
9874
- function tagTextColor(bg, over) {
9875
- const [r, g, b, a] = parseColor(bg);
9876
- let R = r;
9877
- let G = g;
9878
- let B = b;
9879
- if (a < 1) {
9880
- const [or, og, ob] = parseColor(over);
9881
- R = r * a + or * (1 - a);
9882
- G = g * a + og * (1 - a);
9883
- B = b * a + ob * (1 - a);
9884
- }
9885
- const lin = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
9886
- const L = 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
9887
- return L >= 0.4 ? "#000000" : "#ffffff";
9888
- }
9889
- function formatCountdown(ms) {
9890
- const total = Math.max(0, Math.floor(ms / 1e3));
9891
- const s = total % 60;
9892
- const m = Math.floor(total / 60) % 60;
9893
- const h = Math.floor(total / 3600);
9894
- const pad = (v) => String(v).padStart(2, "0");
9895
- return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
9896
- }
9897
10459
 
9898
10460
  // src/renderers/native/chrome/LabelTooltip.ts
9899
10461
  var HOVER_DELAY_MS = 350;
@@ -10099,6 +10661,11 @@ function setDash3(ctx, style) {
10099
10661
  function settingsIdSlug(label) {
10100
10662
  return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
10101
10663
  }
10664
+ var MARKS_SETTINGS_ID = "events";
10665
+ var MARKS_GROUPS_SETTINGS_ID = "events.groups";
10666
+ function markGroupSettingsId(groupId) {
10667
+ return `${MARKS_GROUPS_SETTINGS_ID}.${settingsIdSlug(groupId)}`;
10668
+ }
10102
10669
  function settingsIdHidden(id, hidden) {
10103
10670
  if (hidden.size === 0) return false;
10104
10671
  let path = id;
@@ -10192,7 +10759,11 @@ var BUILTIN_SETTINGS_IDS = [
10192
10759
  "symbol.style.baseline.base-level",
10193
10760
  "symbol.style.baseline.width",
10194
10761
  "symbol.animation",
10762
+ "symbol.animation.zoom",
10763
+ "symbol.animation.pan",
10764
+ "symbol.animation.autoscale",
10195
10765
  "symbol.animation.price-changes",
10766
+ "symbol.animation.intro",
10196
10767
  "symbol.timezone",
10197
10768
  "scales",
10198
10769
  "scales.price-scale",
@@ -10218,8 +10789,13 @@ var BUILTIN_SETTINGS_IDS = [
10218
10789
  "canvas.grid.horizontal",
10219
10790
  "canvas.theme"
10220
10791
  ];
10221
- function settingsIdCatalog(hostSections) {
10792
+ function settingsIdCatalog(hostSections, markGroups = []) {
10222
10793
  const ids = new Set(BUILTIN_SETTINGS_IDS);
10794
+ if (markGroups.length > 0) {
10795
+ ids.add(MARKS_SETTINGS_ID);
10796
+ ids.add(MARKS_GROUPS_SETTINGS_ID);
10797
+ for (const g of markGroups) ids.add(markGroupSettingsId(g.id));
10798
+ }
10223
10799
  for (const def of chartTypes()) {
10224
10800
  if (hasOwnCandlePaint(def.id)) {
10225
10801
  const style = `symbol.style.${def.id}`;
@@ -10259,7 +10835,7 @@ function styleLabel(id) {
10259
10835
  return chartType(id)?.label ?? BUILTIN_STYLE_LABELS[id] ?? id;
10260
10836
  }
10261
10837
  var SD_STYLE_ID = "vela-settings-controls";
10262
- var SD_STYLE_REV = "5";
10838
+ var SD_STYLE_REV = "6";
10263
10839
  var SETTINGS_BORDER = "var(--vela-border)";
10264
10840
  function ensureControlStyles() {
10265
10841
  if (typeof document === "undefined") return;
@@ -10335,7 +10911,15 @@ ${overlayScrollbarCss(".vela-sd-pane")}
10335
10911
  .vela-sd-mobile .vela-select-trigger,.vela-sd-mobile .vela-num input,.vela-sd-mobile .vela-width-field{height:34px;}
10336
10912
  .vela-sd-mobile .vela-sd-close{width:40px;height:40px;}
10337
10913
  .vela-sd-mobile .vela-sd-btn{height:38px;}
10338
- .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}`;
10914
+ .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}
10915
+ /* The wrap rule above is for ROW LABELS only: a select's closed value must keep its
10916
+ single-line ellipsis, or a long option wraps to several lines inside the 34px
10917
+ trigger and spills over the rows around it. Three classes so it outranks the
10918
+ two-classes-plus-element selector above. The kit's fixed 100px column is a desktop
10919
+ alignment device; on mobile the trigger hugs its value instead (the grid's control
10920
+ column is max-content), capped so a long option still ellipsizes before the label. */
10921
+ .vela-sd-mobile .vela-select-trigger .vela-select-label{white-space:nowrap !important;}
10922
+ .vela-sd-mobile .vela-select:not([data-fill]){width:auto;min-width:100px;max-width:min(220px,55vw);}`;
10339
10923
  if (!existing) document.head.appendChild(st);
10340
10924
  }
10341
10925
  var SettingsDialog = class {
@@ -10350,6 +10934,9 @@ var SettingsDialog = class {
10350
10934
  this.config = null;
10351
10935
  this.syncTypeTabs = null;
10352
10936
  this.hostSections = [];
10937
+ /** The timeline-mark groups (defined + named by marks) — one checkbox each on the Events tab. */
10938
+ this.markGroups = [];
10939
+ this.markGroupVisible = () => true;
10353
10940
  /** The Canvas → Theme row: current app theme + where a pick is raised. The row is a
10354
10941
  * host callback, NOT a config patch — the app theme stays out of the persisted
10355
10942
  * `ChartConfig`, so exported templates never carry it. */
@@ -10374,6 +10961,11 @@ var SettingsDialog = class {
10374
10961
  setHostSections(sections) {
10375
10962
  this.hostSections = sections;
10376
10963
  }
10964
+ /** The timeline-mark groups and their current visibility — the Events tab's rows on next open. */
10965
+ setMarkGroups(groups, visible) {
10966
+ this.markGroups = groups;
10967
+ this.markGroupVisible = visible;
10968
+ }
10377
10969
  /** Replace the visibility policy — an open dialog rebuilds in place to honor it. */
10378
10970
  setHiddenSettings(ids) {
10379
10971
  const next = new Set(ids);
@@ -10547,12 +11139,36 @@ var SettingsDialog = class {
10547
11139
  }
10548
11140
  showActive(config.series.style);
10549
11141
  body.append(sid(this.sectionTitle("Animation"), "symbol.animation"));
11142
+ body.append(sid(this.boolRow(
11143
+ "Animate zoom",
11144
+ config.animations.zoom,
11145
+ (v) => this.emit({ animations: { zoom: v } }),
11146
+ this.hint("Glide the chart to each zoom step instead of jumping.")
11147
+ ), "symbol.animation.zoom"));
11148
+ body.append(sid(this.boolRow(
11149
+ "Pan momentum",
11150
+ config.animations.pan,
11151
+ (v) => this.emit({ animations: { pan: v } }),
11152
+ this.hint("Keep gliding briefly after a drag release, and ease scroll-to-latest and keyboard pans.")
11153
+ ), "symbol.animation.pan"));
11154
+ body.append(sid(this.boolRow(
11155
+ "Animate price scale",
11156
+ config.animations.autoscale,
11157
+ (v) => this.emit({ animations: { autoscale: v } }),
11158
+ this.hint("Glide the price scale to its new range while zooming or panning.")
11159
+ ), "symbol.animation.autoscale"));
10550
11160
  body.append(sid(this.boolRow(
10551
11161
  "Animate price changes",
10552
11162
  config.priceScale.animateLastPrice,
10553
11163
  (v) => this.emit({ priceScale: { animateLastPrice: v } }),
10554
11164
  this.hint("Glide the live bar to each new price instead of snapping.")
10555
11165
  ), "symbol.animation.price-changes"));
11166
+ body.append(sid(this.boolRow(
11167
+ "Reveal on load",
11168
+ config.animations.intro,
11169
+ (v) => this.emit({ animations: { intro: v } }),
11170
+ this.hint("Draw the candles in when a chart first loads. Takes effect on the next load.")
11171
+ ), "symbol.animation.intro"));
10556
11172
  body.append(sid(this.sectionTitle("Time zone"), "symbol.timezone"));
10557
11173
  body.append(sid(this.selectRowLabeled("Time zone", normalizeTimezone(config.timeScale.timezone), timezoneOptions(config.timeScale.timezone), (v) => this.emit({ timeScale: { timezone: v } })), "symbol.timezone"));
10558
11174
  const renderHostSections = (placement) => {
@@ -10622,6 +11238,13 @@ var SettingsDialog = class {
10622
11238
  body.append(sid(this.sectionTitle("Theme"), "canvas.theme"));
10623
11239
  body.append(sid(this.selectRow("Color theme", tc.current === "dark" ? "Dark" : "Light", ["Dark", "Light"], (v) => tc.onSelect(v === "Dark" ? "dark" : "light")), "canvas.theme"));
10624
11240
  }
11241
+ if (this.markGroups.length > 0) {
11242
+ body.append(sid(this.section("Events"), MARKS_SETTINGS_ID));
11243
+ body.append(sid(this.sectionTitle("Visible events"), MARKS_GROUPS_SETTINGS_ID));
11244
+ for (const g of this.markGroups) {
11245
+ body.append(sid(this.boolRow(g.label, this.markGroupVisible(g.id), (v) => this.emit({ marks: { groups: { [g.id]: v } } })), markGroupSettingsId(g.id)));
11246
+ }
11247
+ }
10625
11248
  renderChartTypeSections("end");
10626
11249
  renderHostSections("end");
10627
11250
  if (this.hiddenSettings.size > 0) {
@@ -16193,6 +16816,383 @@ function expandScaleByPixels(scale, heightPx, abovePx, belowPx) {
16193
16816
  return { ...scale, min: scale.min - belowPx * perPx, max: scale.max + abovePx * perPx };
16194
16817
  }
16195
16818
 
16819
+ // src/ui/sanitize-html.ts
16820
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
16821
+ "a",
16822
+ "abbr",
16823
+ "b",
16824
+ "blockquote",
16825
+ "br",
16826
+ "code",
16827
+ "dd",
16828
+ "del",
16829
+ "div",
16830
+ "dl",
16831
+ "dt",
16832
+ "em",
16833
+ "h1",
16834
+ "h2",
16835
+ "h3",
16836
+ "h4",
16837
+ "h5",
16838
+ "h6",
16839
+ "hr",
16840
+ "i",
16841
+ "img",
16842
+ "ins",
16843
+ "kbd",
16844
+ "li",
16845
+ "mark",
16846
+ "ol",
16847
+ "p",
16848
+ "pre",
16849
+ "q",
16850
+ "s",
16851
+ "small",
16852
+ "span",
16853
+ "strong",
16854
+ "sub",
16855
+ "sup",
16856
+ "table",
16857
+ "tbody",
16858
+ "td",
16859
+ "tfoot",
16860
+ "th",
16861
+ "thead",
16862
+ "tr",
16863
+ "u",
16864
+ "ul"
16865
+ ]);
16866
+ var DROPPED_TAGS = /* @__PURE__ */ new Set([
16867
+ "script",
16868
+ "style",
16869
+ "iframe",
16870
+ "frame",
16871
+ "frameset",
16872
+ "object",
16873
+ "embed",
16874
+ "applet",
16875
+ "form",
16876
+ "input",
16877
+ "textarea",
16878
+ "button",
16879
+ "select",
16880
+ "option",
16881
+ "link",
16882
+ "meta",
16883
+ "base",
16884
+ "svg",
16885
+ "math",
16886
+ "template",
16887
+ "noscript",
16888
+ "audio",
16889
+ "video",
16890
+ "canvas",
16891
+ "dialog",
16892
+ "head",
16893
+ "title"
16894
+ ]);
16895
+ var ALLOWED_ATTRS = {
16896
+ a: /* @__PURE__ */ new Set(["href"]),
16897
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height"]),
16898
+ td: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
16899
+ th: /* @__PURE__ */ new Set(["colspan", "rowspan"]),
16900
+ ol: /* @__PURE__ */ new Set(["start"])
16901
+ };
16902
+ var BLOCKED_SCHEMES = /* @__PURE__ */ new Set(["javascript", "vbscript", "data", "file", "blob"]);
16903
+ function tagDisposition(tag) {
16904
+ const t = tag.toLowerCase();
16905
+ if (DROPPED_TAGS.has(t)) return "drop";
16906
+ return ALLOWED_TAGS.has(t) ? "keep" : "unwrap";
16907
+ }
16908
+ function attributeAllowed(tag, name) {
16909
+ const n = name.toLowerCase();
16910
+ if (n.startsWith("on") || n === "style") return false;
16911
+ if (n === "title") return true;
16912
+ return ALLOWED_ATTRS[tag.toLowerCase()]?.has(n) ?? false;
16913
+ }
16914
+ function safeUrl(value, absoluteOnly = false) {
16915
+ let url = "";
16916
+ for (const ch of value) if (ch.charCodeAt(0) > 32) url += ch;
16917
+ const m = /^([a-z][a-z0-9+.-]*):/i.exec(url);
16918
+ const scheme = m ? m[1].toLowerCase() : null;
16919
+ if (scheme !== null && BLOCKED_SCHEMES.has(scheme)) return null;
16920
+ if (absoluteOnly && scheme !== "http" && scheme !== "https") return null;
16921
+ return url;
16922
+ }
16923
+ var ELEMENT_NODE = 1;
16924
+ var TEXT_NODE = 3;
16925
+ function sanitizeHtml(html, doc) {
16926
+ const tpl = doc.createElement("template");
16927
+ tpl.innerHTML = html;
16928
+ const out = doc.createDocumentFragment();
16929
+ copyChildren(tpl.content, out, doc);
16930
+ return out;
16931
+ }
16932
+ function copyChildren(from, to, doc) {
16933
+ for (const child of Array.from(from.childNodes)) {
16934
+ if (child.nodeType === TEXT_NODE) {
16935
+ to.appendChild(doc.createTextNode(child.textContent ?? ""));
16936
+ continue;
16937
+ }
16938
+ if (child.nodeType !== ELEMENT_NODE) continue;
16939
+ const el = child;
16940
+ const tag = el.tagName.toLowerCase();
16941
+ const disposition = tagDisposition(tag);
16942
+ if (disposition === "drop") continue;
16943
+ if (disposition === "unwrap") {
16944
+ copyChildren(el, to, doc);
16945
+ continue;
16946
+ }
16947
+ const clean = doc.createElement(tag);
16948
+ for (const attr of Array.from(el.attributes)) {
16949
+ const name = attr.name.toLowerCase();
16950
+ if (!attributeAllowed(tag, name)) continue;
16951
+ let value = attr.value;
16952
+ if (name === "href" || name === "src") {
16953
+ const safe = safeUrl(value, name === "src");
16954
+ if (safe === null) continue;
16955
+ value = safe;
16956
+ }
16957
+ clean.setAttribute(name, value);
16958
+ }
16959
+ if (tag === "a") {
16960
+ clean.setAttribute("target", "_blank");
16961
+ clean.setAttribute("rel", "noopener noreferrer");
16962
+ }
16963
+ copyChildren(el, clean, doc);
16964
+ to.appendChild(clean);
16965
+ }
16966
+ }
16967
+
16968
+ // src/renderers/native/chrome/marks/MarkPopover.ts
16969
+ var MARKS_STYLE_ID = "vela-marks-popover";
16970
+ var MARKS_CSS = `
16971
+ .vela-marks-panel { padding: 0; gap: 0; min-width: 220px; max-width: 320px; max-height: 320px; overflow-y: auto; overscroll-behavior: contain; }
16972
+ .vela-marks-section { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; }
16973
+ .vela-marks-section + .vela-marks-section { border-top: 1px solid var(--vela-border); }
16974
+ .vela-marks-field { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
16975
+ .vela-marks-field-label { color: var(--vela-fg-muted); }
16976
+ .vela-marks-field-value { color: var(--vela-fg-bright); text-align: right; font-variant-numeric: tabular-nums; }
16977
+ .vela-marks-html { color: var(--vela-fg); line-height: 1.45; overflow-wrap: anywhere; }
16978
+ .vela-marks-html p { margin: 0 0 6px; }
16979
+ .vela-marks-html p:last-child { margin-bottom: 0; }
16980
+ .vela-marks-html a { color: var(--vela-accent); }
16981
+ .vela-marks-html img { max-width: 100%; height: auto; }
16982
+ .vela-marks-html table { border-collapse: collapse; }
16983
+ .vela-marks-html td, .vela-marks-html th { padding: 2px 6px; border: 1px solid var(--vela-border); }
16984
+ .vela-marks-html pre, .vela-marks-html code { font-family: var(--vela-font-mono, monospace); font-size: 0.92em; }
16985
+ .vela-marks-loading, .vela-marks-error { color: var(--vela-fg-muted); font-style: italic; }
16986
+ `;
16987
+ var MarkPopover = class {
16988
+ constructor(deps) {
16989
+ this.deps = deps;
16990
+ this.pop = null;
16991
+ this.openKey = null;
16992
+ /** Bumped per open/close — a lazy content resolving after its popup went away is dropped. */
16993
+ this.generation = 0;
16994
+ const doc = deps.plot.ownerDocument;
16995
+ injectStyles(CALLOUT_STYLE_ID, CALLOUT_CSS, doc);
16996
+ injectStyles(MARKS_STYLE_ID, MARKS_CSS, doc);
16997
+ this.anchor = doc.createElement("div");
16998
+ this.anchor.className = "vela-marks-anchor";
16999
+ Object.assign(this.anchor.style, { position: "absolute", pointerEvents: "none", left: "0", top: "0", width: "0", height: "0" });
17000
+ deps.plot.appendChild(this.anchor);
17001
+ }
17002
+ /** The cluster key the open popup belongs to, or null. */
17003
+ get key() {
17004
+ return this.openKey;
17005
+ }
17006
+ open(cluster, rect) {
17007
+ this.close();
17008
+ this.place(rect);
17009
+ const gen = ++this.generation;
17010
+ this.openKey = cluster.key;
17011
+ this.pop = new Popover({
17012
+ trigger: this.anchor,
17013
+ host: this.deps.host(),
17014
+ theme: this.deps.theme(),
17015
+ gap: 8,
17016
+ align: "center",
17017
+ // centered on the glyph
17018
+ fadeMs: 120,
17019
+ // a short, discreet fade in and out
17020
+ className: "vela-marks-pop",
17021
+ content: (body) => this.build(body, cluster, gen),
17022
+ onClose: () => {
17023
+ if (this.generation === gen) {
17024
+ this.openKey = null;
17025
+ this.pop = null;
17026
+ this.deps.onOpenChange?.(null);
17027
+ }
17028
+ }
17029
+ });
17030
+ this.pop.show();
17031
+ this.deps.onOpenChange?.(cluster.key);
17032
+ }
17033
+ /** Follow the anchor glyph after a repaint; `null` (glyph gone — hidden, scrolled off, marks replaced) closes. */
17034
+ track(rect) {
17035
+ if (!this.pop) return;
17036
+ if (!rect) {
17037
+ this.close();
17038
+ return;
17039
+ }
17040
+ this.place(rect);
17041
+ this.pop.reposition();
17042
+ }
17043
+ close() {
17044
+ const pop = this.pop;
17045
+ const wasOpen = this.openKey !== null;
17046
+ this.pop = null;
17047
+ this.openKey = null;
17048
+ this.generation++;
17049
+ pop?.destroy();
17050
+ if (wasOpen) this.deps.onOpenChange?.(null);
17051
+ }
17052
+ destroy() {
17053
+ this.close();
17054
+ this.anchor.remove();
17055
+ }
17056
+ place(rect) {
17057
+ Object.assign(this.anchor.style, {
17058
+ left: `${rect.x - rect.size / 2}px`,
17059
+ top: `${rect.y - rect.size / 2}px`,
17060
+ width: `${rect.size}px`,
17061
+ height: `${rect.size}px`
17062
+ });
17063
+ }
17064
+ build(body, cluster, gen) {
17065
+ const doc = body.ownerDocument;
17066
+ const root = doc.createElement("div");
17067
+ root.className = "vela-callout-panel vela-marks-panel";
17068
+ const pending = [];
17069
+ for (const mark of cluster.marks) {
17070
+ const section = doc.createElement("section");
17071
+ section.className = "vela-marks-section";
17072
+ if (mark.title) {
17073
+ const title = doc.createElement("div");
17074
+ title.className = "vela-callout-title";
17075
+ title.textContent = mark.title;
17076
+ section.appendChild(title);
17077
+ }
17078
+ const content = mark.content;
17079
+ if (typeof content === "function") {
17080
+ const slot = doc.createElement("div");
17081
+ slot.className = "vela-marks-loading";
17082
+ slot.textContent = "Loading\u2026";
17083
+ section.appendChild(slot);
17084
+ pending.push({ el: section, resolve: () => this.resolveLazy(content, slot, gen) });
17085
+ } else if (content !== void 0) {
17086
+ this.renderContent(section, content);
17087
+ }
17088
+ if (section.childElementCount > 0) root.appendChild(section);
17089
+ }
17090
+ body.appendChild(root);
17091
+ if (pending.length > 0) scheduleLazy(root, pending);
17092
+ }
17093
+ resolveLazy(source, slot, gen) {
17094
+ let result;
17095
+ try {
17096
+ result = Promise.resolve(source());
17097
+ } catch (err) {
17098
+ result = Promise.reject(err instanceof Error ? err : new Error(String(err)));
17099
+ }
17100
+ void result.then(
17101
+ (content) => {
17102
+ if (gen !== this.generation) return;
17103
+ const section = slot.parentElement;
17104
+ if (!section) return;
17105
+ slot.remove();
17106
+ this.renderContent(section, content);
17107
+ this.pop?.reposition();
17108
+ },
17109
+ () => {
17110
+ if (gen !== this.generation) return;
17111
+ slot.className = "vela-marks-error";
17112
+ slot.textContent = "Couldn\u2019t load this entry.";
17113
+ }
17114
+ );
17115
+ }
17116
+ renderContent(section, content) {
17117
+ const doc = section.ownerDocument;
17118
+ if (!content || typeof content !== "object") return;
17119
+ if ("text" in content) {
17120
+ const text = doc.createElement("div");
17121
+ text.className = "vela-callout-text";
17122
+ text.textContent = String(content.text);
17123
+ section.appendChild(text);
17124
+ return;
17125
+ }
17126
+ if ("html" in content) {
17127
+ const html = doc.createElement("div");
17128
+ html.className = "vela-marks-html";
17129
+ html.appendChild(sanitizeHtml(String(content.html), doc));
17130
+ section.appendChild(html);
17131
+ return;
17132
+ }
17133
+ if ("panel" in content && content.panel && Array.isArray(content.panel.items)) {
17134
+ let actions = null;
17135
+ for (const item of content.panel.items) {
17136
+ if (!item || typeof item !== "object") continue;
17137
+ if (item.type === "button") {
17138
+ if (!actions) {
17139
+ actions = doc.createElement("div");
17140
+ actions.className = "vela-callout-actions";
17141
+ section.appendChild(actions);
17142
+ }
17143
+ const btn2 = doc.createElement("button");
17144
+ btn2.type = "button";
17145
+ btn2.className = "vela-callout-btn" + (item.primary ? " vela-callout-btn-primary" : "");
17146
+ btn2.textContent = item.label;
17147
+ btn2.addEventListener("click", () => {
17148
+ item.run();
17149
+ if (item.close !== false) this.close();
17150
+ });
17151
+ actions.appendChild(btn2);
17152
+ continue;
17153
+ }
17154
+ actions = null;
17155
+ if (item.type === "text") {
17156
+ const text = doc.createElement("div");
17157
+ text.className = "vela-callout-text";
17158
+ text.textContent = item.text;
17159
+ section.appendChild(text);
17160
+ } else if (item.type === "field") {
17161
+ const row = doc.createElement("div");
17162
+ row.className = "vela-marks-field";
17163
+ const label = doc.createElement("span");
17164
+ label.className = "vela-marks-field-label";
17165
+ label.textContent = item.label;
17166
+ const value = doc.createElement("span");
17167
+ value.className = "vela-marks-field-value";
17168
+ value.textContent = item.value;
17169
+ row.append(label, value);
17170
+ section.appendChild(row);
17171
+ }
17172
+ }
17173
+ }
17174
+ }
17175
+ };
17176
+ function scheduleLazy(scroller, pending) {
17177
+ if (typeof IntersectionObserver === "undefined") {
17178
+ for (const p of pending) p.resolve();
17179
+ return;
17180
+ }
17181
+ const io = new IntersectionObserver(
17182
+ (entries) => {
17183
+ for (const e of entries) {
17184
+ if (!e.isIntersecting) continue;
17185
+ const p = pending.find((q) => q.el === e.target);
17186
+ if (!p) continue;
17187
+ io.unobserve(e.target);
17188
+ p.resolve();
17189
+ }
17190
+ },
17191
+ { root: scroller }
17192
+ );
17193
+ for (const p of pending) io.observe(p.el);
17194
+ }
17195
+
16196
17196
  // src/renderers/native/core/manualScale.ts
16197
17197
  function rescaleAround(start, factor) {
16198
17198
  if (start.log && start.min > 0 && start.max > start.min) {
@@ -16895,11 +17895,12 @@ var SCROLL_BTN_BOTTOM = TIME_AXIS_H + 14;
16895
17895
  var SCROLL_BTN_PROXIMITY_PX = 120;
16896
17896
  var MIN_VISIBLE_BARS = 2;
16897
17897
  var ZOOM_OUT_MARGIN_BARS = 6;
16898
- var ZOOM_TAU_MS = 70;
16899
- var SCALE_TAU_MS = 80;
16900
- var FLING_TAU_MS = 110;
16901
- var SCROLL_TO_TAU_MS = 130;
17898
+ var INTRO_MODEL_FADE_MS = 350;
16902
17899
  var FLING_STOP_PX = 0.02;
17900
+ var MARK_FLASH_MS = 220;
17901
+ function frameNow() {
17902
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
17903
+ }
16903
17904
  var PRICE_SCALE_K = 4e-3;
16904
17905
  var KEY_ZOOM_STEP = 0.2;
16905
17906
  var SEPARATOR_HIT_PX = 4;
@@ -16948,9 +17949,20 @@ var NativeRenderer = class {
16948
17949
  this.indicatorSlices = new IndicatorDrawingSlices();
16949
17950
  /** Hover tooltips for Pine labels (canvas hit-rects collected by the chrome layer). */
16950
17951
  this.labelTooltip = null;
17952
+ /** The timeline-mark popup (a kit Popover anchored on a lane glyph); null before mount. */
17953
+ this.markPopover = null;
17954
+ /** How the fanned mark stack was opened: a hover folds when the pointer leaves, a tap only on a tap elsewhere. */
17955
+ this.marksExpandedBy = "hover";
17956
+ /** The rAF loop keeping the chrome repainting while a lane glyph pulses (hover) or flashes (click); null when idle. */
17957
+ this.markPulseRaf = null;
17958
+ this.markClickCbs = /* @__PURE__ */ new Set();
16951
17959
  this.crosshairLayer = new CrosshairRenderer();
16952
- /** 1 Hz repaint pump so the price-axis countdown-to-bar-close ticks; null when off. */
16953
- this.countdownTimer = null;
17960
+ /** The second pulse the countdown-to-bar-close chip ticks on: the host's (`setWallClock`)
17961
+ * when one is wired, else the renderer's own second-aligned clock. */
17962
+ this.hostClock = null;
17963
+ this.ownClock = null;
17964
+ /** Live subscription to the pulse while the countdown is on; null when off. */
17965
+ this.countdownUnsub = null;
16954
17966
  this.symbolPicker = null;
16955
17967
  /** Indicator titles (the legend rows) shown — held here so a remount re-applies it. */
16956
17968
  this.indicatorTitlesOn = true;
@@ -16968,19 +17980,25 @@ var NativeRenderer = class {
16968
17980
  /** Drawings layer self-serves Ctrl+Z/Y (see the `historyChords` feature). */
16969
17981
  this.historyChordsEnabled = true;
16970
17982
  this.liveRegion = null;
16971
- // ── animation state (eased zoom + inertial pan + live-bar glide) ──
16972
- this.animZoom = true;
16973
- this.animPan = true;
16974
- this.animLiveBarMs = 0;
16975
- // forming-bar OHLC glide time-constant; 0 = each tick snaps
16976
- this.animLiveBarOnMs = LIVE_BAR_EASE_DEFAULT_MS;
16977
- // the duration the settings-dialog on/off toggle restores (last non-zero value configured)
17983
+ // ── animation state: one ease time-constant per motion (0 = off), each remembering the
17984
+ // host's duration so the config's on/off switches restore it (see EaseSetting) ──
17985
+ this.animZoom = new EaseSetting(ZOOM_EASE_DEFAULT_MS);
17986
+ // wheel-zoom glide
17987
+ this.animPan = new EaseSetting(PAN_INERTIA_DEFAULT_MS);
17988
+ // inertial-pan velocity decay
17989
+ this.animScroll = new EaseSetting(SCROLL_EASE_DEFAULT_MS);
17990
+ // scroll-to-latest / panBy glide
17991
+ this.animAutoscale = new EaseSetting(AUTOSCALE_EASE_DEFAULT_MS);
17992
+ // autoscale glide during zoom/fling
17993
+ this.animLiveBar = new EaseSetting(LIVE_BAR_EASE_DEFAULT_MS, 0);
17994
+ // forming-bar OHLC glide; ships off
16978
17995
  // Brand default candles.
16979
17996
  this.candleUp = BULLISH;
16980
17997
  this.candleDown = BEARISH;
16981
17998
  // ── intro reveal (plays once when candles first appear) ──
16982
- this.introStyle = "settle";
16983
- // 'grow' | 'settle' | '' (off)
17999
+ this.intro = { style: "settle", duration: INTRO_DURATION_DEFAULT_MS };
18000
+ this.introOnStyle = "settle";
18001
+ // the style the config's on/off switch restores
16984
18002
  this.introPlayed = false;
16985
18003
  this.introRaf = null;
16986
18004
  /** The load affordance (three pulsing dots) — up while the host reports a bar load in
@@ -17099,7 +18117,7 @@ var NativeRenderer = class {
17099
18117
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
17100
18118
  this.priceStyleCbs = /* @__PURE__ */ new Set();
17101
18119
  this.name = "native";
17102
- 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"];
18120
+ 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"];
17103
18121
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
17104
18122
  * so moving onto the button doesn't count as leaving). */
17105
18123
  this.onScrollProximityMove = (e) => {
@@ -17131,9 +18149,12 @@ var NativeRenderer = class {
17131
18149
  this.scene.showPriceLine = opts.currentPriceLine;
17132
18150
  this.scene.logScale = opts.logScale;
17133
18151
  this.backendMode = opts.nativeBackend;
17134
- this.animZoom = opts.animZoom;
17135
- this.animPan = opts.animPan;
17136
- this.setLiveBarEase(resolveLiveBarEaseMs(opts.animLiveBar));
18152
+ this.animZoom.set(opts.animZoom);
18153
+ this.animPan.set(opts.animPan);
18154
+ this.animScroll.set(opts.animScroll);
18155
+ this.animAutoscale.set(opts.animAutoscale);
18156
+ this.animLiveBar.set(opts.animLiveBar);
18157
+ this.setIntro(opts.animIntro);
17137
18158
  this.glowAmount = opts.glow;
17138
18159
  this.candleUp = opts.upColor;
17139
18160
  this.candleDown = opts.downColor;
@@ -17177,20 +18198,29 @@ var NativeRenderer = class {
17177
18198
  if (this.backend && "glow" in this.backend) this.backend.glow = this.glowAmount;
17178
18199
  break;
17179
18200
  case "animZoom":
17180
- this.animZoom = Boolean(value);
18201
+ this.animZoom.set(resolveEaseMs(value, ZOOM_EASE_DEFAULT_MS));
17181
18202
  return;
17182
18203
  // affects the next interaction only — nothing to repaint
17183
- case "animPan":
17184
- this.animPan = Boolean(value);
18204
+ case "animPan": {
18205
+ const ms = resolveEaseMs(value, PAN_INERTIA_DEFAULT_MS);
18206
+ this.animPan.set(ms);
18207
+ this.animScroll.toggle(ms > 0);
18208
+ return;
18209
+ }
18210
+ case "animScroll":
18211
+ this.animScroll.set(resolveEaseMs(value, SCROLL_EASE_DEFAULT_MS));
18212
+ return;
18213
+ case "animAutoscale":
18214
+ this.animAutoscale.set(resolveEaseMs(value, AUTOSCALE_EASE_DEFAULT_MS));
17185
18215
  return;
18216
+ // a glide in flight finishes at the new rate (or snaps at 0)
17186
18217
  case "animLiveBar":
17187
- this.setLiveBarEase(resolveLiveBarEaseMs(value));
18218
+ this.animLiveBar.set(resolveLiveBarEaseMs(value));
17188
18219
  return;
17189
18220
  // affects the next tick only; a glide in flight finishes at the new rate (or snaps at 0)
17190
18221
  case "intro": {
17191
- const s = value === false || value === "none" || value === "off" || value == null ? "" : String(value);
17192
- this.introStyle = s;
17193
- if (s) this.playIntro(s);
18222
+ this.setIntro(resolveIntro(value));
18223
+ if (this.intro.style) this.playIntro();
17194
18224
  return;
17195
18225
  }
17196
18226
  case "zoomAnchor":
@@ -17262,6 +18292,10 @@ var NativeRenderer = class {
17262
18292
  case "tradeMarkers":
17263
18293
  this.scene.tradeMarkers = mergeTradeMarkersState(this.scene.tradeMarkers, value);
17264
18294
  break;
18295
+ case "marks":
18296
+ this.scene.marks = mergeMarksState(this.scene.marks, value);
18297
+ this.markPopover?.close();
18298
+ break;
17265
18299
  case "keyboard":
17266
18300
  this.setKeyboardEnabled(Boolean(value));
17267
18301
  return;
@@ -17320,13 +18354,17 @@ var NativeRenderer = class {
17320
18354
  case "glow":
17321
18355
  return this.glowAmount;
17322
18356
  case "animZoom":
17323
- return this.animZoom;
18357
+ return this.animZoom.tau;
17324
18358
  case "animPan":
17325
- return this.animPan;
18359
+ return this.animPan.tau;
18360
+ case "animScroll":
18361
+ return this.animScroll.tau;
18362
+ case "animAutoscale":
18363
+ return this.animAutoscale.tau;
17326
18364
  case "animLiveBar":
17327
- return this.animLiveBarMs;
18365
+ return this.animLiveBar.tau;
17328
18366
  case "intro":
17329
- return this.introStyle;
18367
+ return this.intro.style;
17330
18368
  case "zoomAnchor":
17331
18369
  return this.zoomAnchorMode;
17332
18370
  case "axisDrag":
@@ -17369,6 +18407,8 @@ var NativeRenderer = class {
17369
18407
  }
17370
18408
  case "tradeMarkers":
17371
18409
  return { ...this.scene.tradeMarkers, colors: { ...this.scene.tradeMarkers.colors } };
18410
+ case "marks":
18411
+ return { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } };
17372
18412
  case "keyboard":
17373
18413
  return this.keyboardEnabled;
17374
18414
  case "historyChords":
@@ -17494,7 +18534,13 @@ var NativeRenderer = class {
17494
18534
  currentPriceLine: this.scene.showPriceLine,
17495
18535
  priceLabel: this.scene.showPriceLabel,
17496
18536
  countdown: this.scene.showCountdown,
17497
- animateLastPrice: this.animLiveBarMs > 0
18537
+ animateLastPrice: this.animLiveBar.on
18538
+ },
18539
+ animations: {
18540
+ zoom: this.animZoom.on,
18541
+ pan: this.animPan.on,
18542
+ autoscale: this.animAutoscale.on,
18543
+ intro: this.intro.style !== false
17498
18544
  },
17499
18545
  panes: { separatorColor: s.separatorColor ?? t.borderColor },
17500
18546
  trades: {
@@ -17506,6 +18552,7 @@ var NativeRenderer = class {
17506
18552
  exitColor: this.scene.tradeMarkers.colors.exit
17507
18553
  },
17508
18554
  timeScale: { timezone: this.scene.timezone },
18555
+ marks: { visible: this.scene.marks.visible, groups: { ...this.scene.marks.groups } },
17509
18556
  candles: {
17510
18557
  upColor: this.candleUp,
17511
18558
  downColor: this.candleDown,
@@ -17601,7 +18648,12 @@ var NativeRenderer = class {
17601
18648
  this.scene.showPriceLabel = next.priceScale.priceLabel;
17602
18649
  this.scene.showCountdown = next.priceScale.countdown;
17603
18650
  this.syncCountdownTimer();
17604
- this.animLiveBarMs = next.priceScale.animateLastPrice ? this.animLiveBarOnMs : 0;
18651
+ this.animLiveBar.toggle(next.priceScale.animateLastPrice);
18652
+ this.animZoom.toggle(next.animations.zoom);
18653
+ this.animPan.toggle(next.animations.pan);
18654
+ this.animScroll.toggle(next.animations.pan);
18655
+ this.animAutoscale.toggle(next.animations.autoscale);
18656
+ this.intro = { style: next.animations.intro ? this.introOnStyle : false, duration: this.intro.duration || INTRO_DURATION_DEFAULT_MS };
17605
18657
  s.separatorColor = keepInherit(s.separatorColor, next.panes.separatorColor, prevTheme.borderColor);
17606
18658
  this.scene.tradeMarkers = {
17607
18659
  visible: next.trades.visible,
@@ -17610,6 +18662,7 @@ var NativeRenderer = class {
17610
18662
  colors: { long: next.trades.longColor, short: next.trades.shortColor, exit: next.trades.exitColor }
17611
18663
  };
17612
18664
  this.scene.timezone = next.timeScale.timezone;
18665
+ this.scene.marks = { visible: next.marks.visible, groups: { ...next.marks.groups } };
17613
18666
  this.candleUp = next.candles.upColor;
17614
18667
  this.candleDown = next.candles.downColor;
17615
18668
  s.candle = {
@@ -17742,6 +18795,7 @@ var NativeRenderer = class {
17742
18795
  }
17743
18796
  this.settingsDialog.setTheme(this.theme);
17744
18797
  this.settingsDialog.setHostSections(this.hostSettingsSections);
18798
+ this.settingsDialog.setMarkGroups(this.markGroupsInUse(), (id) => markGroupVisible(this.scene.marks, id, this.scene.markGroups));
17745
18799
  this.settingsDialog.setHiddenSettings(this.hiddenSettings);
17746
18800
  this.syncThemeControl();
17747
18801
  this.settingsDialog.toggle(
@@ -17832,10 +18886,10 @@ var NativeRenderer = class {
17832
18886
  this.glideRightOffset(ZOOM_OUT_MARGIN_BARS);
17833
18887
  }
17834
18888
  /** Ease rightOffset to `target` at constant zoom (see animTick's scroll glide);
17835
- * instant when pan animation is off. Shared by scroll-to-latest and panBy. */
18889
+ * instant when the scroll glide is off. Shared by scroll-to-latest and panBy. */
17836
18890
  glideRightOffset(target) {
17837
18891
  const vp = this.coords.getViewport();
17838
- if (!this.animPan) {
18892
+ if (!this.animScroll.on) {
17839
18893
  this.applyViewport({ barSpacing: vp.barSpacing, rightOffset: target });
17840
18894
  return;
17841
18895
  }
@@ -17900,20 +18954,21 @@ var NativeRenderer = class {
17900
18954
  * full size, eased, with a left→right stagger so the chart draws itself; `settle`
17901
18955
  * adds an ease-out-back overshoot. Autoscale stays on the real bars so the frame
17902
18956
  * never moves. Re-callable, so styles can be compared live from the console.
18957
+ * Style and sweep duration come from the resolved `intro` setting.
17903
18958
  */
17904
- playIntro(style) {
18959
+ playIntro() {
17905
18960
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
17906
18961
  this.introRaf = null;
18962
+ const { style, duration } = this.intro;
17907
18963
  const real = this.bars;
17908
18964
  const n = real.length;
17909
- if (n === 0) return;
18965
+ if (n === 0 || !style) return;
17910
18966
  this.computeScales();
17911
18967
  for (const pane of this.scene.panes.values()) pane.scale = { ...pane.scaleTarget };
17912
18968
  this.modelAlpha = 0;
17913
- const DURATION = 650;
17914
18969
  const start = performance.now();
17915
18970
  const step = (now) => {
17916
- const p = Math.min(1, (now - start) / DURATION);
18971
+ const p = Math.min(1, (now - start) / duration);
17917
18972
  this.scene.bars = p >= 1 ? real : real.map((b, i) => this.revealCandle(b, i, p, n, style));
17918
18973
  this.paintData();
17919
18974
  if (p < 1) {
@@ -17927,10 +18982,10 @@ var NativeRenderer = class {
17927
18982
  }
17928
18983
  /** After the candle reveal, fade the indicator models (series/fills/…) from hidden to full. */
17929
18984
  fadeInModels() {
17930
- const FADE = 350;
18985
+ const fade = Math.min(INTRO_MODEL_FADE_MS, this.intro.duration || INTRO_MODEL_FADE_MS);
17931
18986
  const start = performance.now();
17932
18987
  const step = (now) => {
17933
- this.modelAlpha = Math.min(1, (now - start) / FADE);
18988
+ this.modelAlpha = Math.min(1, (now - start) / fade);
17934
18989
  this.paintData();
17935
18990
  if (this.modelAlpha < 1) {
17936
18991
  this.introRaf = requestAnimationFrame(step);
@@ -18045,7 +19100,8 @@ var NativeRenderer = class {
18045
19100
  zoomTo: (target, anchorLogical, anchorX) => this.zoomTo(target, anchorLogical, anchorX),
18046
19101
  fling: (v) => this.fling(v),
18047
19102
  onPointerMove: (x, y) => this.handlePointerMove(x, y),
18048
- onClick: (x) => {
19103
+ onClick: (x, y) => {
19104
+ if (this.handleMarkClick(x, y)) return;
18049
19105
  this.userDrawings?.deselect();
18050
19106
  this.handleClick(x);
18051
19107
  },
@@ -18074,7 +19130,7 @@ var NativeRenderer = class {
18074
19130
  drawingsPointerDown: (x, y, snap, shift2, mod) => this.userDrawings?.pointerDown(x, y, snap, shift2, mod),
18075
19131
  drawingsPointerMove: (x, y, snap, shift2, mod) => this.userDrawings?.pointerMove(x, y, snap, shift2, mod),
18076
19132
  drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
18077
- drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
19133
+ drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? (this.chrome.markGlyphAt(x, y) ? "pointer" : null),
18078
19134
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
18079
19135
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
18080
19136
  });
@@ -18090,8 +19146,18 @@ var NativeRenderer = class {
18090
19146
  this.plot.addEventListener("pointerleave", this.onScrollProximityLeave);
18091
19147
  this.labelTooltip = new LabelTooltip(this.plot, {
18092
19148
  theme: () => this.chromeTheme(),
18093
- lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y)
19149
+ lookup: (x, y) => this.indicatorSlices.labelTooltipAt(x, y) ?? this.chrome.markTooltipAt(x, y, this.markGroupsInUse())
19150
+ });
19151
+ this.markPopover = new MarkPopover({
19152
+ plot: this.plot,
19153
+ host: () => this.dialogHost ?? this.plot,
19154
+ theme: () => this.chromeTheme(),
19155
+ onOpenChange: (key) => {
19156
+ this.scene.marksActiveKey = key;
19157
+ this.scheduler?.invalidate(2 /* Chrome */);
19158
+ }
18094
19159
  });
19160
+ this.chrome.setMarkIconReady(() => this.scheduler?.invalidate(2 /* Chrome */));
18095
19161
  this.userDrawings = new UserDrawingController(this.wrapper, this.plot, this.drawingsCanvas, {
18096
19162
  projector: () => this.drawingProjector(),
18097
19163
  dpr: () => this.coords.dpr,
@@ -18306,29 +19372,40 @@ var NativeRenderer = class {
18306
19372
  resize() {
18307
19373
  this.syncSize();
18308
19374
  }
18309
- /** Run a 1 Hz repaint pump while the countdown chip is on (so it ticks); stop it otherwise.
18310
- * Chrome tier: only the chip's wall-clock text moves — an idle chart must not recompute
19375
+ /** Drive the countdown chip from the host's second pulse (`null` the renderer's own). */
19376
+ setWallClock(clock) {
19377
+ if (clock === this.hostClock) return;
19378
+ this.hostClock = clock;
19379
+ if (this.countdownUnsub != null) {
19380
+ this.countdownUnsub();
19381
+ this.countdownUnsub = null;
19382
+ }
19383
+ this.syncCountdownTimer();
19384
+ }
19385
+ /** Subscribe to the second pulse while the countdown chip is on (so it ticks); unsubscribe
19386
+ * otherwise. Chrome tier: only the chip's text moves — an idle chart must not recompute
18311
19387
  * scales or repaint the geometry/volume/VPVR/SDK layers once a second (that cost
18312
19388
  * multiplies by the cell count in a multi-chart workspace). */
18313
19389
  syncCountdownTimer() {
18314
19390
  if (this.scene.showCountdown) {
18315
- if (this.countdownTimer == null) {
18316
- this.countdownTimer = setInterval(() => {
19391
+ if (this.countdownUnsub == null) {
19392
+ const clock = this.hostClock ?? (this.ownClock ?? (this.ownClock = new SecondClock()));
19393
+ this.countdownUnsub = clock.onTick(() => {
18317
19394
  if (this.scene.showCountdown && this.scene.bars.length > 0) this.scheduler?.invalidate(2 /* Chrome */);
18318
- }, 1e3);
19395
+ });
18319
19396
  }
18320
- } else if (this.countdownTimer != null) {
18321
- clearInterval(this.countdownTimer);
18322
- this.countdownTimer = null;
19397
+ } else if (this.countdownUnsub != null) {
19398
+ this.countdownUnsub();
19399
+ this.countdownUnsub = null;
18323
19400
  }
18324
19401
  }
18325
19402
  destroy() {
18326
19403
  if (this.introRaf != null) cancelAnimationFrame(this.introRaf);
18327
19404
  this.loadingEl?.remove();
18328
19405
  this.loadingEl = null;
18329
- if (this.countdownTimer != null) {
18330
- clearInterval(this.countdownTimer);
18331
- this.countdownTimer = null;
19406
+ if (this.countdownUnsub != null) {
19407
+ this.countdownUnsub();
19408
+ this.countdownUnsub = null;
18332
19409
  }
18333
19410
  this.scheduler?.destroy();
18334
19411
  this.animator?.stop();
@@ -18353,6 +19430,11 @@ var NativeRenderer = class {
18353
19430
  this.plot?.removeEventListener("pointerleave", this.onScrollProximityLeave);
18354
19431
  this.labelTooltip?.destroy();
18355
19432
  this.labelTooltip = null;
19433
+ this.markPopover?.destroy();
19434
+ this.markPopover = null;
19435
+ this.chrome.setMarkIconReady(null);
19436
+ if (this.markPulseRaf !== null) cancelAnimationFrame(this.markPulseRaf);
19437
+ this.markPulseRaf = null;
18356
19438
  this.scrollButton?.remove();
18357
19439
  this.scrollButton = null;
18358
19440
  for (const l of this.extLayers) l.instance.destroy?.();
@@ -18411,8 +19493,8 @@ var NativeRenderer = class {
18411
19493
  }
18412
19494
  if (!this.introPlayed && this.bars.length > 0) {
18413
19495
  this.introPlayed = true;
18414
- if (this.introStyle) {
18415
- this.playIntro(this.introStyle);
19496
+ if (this.intro.style) {
19497
+ this.playIntro();
18416
19498
  return;
18417
19499
  }
18418
19500
  }
@@ -18423,7 +19505,7 @@ var NativeRenderer = class {
18423
19505
  const last = this.bars[n - 1];
18424
19506
  if (last && bar.time === last.time) {
18425
19507
  this.bars[n - 1] = bar;
18426
- if (this.animLiveBarMs <= 0 || this.liveEaseTime !== bar.time) {
19508
+ if (!this.animLiveBar.on || this.liveEaseTime !== bar.time) {
18427
19509
  this.syncLiveEase(bar);
18428
19510
  } else {
18429
19511
  this.animator.start();
@@ -18439,11 +19521,11 @@ var NativeRenderer = class {
18439
19521
  this.scene.bars = this.bars;
18440
19522
  this.scheduler.invalidate(4 /* Full */);
18441
19523
  }
18442
- /** Set the live-bar glide duration (0 = off). A non-zero value is also remembered as
18443
- * what the config's on/off toggle (`priceScale.animateLastPrice`) switches back on to. */
18444
- setLiveBarEase(ms) {
18445
- this.animLiveBarMs = ms;
18446
- if (ms > 0) this.animLiveBarOnMs = ms;
19524
+ /** Set the reveal (style + duration). A non-off style is also remembered as what the
19525
+ * config's on/off toggle (`animations.intro`) switches back on to. */
19526
+ setIntro(next) {
19527
+ this.intro = next;
19528
+ if (next.style) this.introOnStyle = next.style;
18447
19529
  }
18448
19530
  /** Snap the eased forming-bar state to `bar` — no glide (a fresh bar or the first tick of one). */
18449
19531
  syncLiveEase(bar) {
@@ -18457,7 +19539,7 @@ var NativeRenderer = class {
18457
19539
  const target = this.bars[this.bars.length - 1];
18458
19540
  if (!target || this.liveEaseTime !== target.time) return false;
18459
19541
  const eps = Math.max(1e-9, Math.abs(target.close) * 1e-6);
18460
- const tau = this.animLiveBarMs;
19542
+ const tau = this.animLiveBar.tau;
18461
19543
  const nh = easeToward(this.liveEaseHigh, target.high, dtMs, tau);
18462
19544
  const nl = easeToward(this.liveEaseLow, target.low, dtMs, tau);
18463
19545
  const nc = easeToward(this.liveEaseClose, target.close, dtMs, tau);
@@ -18597,10 +19679,12 @@ var NativeRenderer = class {
18597
19679
  this.refreshAnchorOffset(model);
18598
19680
  if (model.native && this.extLayers.some((l) => l.def.id === model.native.type)) this.scene.assignIndicatorZTop(model.id);
18599
19681
  else this.scene.assignIndicatorZ(model.id);
18600
- this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
18601
- native: !!model.native,
18602
- ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
18603
- });
19682
+ if (model.legend !== false) {
19683
+ this.inputsUI.upsert(model.id, model.shorttitle ?? model.title, model.inputs, model.inputValues, model.paneId, {
19684
+ native: !!model.native,
19685
+ ...model.props ? { props: model.props, propValues: model.propValues ?? {} } : {}
19686
+ });
19687
+ }
18604
19688
  if (model.native?.type === "volume") {
18605
19689
  this.volumeActive = true;
18606
19690
  this.volumeHidden = false;
@@ -18747,7 +19831,7 @@ var NativeRenderer = class {
18747
19831
  this.settingsDialog?.setHiddenSettings(this.hiddenSettings);
18748
19832
  }
18749
19833
  listSettingsIds() {
18750
- return settingsIdCatalog(this.hostSettingsSections);
19834
+ return settingsIdCatalog(this.hostSettingsSections, this.markGroupsInUse());
18751
19835
  }
18752
19836
  onChartTypeSettingsChange(cb) {
18753
19837
  this.chartTypeSettingsCbs.add(cb);
@@ -18773,6 +19857,22 @@ var NativeRenderer = class {
18773
19857
  this.axisLongPressCbs.add(cb);
18774
19858
  return () => this.axisLongPressCbs.delete(cb);
18775
19859
  }
19860
+ // ── timeline marks (the `chart.marks` model; see the port) ──
19861
+ setTimelineMarks(marks, groups) {
19862
+ this.scene.timelineMarks = marks;
19863
+ this.scene.markGroups = groups;
19864
+ this.scene.marksExpandedStack = null;
19865
+ this.markPopover?.close();
19866
+ this.scheduler?.invalidate(2 /* Chrome */);
19867
+ }
19868
+ onMarkClick(cb) {
19869
+ this.markClickCbs.add(cb);
19870
+ return () => this.markClickCbs.delete(cb);
19871
+ }
19872
+ /** Every group the lane knows: the defined ones, then those marks name without a definition. */
19873
+ markGroupsInUse() {
19874
+ return effectiveMarkGroups(this.scene.timelineMarks, this.scene.markGroups);
19875
+ }
18776
19876
  onViewportChange(cb) {
18777
19877
  this.viewportCbs.add(cb);
18778
19878
  return () => this.viewportCbs.delete(cb);
@@ -18826,7 +19926,7 @@ var NativeRenderer = class {
18826
19926
  this.zoomAnchorX = anchorX;
18827
19927
  this.panVelocity = 0;
18828
19928
  this.scrollTargetRO = null;
18829
- if (!this.animZoom) {
19929
+ if (!this.animZoom.on) {
18830
19930
  const v = this.clampViewport(barSpacing, this.anchoredRightOffset(barSpacing));
18831
19931
  this.coords.setViewport(v);
18832
19932
  this.targetBarSpacing = v.barSpacing;
@@ -18839,7 +19939,7 @@ var NativeRenderer = class {
18839
19939
  }
18840
19940
  /** Inertial pan: continue with a rightOffset velocity (logical units / ms) that decays. */
18841
19941
  fling(velocity) {
18842
- if (!this.animPan) return;
19942
+ if (!this.animPan.on) return;
18843
19943
  this.scrollTargetRO = null;
18844
19944
  this.panVelocity = velocity;
18845
19945
  this.animator.start();
@@ -18879,7 +19979,7 @@ var NativeRenderer = class {
18879
19979
  let active = false;
18880
19980
  const tbs = this.targetBarSpacing;
18881
19981
  if (Math.abs(barSpacing - tbs) > tbs * 1e-3) {
18882
- barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, ZOOM_TAU_MS));
19982
+ barSpacing = clampBarSpacing(easeToward(barSpacing, tbs, dtMs, this.animZoom.tau));
18883
19983
  rightOffset = this.anchoredRightOffset(barSpacing);
18884
19984
  active = true;
18885
19985
  } else if (barSpacing !== tbs) {
@@ -18889,13 +19989,14 @@ var NativeRenderer = class {
18889
19989
  const stopVel = FLING_STOP_PX / Math.max(1e-6, barSpacing * this.coords.spacingScale);
18890
19990
  if (Math.abs(this.panVelocity) > stopVel) {
18891
19991
  rightOffset += this.panVelocity * dtMs;
18892
- this.panVelocity *= Math.exp(-dtMs / FLING_TAU_MS);
19992
+ const tau = this.animPan.tau;
19993
+ this.panVelocity = tau > 0 ? this.panVelocity * Math.exp(-dtMs / tau) : 0;
18893
19994
  if (Math.abs(this.panVelocity) <= stopVel) this.panVelocity = 0;
18894
19995
  else active = true;
18895
19996
  }
18896
19997
  if (this.scrollTargetRO != null) {
18897
19998
  const target = this.scrollTargetRO;
18898
- const next = easeToward(rightOffset, target, dtMs, SCROLL_TO_TAU_MS);
19999
+ const next = easeToward(rightOffset, target, dtMs, this.animScroll.tau);
18899
20000
  if (Math.abs(next - target) < 1e-3) {
18900
20001
  rightOffset = target;
18901
20002
  this.scrollTargetRO = null;
@@ -18923,6 +20024,7 @@ var NativeRenderer = class {
18923
20024
  * through Math.log), not a non-linear jump. */
18924
20025
  easeScales(dtMs) {
18925
20026
  let moving = false;
20027
+ const tau = this.animAutoscale.tau;
18926
20028
  for (const pane of this.scene.panes.values()) {
18927
20029
  const t = pane.scaleTarget;
18928
20030
  const s = pane.scale;
@@ -18930,8 +20032,8 @@ var NativeRenderer = class {
18930
20032
  const lt0 = Math.log(t.min);
18931
20033
  const lt1 = Math.log(t.max);
18932
20034
  const lspan = Math.max(1e-9, Math.abs(lt1 - lt0));
18933
- const n0 = easeToward(Math.log(s.min), lt0, dtMs, SCALE_TAU_MS);
18934
- const n1 = easeToward(Math.log(s.max), lt1, dtMs, SCALE_TAU_MS);
20035
+ const n0 = easeToward(Math.log(s.min), lt0, dtMs, tau);
20036
+ const n1 = easeToward(Math.log(s.max), lt1, dtMs, tau);
18935
20037
  if (Math.abs(n0 - lt0) <= lspan * 1e-3 && Math.abs(n1 - lt1) <= lspan * 1e-3) {
18936
20038
  pane.scale = { min: t.min, max: t.max, log: true };
18937
20039
  } else {
@@ -18941,8 +20043,8 @@ var NativeRenderer = class {
18941
20043
  continue;
18942
20044
  }
18943
20045
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
18944
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
18945
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
20046
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
20047
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
18946
20048
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
18947
20049
  nmin = t.min;
18948
20050
  nmax = t.max;
@@ -18955,8 +20057,8 @@ var NativeRenderer = class {
18955
20057
  const t = sl.scaleTarget;
18956
20058
  const s = sl.scale;
18957
20059
  const span = Math.max(1e-9, Math.abs(t.max - t.min));
18958
- let nmin = easeToward(s.min, t.min, dtMs, SCALE_TAU_MS);
18959
- let nmax = easeToward(s.max, t.max, dtMs, SCALE_TAU_MS);
20060
+ let nmin = easeToward(s.min, t.min, dtMs, tau);
20061
+ let nmax = easeToward(s.max, t.max, dtMs, tau);
18960
20062
  if (Math.abs(nmin - t.min) <= span * 1e-3 && Math.abs(nmax - t.max) <= span * 1e-3) {
18961
20063
  nmin = t.min;
18962
20064
  nmax = t.max;
@@ -18977,6 +20079,8 @@ var NativeRenderer = class {
18977
20079
  this.scene.crosshair = null;
18978
20080
  this.hoverSeparatorY = null;
18979
20081
  this.lastPointer = null;
20082
+ if (this.marksExpandedBy === "hover") this.setMarksExpanded(null);
20083
+ this.setMarkHover(null);
18980
20084
  this.scheduler.invalidate(1 /* Cursor */);
18981
20085
  this.hoverLogical = null;
18982
20086
  const empty = { time: null, price: null, paneKind: null, values: /* @__PURE__ */ new Map(), ohlc: null };
@@ -18988,6 +20092,8 @@ var NativeRenderer = class {
18988
20092
  this.lastPointer = inData ? { x, y } : null;
18989
20093
  this.hoverSeparatorY = x >= 0 && y >= 0 && y <= this.coords.height ? this.separatorHoverY(y) : null;
18990
20094
  this.scheduler.invalidate(1 /* Cursor */);
20095
+ this.setMarksExpanded(inData ? this.chrome.markStackAt(x, y) : null);
20096
+ this.setMarkHover(inData ? this.chrome.markGlyphAt(x, y)?.cluster.key ?? null : null);
18991
20097
  const logical = Math.round(this.coords.xToLogical(x));
18992
20098
  const onBar = logical >= 0 && logical < this.coords.barCount;
18993
20099
  const time = onBar ? this.coords.logicalToTime(logical) : null;
@@ -19017,6 +20123,76 @@ var NativeRenderer = class {
19017
20123
  const onBar = logical >= 0 && logical < this.coords.barCount;
19018
20124
  for (const cb of this.clickCbs) cb({ time: onBar ? this.coords.logicalToTime(logical) : null, price: null });
19019
20125
  }
20126
+ /**
20127
+ * Fan out (or collapse, with null) a multi-group mark stack; repaints the chrome tier when
20128
+ * it changes. A stack whose glyph holds the open popup stays fanned — the pointer leaving
20129
+ * the plot for the popup must not bury the glyph under the deck (which would close it).
20130
+ */
20131
+ setMarksExpanded(stack, by = "hover") {
20132
+ if (this.scene.marksExpandedStack === stack) return;
20133
+ if (stack === null && this.markPopover?.key) {
20134
+ const open = this.chrome.markGlyphByKey(this.markPopover.key);
20135
+ if (open && open.stack === this.scene.marksExpandedStack) return;
20136
+ }
20137
+ this.scene.marksExpandedStack = stack;
20138
+ this.marksExpandedBy = by;
20139
+ this.scheduler?.invalidate(2 /* Chrome */);
20140
+ }
20141
+ /**
20142
+ * A click on the mark lane: a collapsed deck fans out (the touch path — a mouse already
20143
+ * fanned it by hovering), a glyph reports its cluster (`onMarkClick`) and opens the popup
20144
+ * when any of its marks carries content. True when the click landed on the lane.
20145
+ */
20146
+ handleMarkClick(x, y) {
20147
+ const glyph = this.chrome.markGlyphAt(x, y);
20148
+ if (!glyph) {
20149
+ if (this.scene.marksExpandedStack !== null && this.chrome.markStackAt(x, y) === null) this.setMarksExpanded(null);
20150
+ return false;
20151
+ }
20152
+ if (glyph.decked) {
20153
+ this.setMarksExpanded(glyph.stack, "tap");
20154
+ return true;
20155
+ }
20156
+ const marks = glyph.cluster.marks;
20157
+ const first = marks[0];
20158
+ const event = { id: first.id, ids: marks.map((m) => m.id), time: first.time, ...glyph.cluster.group !== void 0 ? { group: glyph.cluster.group } : {} };
20159
+ for (const cb of this.markClickCbs) cb(event);
20160
+ if (marks.some((m) => m.content !== void 0)) {
20161
+ this.markPopover?.open(glyph.cluster, { x: glyph.x, y: glyph.y, size: glyph.size });
20162
+ } else {
20163
+ this.scene.marksFlash = { key: glyph.cluster.key, until: frameNow() + MARK_FLASH_MS };
20164
+ this.syncMarkPulse();
20165
+ }
20166
+ return true;
20167
+ }
20168
+ /** After a chrome frame: keep the open mark popup on its glyph, or close it once the glyph is gone. */
20169
+ trackMarkPopover() {
20170
+ const key = this.markPopover?.key;
20171
+ if (!key) return;
20172
+ const g = this.chrome.markGlyphByKey(key);
20173
+ this.markPopover.track(g && !(g.decked && g.depth !== 0) ? { x: g.x, y: g.y, size: g.size } : null);
20174
+ }
20175
+ /** The lane glyph under the pointer — it swells once as the pointer lands (a rAF-driven chrome repaint for the pulse's duration). */
20176
+ setMarkHover(key) {
20177
+ if (this.scene.marksHoverKey === key) return;
20178
+ this.scene.marksHoverKey = key;
20179
+ this.scene.marksHoverSince = frameNow();
20180
+ this.scheduler?.invalidate(2 /* Chrome */);
20181
+ this.syncMarkPulse();
20182
+ }
20183
+ /** Run a chrome-tier repaint loop while a glyph's hover pulse or click flash plays; it stops itself once both are over. */
20184
+ syncMarkPulse() {
20185
+ if (this.markPulseRaf !== null || typeof requestAnimationFrame !== "function") return;
20186
+ const tick = () => {
20187
+ this.markPulseRaf = null;
20188
+ const now = frameNow();
20189
+ if (this.scene.marksFlash && this.scene.marksFlash.until <= now) this.scene.marksFlash = null;
20190
+ this.scheduler?.invalidate(2 /* Chrome */);
20191
+ const pulsing = this.scene.marksHoverKey !== null && now - this.scene.marksHoverSince < MARK_PULSE_MS;
20192
+ if (pulsing || this.scene.marksFlash !== null) this.markPulseRaf = requestAnimationFrame(tick);
20193
+ };
20194
+ this.markPulseRaf = requestAnimationFrame(tick);
20195
+ }
19020
20196
  paneAtY(y) {
19021
20197
  return this.paneNodeAtY(y);
19022
20198
  }
@@ -19408,6 +20584,7 @@ var NativeRenderer = class {
19408
20584
  } else if (repaintsChrome(level) && this.paintedData) {
19409
20585
  this.chrome.prepare(this.scene, this.coords, this.theme);
19410
20586
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
20587
+ this.trackMarkPopover();
19411
20588
  }
19412
20589
  this.crosshairLayer.render(this.scene, this.coords, this.theme, this.hoverSeparatorY, this.externalCrossPx());
19413
20590
  if (!repaintsData(level) && this.paintedData) this.repaintCursorLayers();
@@ -19423,7 +20600,7 @@ var NativeRenderer = class {
19423
20600
  const lp = this.layerPane(l.def.id) ?? pane;
19424
20601
  if (lp.collapsed) continue;
19425
20602
  l.instance.render(this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs));
19426
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
20603
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
19427
20604
  }
19428
20605
  }
19429
20606
  /** Blank one SDK layer canvas (a collapsed host pane suppresses the layer's painting). */
@@ -19488,7 +20665,7 @@ var NativeRenderer = class {
19488
20665
  const args = this.extLayerArgs(l.def.id, lp.scale, lp.bounds, nowMs);
19489
20666
  l.instance.render(args);
19490
20667
  if (lp === pane) folded = foldBaseModulation(folded, l.instance.modulateBase?.(args) ?? null);
19491
- if (this.animZoom && l.instance.animating?.()) this.animator.start();
20668
+ if (this.animZoom.on && l.instance.animating?.()) this.animator.start();
19492
20669
  }
19493
20670
  if (folded) {
19494
20671
  if (folded.candleBodyScale != null) this.backend.candleBodyScale = clamp012(folded.candleBodyScale) || 0.01;
@@ -19507,6 +20684,7 @@ var NativeRenderer = class {
19507
20684
  this.backdropRenderer.render(this.scene, this.coords, this.theme, gridAlpha);
19508
20685
  this.backend.render(this.scene, this.coords, this.theme);
19509
20686
  this.chrome.render(this.scene, this.coords, this.theme, this.axisSurface());
20687
+ this.trackMarkPopover();
19510
20688
  this.userDrawings?.render();
19511
20689
  if (easeLive && liveActual) this.bars[li] = liveActual;
19512
20690
  this.paintedData = true;
@@ -20019,7 +21197,7 @@ var NativeRenderer = class {
20019
21197
  const map2 = /* @__PURE__ */ new Map();
20020
21198
  for (const pane of this.scene.panes.values()) {
20021
21199
  if (!pane.collapsed) continue;
20022
- const models = this.scene.orderedIndicatorsForPane(pane.id);
21200
+ const models = this.scene.orderedIndicatorsForPane(pane.id).filter((m) => m.legend !== false);
20023
21201
  const merged = new Set(this.scene.ownScaleIndicatorsForPane(pane.id).map((m) => m.id));
20024
21202
  const master = models.find((m) => !merged.has(m.id)) ?? models[0];
20025
21203
  map2.set(pane.id, master?.id ?? null);
@@ -20443,6 +21621,93 @@ function presetToRange(preset, bars) {
20443
21621
  return { from: Math.max(first, from), to: last };
20444
21622
  }
20445
21623
 
21624
+ // src/core/marks/MarksController.ts
21625
+ var MarksController = class {
21626
+ constructor(renderer, events) {
21627
+ this.renderer = renderer;
21628
+ /** Insertion-ordered — the order a cluster falls back to for equal times. */
21629
+ this.marks = /* @__PURE__ */ new Map();
21630
+ this.groups = /* @__PURE__ */ new Map();
21631
+ this.subs = [];
21632
+ this.enabled = !!renderer.capabilities.timelineMarks && typeof renderer.setTimelineMarks === "function";
21633
+ if (this.enabled && renderer.onMarkClick) this.subs.push(renderer.onMarkClick((e) => events.emit("mark:click", e)));
21634
+ }
21635
+ /** Whether the active renderer paints timeline marks. */
21636
+ get supported() {
21637
+ return this.enabled;
21638
+ }
21639
+ /** Add (or replace, by id) one mark. */
21640
+ add(mark) {
21641
+ this.marks.set(mark.id, validateMark(mark));
21642
+ this.sync();
21643
+ }
21644
+ /** Replace the whole set — a market switch. */
21645
+ set(marks) {
21646
+ this.marks.clear();
21647
+ for (const m of marks) this.marks.set(m.id, validateMark(m));
21648
+ this.sync();
21649
+ }
21650
+ remove(id) {
21651
+ const had = this.marks.delete(id);
21652
+ if (had) this.sync();
21653
+ return had;
21654
+ }
21655
+ clear() {
21656
+ if (this.marks.size === 0) return;
21657
+ this.marks.clear();
21658
+ this.sync();
21659
+ }
21660
+ /** Every mark, in insertion order (shallow copies — mutating one changes nothing). */
21661
+ all() {
21662
+ return [...this.marks.values()].map((m) => ({ ...m, glyph: { ...m.glyph } }));
21663
+ }
21664
+ /** Define (or replace) a group's presentation — its settings label and default visibility. */
21665
+ defineGroup(group) {
21666
+ if (!group || typeof group.id !== "string" || group.id.length === 0) throw new Error("[vela] marks.defineGroup: `id` must be a non-empty string");
21667
+ if (typeof group.label !== "string") throw new Error(`[vela] marks.defineGroup: group "${group.id}" needs a string \`label\``);
21668
+ this.groups.set(group.id, { ...group });
21669
+ this.sync();
21670
+ }
21671
+ /** The defined groups, in definition order. */
21672
+ groupDefinitions() {
21673
+ return [...this.groups.values()].map((g) => ({ ...g }));
21674
+ }
21675
+ /**
21676
+ * Show or hide one group's marks. The choice lives in the renderer's cosmetic config
21677
+ * (the `marks` feature) — what the settings dialog's Events checkboxes edit and what
21678
+ * a persisted chart restores — so it warns + no-ops on a renderer without it.
21679
+ */
21680
+ setGroupVisible(id, visible) {
21681
+ if (!this.renderer.features.includes("marks")) {
21682
+ console.warn(`[vela] renderer "${this.renderer.name}" does not paint timeline marks \u2014 setGroupVisible ignored.`);
21683
+ return;
21684
+ }
21685
+ this.renderer.applyFeature("marks", { groups: { [id]: visible } });
21686
+ }
21687
+ /** A group's effective visibility: the user's (persisted) choice, else the group's declared default, else visible. */
21688
+ isGroupVisible(id) {
21689
+ const state = this.renderer.readFeature("marks");
21690
+ const chosen = state?.groups?.[id];
21691
+ if (typeof chosen === "boolean") return chosen;
21692
+ return this.groups.get(id)?.visible !== false;
21693
+ }
21694
+ destroy() {
21695
+ for (const unsub of this.subs) unsub();
21696
+ this.subs.length = 0;
21697
+ }
21698
+ sync() {
21699
+ if (!this.enabled) return;
21700
+ this.renderer.setTimelineMarks([...this.marks.values()], [...this.groups.values()]);
21701
+ }
21702
+ };
21703
+ function validateMark(mark) {
21704
+ if (!mark || typeof mark !== "object") throw new Error("[vela] marks: a mark must be an object");
21705
+ if (typeof mark.id !== "string" || mark.id.length === 0) throw new Error("[vela] marks: `id` must be a non-empty string");
21706
+ if (typeof mark.time !== "number" || !Number.isFinite(mark.time)) throw new Error(`[vela] marks: mark "${mark.id}" needs a finite epoch-ms \`time\``);
21707
+ 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\``);
21708
+ return { ...mark, glyph: { ...mark.glyph } };
21709
+ }
21710
+
20446
21711
  // src/core/engine/DrawingSeriesService.ts
20447
21712
  var MAX_BARS = 5e3;
20448
21713
  var PAD_FRAC = 0.25;
@@ -20748,6 +22013,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20748
22013
  marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
20749
22014
  });
20750
22015
  this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
22016
+ this.marks = new MarksController(this.renderer, this.events);
20751
22017
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
20752
22018
  this.endLoad();
20753
22019
  this.events.emit("data:unresolved", info);
@@ -21612,6 +22878,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
21612
22878
  record.session = void 0;
21613
22879
  record.native?.instance.suspend();
21614
22880
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, false);
22881
+ else if (record.native) this.mountHiddenNativeRow(id, record);
21615
22882
  } else {
21616
22883
  if (record.renderHandle) this.renderer.setIndicatorVisible?.(record.renderHandle, true);
21617
22884
  record.pendingStructural = true;
@@ -21699,6 +22966,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
21699
22966
  this.unresolvedUnsub = null;
21700
22967
  this.feed.destroy?.();
21701
22968
  this.drawings.destroy();
22969
+ this.marks.destroy();
21702
22970
  this.renderer.destroy();
21703
22971
  this.events.clear();
21704
22972
  }
@@ -21812,6 +23080,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
21812
23080
  overlay: d.overlay,
21813
23081
  paneHint: d.paneHint,
21814
23082
  native: { type: record.native.type },
23083
+ ...d.legend === false ? { legend: false } : {},
21815
23084
  ...out.paneAxis != null ? { paneAxis: out.paneAxis } : {},
21816
23085
  series: out.series ?? [],
21817
23086
  fills: out.fills ?? [],
@@ -21834,9 +23103,17 @@ var EngineOrchestrator = class _EngineOrchestrator {
21834
23103
  * over it in place (`pendingStructural`), clears the spinner, and only THEN fires
21835
23104
  * `indicator:added`/`ready` — so event semantics and `inspect()` (which skips
21836
23105
  * loading records) still mean "the indicator produced output".
23106
+ *
23107
+ * A HIDDEN record mounts too — dimmed, no spinner (its session never starts while
23108
+ * hidden, so nothing is computing and no model will ever arrive to mount the row
23109
+ * later). Without this an indicator ADDED hidden (a restored ledger/ext entry) had
23110
+ * no legend row at all: invisible AND unreachable — the eye that unhides it never
23111
+ * existed. The hidden mount announces immediately for the same reason: the "first
23112
+ * computed model" that normally announces cannot come until the indicator is shown,
23113
+ * and host UIs (object tree, landing watchers) must know it exists NOW.
21837
23114
  */
21838
23115
  mountLoadingPlaceholder(id, record) {
21839
- if (record.renderHandle || record.hidden || !record.prepared) return;
23116
+ if (record.renderHandle || !record.prepared) return;
21840
23117
  const meta = record.prepared.meta;
21841
23118
  const model = {
21842
23119
  id,
@@ -21860,8 +23137,34 @@ var EngineOrchestrator = class _EngineOrchestrator {
21860
23137
  this.ensurePaneFor(paneId);
21861
23138
  record.renderHandle = this.renderer.mountIndicator(model);
21862
23139
  record.pendingStructural = true;
23140
+ if (record.hidden) {
23141
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
23142
+ this.announce(record, this.handles.get(id));
23143
+ return;
23144
+ }
21863
23145
  this.setLoading(record, true);
21864
23146
  }
23147
+ /**
23148
+ * Mount the legend row for a NATIVE indicator that is being hidden BEFORE it ever
23149
+ * started (a restored-hidden ledger entry: `startNativeIndicator` bails on hidden
23150
+ * records, so no model — and therefore no row — would ever mount). The native
23151
+ * counterpart of {@link mountLoadingPlaceholder}'s hidden branch: an empty model
23152
+ * carries the title + inputs schema, the renderer marks the row hidden, and the
23153
+ * announce makes the indicator visible to host UIs. Showing later STARTS the
23154
+ * instance (the `started` flag path) and its first emit remounts over this row.
23155
+ */
23156
+ mountHiddenNativeRow(id, record) {
23157
+ if (record.renderHandle || !record.native) return;
23158
+ const model = this.buildNativeModel(record, {});
23159
+ const paneId = this.routePane(id, model, record.options ?? {});
23160
+ this.placeModel(model, id, paneId);
23161
+ record.model = model;
23162
+ this.ensurePaneFor(paneId);
23163
+ record.renderHandle = this.renderer.mountIndicator(model);
23164
+ record.pendingStructural = true;
23165
+ this.renderer.setIndicatorVisible?.(record.renderHandle, false);
23166
+ this.announce(record, this.handles.get(id));
23167
+ }
21865
23168
  /** Flip the record's loading state and reflect it in the legend row (spinner on/off). */
21866
23169
  setLoading(record, loading) {
21867
23170
  record.loading = loading;
@@ -21896,6 +23199,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
21896
23199
  for (const r of this.registry.all()) {
21897
23200
  const model = r.model;
21898
23201
  if (!model) continue;
23202
+ if (model.legend === false) continue;
21899
23203
  const paneId = model.paneId ?? "price";
21900
23204
  if (!byPane.has(paneId)) byPane.set(paneId, []);
21901
23205
  byPane.get(paneId).push({
@@ -24881,6 +26185,7 @@ var Vela = class {
24881
26185
  if (Object.keys(defaults).length > 0) this.rendererControl.set(defaults);
24882
26186
  this.panesControl = new PanesControl(this.orchestrator);
24883
26187
  this.drawingsControl = new DrawingsControl(this.orchestrator.drawings);
26188
+ this.marksControl = new MarksControl(this.orchestrator.marks);
24884
26189
  }
24885
26190
  /**
24886
26191
  * Register a scripting engine so `addIndicator({ language })` can run that
@@ -25099,6 +26404,17 @@ var Vela = class {
25099
26404
  get drawings() {
25100
26405
  return this.drawingsControl;
25101
26406
  }
26407
+ /**
26408
+ * The chart's timeline-marks control surface: host events pinned to a bar and shown
26409
+ * as glyphs on a lane above the time axis, each opening a popup on click —
26410
+ * `chart.marks.add({ id, time, glyph, title, content })`, `chart.marks.set(list)`,
26411
+ * `chart.marks.defineGroup({ id, label })`. Marks are data, not user state: re-supply
26412
+ * them on `market:changed`. On a renderer without the `timelineMarks` capability the
26413
+ * model still fills but nothing paints (`chart.marks.supported`).
26414
+ */
26415
+ get marks() {
26416
+ return this.marksControl;
26417
+ }
25102
26418
  on(event, handler) {
25103
26419
  return this.orchestrator.events.on(event, handler);
25104
26420
  }
@@ -25157,4 +26473,4 @@ function resolveElement(container) {
25157
26473
  return element;
25158
26474
  }
25159
26475
 
25160
- export { BUILTIN_PRICE_STYLES, BarStore, CachingDataFeed, DARK_THEME, DataControl, DrawingStore, DrawingToolbar, DrawingsControl, LEGEND_AT_TOP_ATTR, LIGHT_THEME, MultiProviderFeed, NativeRenderer, RendererControl, TIMEZONES, TypedEventBus, Vela, applyAttributionMarkTheme, buildToolbar, createAttributionMark, createCustomMark, defaultMemberOf, defaultToolbar, groupKeyOf, groupMembers, isGroupRow, normalizeSession, normalizeTimezone, parseSymbol, priceStyleIds, registerBuiltinChartTypes, resolveTheme, sharedBarStore, timeframeToMs, tzButtonLabel, tzMenuLabel, tzOffset };
26476
+ export { BUILTIN_PRICE_STYLES, BarStore, CachingDataFeed, DARK_THEME, DataControl, DrawingStore, DrawingToolbar, DrawingsControl, LEGEND_AT_TOP_ATTR, LIGHT_THEME, MarksControl, MultiProviderFeed, NativeRenderer, RendererControl, SecondClock, TIMEZONES, TypedEventBus, Vela, applyAttributionMarkTheme, buildToolbar, createAttributionMark, createCustomMark, defaultMemberOf, defaultToolbar, groupKeyOf, groupMembers, isGroupRow, normalizeSession, normalizeTimezone, parseSymbol, priceStyleIds, registerBuiltinChartTypes, resolveTheme, sharedBarStore, timeframeToMs, tzButtonLabel, tzMenuLabel, tzOffset };