@luxalgo/vela 0.6.16 → 0.6.18

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 (46) hide show
  1. package/README.md +4 -0
  2. package/dist/{DataProvider-XLdJB86x.d.ts → DataProvider-Cb-2lC5O.d.ts} +1 -1
  3. package/dist/{DataProvider-xH2VBcFT.d.cts → DataProvider-UtWw2CgJ.d.cts} +1 -1
  4. package/dist/{chunk-GIJYO4LO.js → chunk-22QJKGXX.js} +666 -189
  5. package/dist/{chunk-SEOO6YG7.js → chunk-BXF7VNNE.js} +181 -111
  6. package/dist/{chunk-6DPAYEGO.js → chunk-CAFCLMPF.js} +1 -0
  7. package/dist/{chunk-I77F2SVE.js → chunk-IFJJPXSV.js} +1 -1
  8. package/dist/{chunk-MGU3USTP.js → chunk-T5Z5YUCF.js} +1 -1
  9. package/dist/{chunk-Q3TFJNJW.js → chunk-XCBJU674.js} +2 -2
  10. package/dist/{contributions-BEkxwA5F.d.cts → contributions-5nz3PLRe.d.ts} +9 -4
  11. package/dist/{contributions-CoAX7_SF.d.ts → contributions-DEPRtkmG.d.cts} +9 -4
  12. package/dist/index.cjs +662 -185
  13. package/dist/index.d.cts +15 -6
  14. package/dist/index.d.ts +15 -6
  15. package/dist/index.js +4 -4
  16. package/dist/{options-bdSAtp7y.d.ts → options-BCRmYALw.d.cts} +24 -5
  17. package/dist/{options-bdSAtp7y.d.cts → options-BCRmYALw.d.ts} +24 -5
  18. package/dist/{plugin-CNUgbRNq.d.ts → plugin-CUtfLeqz.d.ts} +3 -3
  19. package/dist/{plugin-Dxyq35Zf.d.cts → plugin-DVUaROPo.d.cts} +3 -3
  20. package/dist/plugin.cjs +1 -0
  21. package/dist/plugin.d.cts +4 -4
  22. package/dist/plugin.d.ts +4 -4
  23. package/dist/plugin.js +2 -2
  24. package/dist/providers/binance.d.cts +2 -2
  25. package/dist/providers/binance.d.ts +2 -2
  26. package/dist/providers/coinbase.d.cts +2 -2
  27. package/dist/providers/coinbase.d.ts +2 -2
  28. package/dist/providers/hyperliquid.d.cts +2 -2
  29. package/dist/providers/hyperliquid.d.ts +2 -2
  30. package/dist/{statusline-model-CbPza-Wc.d.cts → statusline-model-7ECSDtgt.d.cts} +3 -3
  31. package/dist/{statusline-model-D7cWkw8A.d.ts → statusline-model-D1Fhs8Dk.d.ts} +3 -3
  32. package/dist/ui.cjs +1 -0
  33. package/dist/ui.d.cts +1 -1
  34. package/dist/ui.d.ts +1 -1
  35. package/dist/ui.js +3 -3
  36. package/dist/vela.global.js +662 -185
  37. package/dist/vela.global.min.js +52 -53
  38. package/dist/widget.cjs +839 -291
  39. package/dist/widget.d.cts +39 -12
  40. package/dist/widget.d.ts +39 -12
  41. package/dist/widget.js +7 -7
  42. package/dist/workspace.cjs +839 -291
  43. package/dist/workspace.d.cts +5 -5
  44. package/dist/workspace.d.ts +5 -5
  45. package/dist/workspace.js +6 -6
  46. package/package.json +1 -1
@@ -1234,6 +1234,7 @@ registerIcon("star-filled", S('<path d="M8 2.2l1.75 3.55 3.9.55-2.8 2.75.65 3.9L
1234
1234
  registerIcon("grip", S('<circle cx="6" cy="3.5" r="1"/><circle cx="10" cy="3.5" r="1"/><circle cx="6" cy="8" r="1"/><circle cx="10" cy="8" r="1"/><circle cx="6" cy="12.5" r="1"/><circle cx="10" cy="12.5" r="1"/>', 'fill="currentColor" stroke="none"'));
1235
1235
  registerIcon("kebab", S('<circle cx="8" cy="3.2" r="1.2"/><circle cx="8" cy="8" r="1.2"/><circle cx="8" cy="12.8" r="1.2"/>', 'fill="currentColor" stroke="none"'));
1236
1236
  registerIcon("burger", S('<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/>'));
1237
+ registerIcon("info", S('<path d="M8 7.4v4.4"/><circle cx="8" cy="4.6" r="0.9" fill="currentColor" stroke="none"/>'));
1237
1238
  registerIcon("reset", S('<path d="M2 8a6 6 0 1 0 6-6 6.5 6.5 0 0 0-4.5 1.83L2 5.33"/><path d="M2 2v3.33h3.33"/>'));
1238
1239
  registerIcon("pane-collapse", S('<path d="M2.6 9.4h4v4M13.4 6.6h-4v-4"/><path d="m9.4 6.6 4.2-4.2M2.4 13.6l4.2-4.2"/>'));
1239
1240
  registerIcon("pane-expand", S('<path d="M9.8 2.4h3.8v3.8M6.2 13.6H2.4V9.8"/><path d="m13.6 2.4-4.4 4.4M2.4 13.6l4.4-4.4"/>'));
@@ -5038,15 +5039,15 @@ function candleOverrideFor(style, bags) {
5038
5039
  if (!hasOwnCandlePaint(style)) return null;
5039
5040
  const bag = bags[style] ?? {};
5040
5041
  const color = (v) => isColor(v) ? v : null;
5041
- const bool = (v) => isBool(v) ? v : null;
5042
+ const bool2 = (v) => isBool(v) ? v : null;
5042
5043
  return {
5043
5044
  upColor: color(bag.candleUpColor),
5044
5045
  downColor: color(bag.candleDownColor),
5045
- bodyVisible: bool(bag.candleBodyVisible),
5046
- borderVisible: bool(bag.candleBorderVisible),
5046
+ bodyVisible: bool2(bag.candleBodyVisible),
5047
+ borderVisible: bool2(bag.candleBorderVisible),
5047
5048
  borderUpColor: color(bag.candleBorderUpColor),
5048
5049
  borderDownColor: color(bag.candleBorderDownColor),
5049
- wickVisible: bool(bag.candleWickVisible),
5050
+ wickVisible: bool2(bag.candleWickVisible),
5050
5051
  wickUpColor: color(bag.candleWickUpColor),
5051
5052
  wickDownColor: color(bag.candleWickDownColor)
5052
5053
  };
@@ -5191,7 +5192,8 @@ function mergeConfig(base, patch) {
5191
5192
  labelsVisible: isBool(ps.labelsVisible) ? ps.labelsVisible : base.priceScale.labelsVisible,
5192
5193
  currentPriceLine: isBool(ps.currentPriceLine) ? ps.currentPriceLine : base.priceScale.currentPriceLine,
5193
5194
  priceLabel: isBool(ps.priceLabel) ? ps.priceLabel : base.priceScale.priceLabel,
5194
- countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown
5195
+ countdown: isBool(ps.countdown) ? ps.countdown : base.priceScale.countdown,
5196
+ animateLastPrice: isBool(ps.animateLastPrice) ? ps.animateLastPrice : base.priceScale.animateLastPrice
5195
5197
  },
5196
5198
  panes: {
5197
5199
  separatorColor: isColor(panes.separatorColor) ? panes.separatorColor : base.panes.separatorColor
@@ -12032,6 +12034,9 @@ function paneLabel(pane, fallbackTitle) {
12032
12034
  function indicatorLabel(i, fallbackTitle) {
12033
12035
  return i.title || fallbackTitle(i.id) || i.id;
12034
12036
  }
12037
+ function indicatorRowLabel(i, fallbackTitle) {
12038
+ return i.shorttitle || indicatorLabel(i, fallbackTitle);
12039
+ }
12035
12040
  function paneRows(pane) {
12036
12041
  return pane.items.flatMap((it) => it.kind === "row" ? [it.row] : []);
12037
12042
  }
@@ -12163,7 +12168,7 @@ function paneItems(snap, pane, units) {
12163
12168
  const indicatorRow = (i) => ({
12164
12169
  kind: "indicator",
12165
12170
  id: i.id,
12166
- label: indicatorLabel(i, snap.handleTitle),
12171
+ label: indicatorRowLabel(i, snap.handleTitle),
12167
12172
  visible: snap.indicatorVisible(i.id),
12168
12173
  ownScale: i.ownScale
12169
12174
  });
@@ -12576,9 +12581,13 @@ var CSS6 = `
12576
12581
  .vela-ot-empty { padding: 20px 10px; text-align: center; color: var(--vela-fg-muted); font-size: 12px; }
12577
12582
 
12578
12583
  /* \u2500\u2500 drawing groups \u2500\u2500 */
12579
- /* One top-level entry in a pane's drawing list: a lone drawing, or a whole group block. The
12580
- transparent border reserves the outline used when a drawing is dropped into the group. */
12581
- .vela-ot-unit { border: 1px solid transparent; border-radius: 6px; }
12584
+ /* One top-level entry in a pane's drawing list: a lone drawing, or a whole group block. No
12585
+ border of its own \u2014 units stack flush, so consecutive highlighted rows read as one block; the
12586
+ drop-into-group outline is drawn inside the box instead (see the drag-and-drop rules). */
12587
+ .vela-ot-unit { border-radius: 6px; }
12588
+ /* Adjacent picked rows merge into one contiguous highlight: the shared edge loses its rounding. */
12589
+ .vela-ot-unit:has(> .vela-ot-row[data-picked]) + .vela-ot-unit > .vela-ot-row[data-picked] { border-top-left-radius: 0; border-top-right-radius: 0; }
12590
+ .vela-ot-unit:has(+ .vela-ot-unit > .vela-ot-row[data-picked]) > .vela-ot-row[data-picked] { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
12582
12591
  .vela-ot-row[data-row-kind='group'] > .vela-icon { width: 12px; font-size: 11px; }
12583
12592
  /* A member sits indented under its group's header. */
12584
12593
  .vela-ot-subrow { padding-left: 26px; }
@@ -12635,8 +12644,10 @@ var CSS6 = `
12635
12644
  .vela-ot-gap[data-drop] { height: 4px; background: var(--vela-fg-bright); }
12636
12645
  .vela-ot-gap[data-drop]::before { display: none; }
12637
12646
  /* A whole container accepts the drop: merging into a pane, or a pane with no drawings yet.
12638
- The pane block's transparent border reserves the room for this outline. */
12647
+ The pane block's transparent border reserves the room for this outline; a group unit has no
12648
+ border to color, so it draws the same line as an inset outline (no layout footprint). */
12639
12649
  .vela-ot [data-drop='target'] { border-color: var(--vela-fg-bright); background: var(--vela-hover); }
12650
+ .vela-ot-unit[data-drop='target'] { outline: 1px solid var(--vela-fg-bright); outline-offset: -1px; }
12640
12651
  /* Where a reorder would insert. */
12641
12652
  .vela-ot [data-drop='before'] { box-shadow: inset 0 2px 0 var(--vela-fg-bright); }
12642
12653
  .vela-ot [data-drop='after'] { box-shadow: inset 0 -2px 0 var(--vela-fg-bright); }
@@ -12699,7 +12710,8 @@ var ObjectTree = class extends SidePanel {
12699
12710
  super(host, "Object tree", "vela-ot");
12700
12711
  this.iconFor = iconFor;
12701
12712
  this.chart = null;
12702
- this.selectedDrawing = null;
12713
+ /** What the CHART has selected — every member of a multi-selection, mirrored as rows. */
12714
+ this.selectedDrawings = /* @__PURE__ */ new Set();
12703
12715
  this.symbolName = "";
12704
12716
  /** The raw (possibly venue-prefixed) symbol — what icon resolution routes on. */
12705
12717
  this.symbolRaw = "";
@@ -12750,12 +12762,9 @@ var ObjectTree = class extends SidePanel {
12750
12762
  this.unsubs.push(chart.on(ev, () => this.refresh()));
12751
12763
  }
12752
12764
  this.unsubs.push(
12753
- chart.on("drawing:selected", ({ id }) => {
12754
- this.selectedDrawing = id;
12755
- if (!this.syncingSelection) {
12756
- this.picked.clear();
12757
- if (id) this.picked.add(id);
12758
- }
12765
+ chart.on("drawing:selected", ({ ids }) => {
12766
+ this.selectedDrawings = new Set(ids);
12767
+ if (!this.syncingSelection) this.picked = new Set(ids);
12759
12768
  this.refresh();
12760
12769
  })
12761
12770
  );
@@ -13103,7 +13112,7 @@ var ObjectTree = class extends SidePanel {
13103
13112
  el.dataset.drag = "1";
13104
13113
  el.title = "Drag to restack, or onto another pane to move it there";
13105
13114
  if (inGroup) el.classList.add("vela-ot-subrow");
13106
- if (d.id === this.selectedDrawing) el.dataset.selected = "1";
13115
+ if (this.selectedDrawings.has(d.id)) el.dataset.selected = "1";
13107
13116
  if (this.picked.has(d.id)) el.dataset.picked = "1";
13108
13117
  el.addEventListener("click", (e) => this.onDrawClick(e, d.id));
13109
13118
  return el;
@@ -14267,6 +14276,20 @@ var SymbolPicker = class {
14267
14276
  this.seed = initialQuery;
14268
14277
  this.dialog.show();
14269
14278
  }
14279
+ get isOpen() {
14280
+ return this.dialog.open;
14281
+ }
14282
+ /** Text typed at the picker that never reached its field: closed, it opens seeded
14283
+ * with the text; open (the field takes focus a tick after the dialog does, and a
14284
+ * fast typist's next key lands in that tick), it extends the query. */
14285
+ type(text) {
14286
+ if (!this.isOpen) {
14287
+ this.open(text);
14288
+ return;
14289
+ }
14290
+ this.input.value = (this.input.value + text).toUpperCase();
14291
+ this.refresh();
14292
+ }
14270
14293
  close() {
14271
14294
  this.dialog.hide();
14272
14295
  }
@@ -14665,6 +14688,20 @@ var TimeframeQuick = class {
14665
14688
  this.input.setSelectionRange(this.input.value.length, this.input.value.length);
14666
14689
  }, 0);
14667
14690
  }
14691
+ get isOpen() {
14692
+ return this.dialog.open;
14693
+ }
14694
+ /** Text typed at the entry that never reached its field: closed, it opens seeded
14695
+ * with the text; open (the field takes focus a tick after the dialog does, and a
14696
+ * fast typist's next key lands in that tick), it extends the entry. */
14697
+ type(text) {
14698
+ if (!this.isOpen) {
14699
+ this.open(text);
14700
+ return;
14701
+ }
14702
+ this.input.value += text;
14703
+ this.renderHint();
14704
+ }
14668
14705
  close() {
14669
14706
  this.dialog.hide();
14670
14707
  }
@@ -16735,15 +16772,18 @@ var DrawingController = class {
16735
16772
  next = [...ids];
16736
16773
  }
16737
16774
  this.selectedIds = next.filter((id) => this.store.has(id));
16775
+ this.announceSelection();
16776
+ }
16777
+ /** Push the selection to the renderer and announce it (primary + the full list). */
16778
+ announceSelection() {
16738
16779
  this.port?.setSelection(this.selectedIds);
16739
- this.events.emit("drawing:selected", { id: this.selectedIds[0] ?? null });
16780
+ this.events.emit("drawing:selected", { id: this.selectedIds[0] ?? null, ids: [...this.selectedIds] });
16740
16781
  }
16741
16782
  /** Restore a history snapshot, reconciling selection against what survived. */
16742
16783
  restoreSnapshot(doc) {
16743
16784
  this.store.load(doc);
16744
16785
  this.selectedIds = this.selectedIds.filter((id) => this.store.has(id));
16745
- this.port?.setSelection(this.selectedIds);
16746
- this.events.emit("drawing:selected", { id: this.selectedIds[0] ?? null });
16786
+ this.announceSelection();
16747
16787
  }
16748
16788
  /** Delete drawings as one undo step; prune them from the selection. */
16749
16789
  deleteMany(ids) {
@@ -16876,6 +16916,9 @@ var DrawingController = class {
16876
16916
  case "duplicate":
16877
16917
  this.duplicate(i.ids);
16878
16918
  break;
16919
+ case "clone":
16920
+ if (this.enabled && i.docs.length) this.insertClones(i.docs);
16921
+ break;
16879
16922
  case "copy":
16880
16923
  this.copy(i.ids);
16881
16924
  break;
@@ -17126,6 +17169,23 @@ function attachChromeTooltip(anchor, opts) {
17126
17169
  clear();
17127
17170
  };
17128
17171
  }
17172
+ function chromeHint(text, opts) {
17173
+ const px = opts.size ?? 16;
17174
+ const el = document.createElement("span");
17175
+ el.className = "vela-hint";
17176
+ el.setAttribute("role", "img");
17177
+ el.setAttribute("aria-label", text);
17178
+ el.innerHTML = iconAt("info", px);
17179
+ el.style.cssText = `flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:${px}px;height:${px}px;border-radius:50%;cursor:help;user-select:none;background:var(--vela-hover);color:var(--vela-fg-muted);transition:background var(--vela-dur-fast) ease,color var(--vela-dur-fast) ease;`;
17180
+ const hover = (on) => {
17181
+ el.style.background = on ? "var(--vela-active)" : "var(--vela-hover)";
17182
+ el.style.color = on ? "var(--vela-fg-bright)" : "var(--vela-fg-muted)";
17183
+ };
17184
+ el.addEventListener("pointerenter", () => hover(true));
17185
+ el.addEventListener("pointerleave", () => hover(false));
17186
+ const dispose = attachChromeTooltip(el, { host: opts.host, theme: opts.theme, text: () => text, wrap: true, delayMs: 250 });
17187
+ return { el, dispose };
17188
+ }
17129
17189
 
17130
17190
  // src/renderers/native/drawings/DrawingToolbar.ts
17131
17191
  var TOOLBAR_WIDTH = 44;
@@ -18131,6 +18191,24 @@ function localStorageAdapter(storageKey) {
18131
18191
  };
18132
18192
  }
18133
18193
 
18194
+ // src/core/options.ts
18195
+ var normalizeSession = (v) => v === "regular" || v === "extended" ? v : void 0;
18196
+ var LIVE_BAR_EASE_DEFAULT_MS = 90;
18197
+ var LIVE_BAR_EASE_MAX_MS = 1e3;
18198
+ function resolveLiveBarEaseMs(value) {
18199
+ if (value === true) return LIVE_BAR_EASE_DEFAULT_MS;
18200
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
18201
+ return Math.min(value, LIVE_BAR_EASE_MAX_MS);
18202
+ }
18203
+ function resolveAnimations(animations) {
18204
+ if (typeof animations === "boolean") return { animZoom: animations, animPan: animations, animLiveBar: 0 };
18205
+ return {
18206
+ animZoom: animations?.zoom ?? true,
18207
+ animPan: animations?.pan ?? true,
18208
+ animLiveBar: resolveLiveBarEaseMs(animations?.liveBar)
18209
+ };
18210
+ }
18211
+
18134
18212
  // src/core/model/series.ts
18135
18213
  function isLineLikeSeries(spec) {
18136
18214
  return spec.kind !== "candle" && spec.kind !== "bar" && spec.kind !== "markers";
@@ -19807,7 +19885,12 @@ var EngineOrchestrator = class _EngineOrchestrator {
19807
19885
  if (!model) continue;
19808
19886
  const paneId = model.paneId ?? "price";
19809
19887
  if (!byPane.has(paneId)) byPane.set(paneId, []);
19810
- byPane.get(paneId).push({ id: r.id, title: model.title || r.title, ownScale: model.ownScale === true });
19888
+ byPane.get(paneId).push({
19889
+ id: r.id,
19890
+ title: model.title || r.title,
19891
+ ...model.shorttitle ? { shorttitle: model.shorttitle } : {},
19892
+ ownScale: model.ownScale === true
19893
+ });
19811
19894
  }
19812
19895
  const panes = [];
19813
19896
  for (const paneId of this.paneOrder) {
@@ -21096,14 +21179,11 @@ var IndicatorInputsDialog = class {
21096
21179
  buildTextField(id, current, emit) {
21097
21180
  return buildFieldControl({ kind: "text", id, value: current, onChange: emit }).el;
21098
21181
  }
21099
- /** A hoverable ⓘ affordance carrying an input's `tooltip` (themed chrome tip; wraps). */
21182
+ /** The shared ⓘ hint carrying an input's `tooltip` (see {@link chromeHint}). */
21100
21183
  infoButton(tooltip2) {
21101
- const b = document.createElement("span");
21102
- b.textContent = "i";
21103
- this.dialogTips.push(attachChromeTooltip(b, { host: this.host.container, theme: () => this.host.theme(), text: () => tooltip2, wrap: true, delayMs: 250 }));
21104
- b.className = "vela-ind-hint";
21105
- b.style.cssText = "flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;font-weight:600;font-size:11px;font-family:inherit;line-height:1;cursor:help;user-select:none;";
21106
- return b;
21184
+ const { el, dispose } = chromeHint(tooltip2, { host: this.host.container, theme: () => this.host.theme(), size: 18 });
21185
+ this.dialogTips.push(dispose);
21186
+ return el;
21107
21187
  }
21108
21188
  /** A dropdown over plain string options (value === label) — a thin case of {@link selectPairs}. */
21109
21189
  select(id, options, current, emit) {
@@ -21540,8 +21620,6 @@ function ensureDialogStyles() {
21540
21620
  ${overlayScrollbarCss(".vela-dialog.vela-ind-dialog *", 9)}
21541
21621
  .vela-ind-tab{font-weight:600;font-size:13px;line-height:20px;transition:color var(--vela-dur-fast) ease,border-color var(--vela-dur-fast) ease;}
21542
21622
  .vela-ind-tab:not(.vela-ind-tab-active):hover{color:var(--vela-fg-bright);}
21543
- .vela-ind-hint{background:var(--vela-hover);color:var(--vela-fg-muted);transition:background var(--vela-dur-fast) ease,color var(--vela-dur-fast) ease;}
21544
- .vela-ind-hint:hover{background:var(--vela-active);color:var(--vela-fg-bright);}
21545
21623
  .vela-ind-ctl,.vela-ind-close{background-color:transparent;color:inherit;transition:color var(--vela-dur-fast) ease,background-color var(--vela-dur-fast) ease;}
21546
21624
  .vela-ind-ctl svg,.vela-ind-close svg{width:${LEGEND_ICON_PX}px;height:${LEGEND_ICON_PX}px;display:block;flex:none;stroke-width:1;}
21547
21625
  .vela-ind-ctl:hover{background-color:var(--vela-hover-strong);}
@@ -24445,7 +24523,7 @@ var InputController = class {
24445
24523
  if (e.repeat || e.key !== "Shift" && e.key !== "Control" && e.key !== "Meta") return;
24446
24524
  if (Number.isNaN(this.cursorX)) return;
24447
24525
  if (this.dragging && this.region !== "drawing") return;
24448
- this.deps.drawingsPointerMove?.(this.cursorX, this.cursorY, this.snapMode(e), e.shiftKey);
24526
+ this.deps.drawingsPointerMove?.(this.cursorX, this.cursorY, this.snapMode(e), e.shiftKey, e.ctrlKey || e.metaKey);
24449
24527
  };
24450
24528
  this.onDown = (e) => {
24451
24529
  if (e.button === 1) {
@@ -24477,7 +24555,7 @@ var InputController = class {
24477
24555
  this.startY = y;
24478
24556
  if (this.deps.drawingsClaim?.(x, y)) {
24479
24557
  this.region = "drawing";
24480
- this.deps.drawingsPointerDown?.(x, y, this.snapMode(e), e.shiftKey);
24558
+ this.deps.drawingsPointerDown?.(x, y, this.snapMode(e), e.shiftKey, e.ctrlKey || e.metaKey);
24481
24559
  this.capture(e.pointerId);
24482
24560
  return;
24483
24561
  }
@@ -24486,6 +24564,11 @@ var InputController = class {
24486
24564
  this.capture(e.pointerId);
24487
24565
  return;
24488
24566
  }
24567
+ if ((e.ctrlKey || e.metaKey) && this.regionAt(x, y) === "data" && this.deps.drawingsMarqueeStart?.(x, y)) {
24568
+ this.region = "drawing";
24569
+ this.capture(e.pointerId);
24570
+ return;
24571
+ }
24489
24572
  const vp = this.deps.getCoords().getViewport();
24490
24573
  this.deps.apply(vp);
24491
24574
  this.startRightOffset = vp.rightOffset;
@@ -24534,7 +24617,7 @@ var InputController = class {
24534
24617
  this.deps.apply({ barSpacing, rightOffset: this.startRightOffset });
24535
24618
  } else if (this.region === "drawing") {
24536
24619
  if (Math.abs(x - this.startX) > slop || Math.abs(y - this.startY) > slop) this.moved = true;
24537
- this.deps.drawingsPointerMove?.(x, y, this.snapMode(e), e.shiftKey);
24620
+ this.deps.drawingsPointerMove?.(x, y, this.snapMode(e), e.shiftKey, e.ctrlKey || e.metaKey);
24538
24621
  } else {
24539
24622
  const coords = this.deps.getCoords();
24540
24623
  const vp = coords.getViewport();
@@ -24557,7 +24640,7 @@ var InputController = class {
24557
24640
  const drawCursor = this.deps.drawingsCursor?.(x, y);
24558
24641
  const r = this.regionAt(x, y);
24559
24642
  this.el.style.cursor = drawCursor ?? (r === "price" ? "ns-resize" : r === "time" ? "ew-resize" : r === "separator" ? "row-resize" : "");
24560
- this.deps.drawingsPointerMove?.(x, y, this.snapMode(e), e.shiftKey);
24643
+ this.deps.drawingsPointerMove?.(x, y, this.snapMode(e), e.shiftKey, e.ctrlKey || e.metaKey);
24561
24644
  }
24562
24645
  if (e.pointerType !== "touch") this.deps.onPointerMove(x, y);
24563
24646
  };
@@ -24898,12 +24981,12 @@ function defaultTradeMarkersState() {
24898
24981
  function mergeTradeMarkersState(base, patch) {
24899
24982
  const p = patch && typeof patch === "object" ? patch : {};
24900
24983
  const c = p.colors && typeof p.colors === "object" ? p.colors : {};
24901
- const bool = (v, fb) => typeof v === "boolean" ? v : fb;
24984
+ const bool2 = (v, fb) => typeof v === "boolean" ? v : fb;
24902
24985
  const color = (v, fb) => typeof v === "string" && v.trim().length > 0 ? v : fb;
24903
24986
  return {
24904
- visible: bool(p.visible, base.visible),
24905
- labels: bool(p.labels, base.labels),
24906
- qty: bool(p.qty, base.qty),
24987
+ visible: bool2(p.visible, base.visible),
24988
+ labels: bool2(p.labels, base.labels),
24989
+ qty: bool2(p.qty, base.qty),
24907
24990
  colors: {
24908
24991
  long: color(c.long, base.colors.long),
24909
24992
  short: color(c.short, base.colors.short),
@@ -27587,6 +27670,8 @@ var BUILTIN_SETTINGS_IDS = [
27587
27670
  "symbol.style.baseline.fill-bottom",
27588
27671
  "symbol.style.baseline.base-level",
27589
27672
  "symbol.style.baseline.width",
27673
+ "symbol.animation",
27674
+ "symbol.animation.price-changes",
27590
27675
  "symbol.timezone",
27591
27676
  "scales",
27592
27677
  "scales.price-scale",
@@ -27729,8 +27814,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
27729
27814
  .vela-sd-mobile .vela-select-trigger,.vela-sd-mobile .vela-num input,.vela-sd-mobile .vela-width-field{height:34px;}
27730
27815
  .vela-sd-mobile .vela-sd-close{width:40px;height:40px;}
27731
27816
  .vela-sd-mobile .vela-sd-btn{height:38px;}
27732
- .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}
27733
- `;
27817
+ .vela-sd-mobile .vela-sd-row span,.vela-sd-mobile .vela-sd-bool span,.vela-sd-mobile .vela-field-label{white-space:normal !important;}`;
27734
27818
  if (!existing) document.head.appendChild(st);
27735
27819
  }
27736
27820
  var SettingsDialog = class {
@@ -27751,6 +27835,8 @@ var SettingsDialog = class {
27751
27835
  this.themeControl = null;
27752
27836
  /** The built tabs, by title — how `showSection` reaches a pane while the dialog is open. */
27753
27837
  this.tabs = [];
27838
+ this.hintTips = [];
27839
+ // ⓘ tooltip disposers of the open build (see hint)
27754
27840
  /** Active instance-strip tab per chart type — remembered across dialog rebuilds. */
27755
27841
  this.typeActiveInstance = /* @__PURE__ */ new Map();
27756
27842
  /** Active TOC group per structured pane (`<typeId>/<pane>` → group label). */
@@ -27939,6 +28025,13 @@ var SettingsDialog = class {
27939
28025
  body.append(g);
27940
28026
  }
27941
28027
  showActive(config.series.style);
28028
+ body.append(sid(this.sectionTitle("Animation"), "symbol.animation"));
28029
+ body.append(sid(this.boolRow(
28030
+ "Animate price changes",
28031
+ config.priceScale.animateLastPrice,
28032
+ (v) => this.emit({ priceScale: { animateLastPrice: v } }),
28033
+ this.hint("Glide the live bar to each new price instead of snapping.")
28034
+ ), "symbol.animation.price-changes"));
27942
28035
  body.append(sid(this.sectionTitle("Time zone"), "symbol.timezone"));
27943
28036
  body.append(sid(this.selectRowLabeled("Time zone", normalizeTimezone(config.timeScale.timezone), timezoneOptions(config.timeScale.timezone), (v) => this.emit({ timeScale: { timezone: v } })), "symbol.timezone"));
27944
28037
  const renderHostSections = (placement) => {
@@ -28146,6 +28239,8 @@ var SettingsDialog = class {
28146
28239
  this.ui = null;
28147
28240
  this.root = null;
28148
28241
  this.tabs = [];
28242
+ for (const dispose of this.hintTips) dispose();
28243
+ this.hintTips = [];
28149
28244
  ui?.destroy();
28150
28245
  }
28151
28246
  destroy() {
@@ -28559,23 +28654,31 @@ var SettingsDialog = class {
28559
28654
  }).el
28560
28655
  });
28561
28656
  }
28562
- /** A toggle row: the checkbox sits to the LEFT of its label (never in the control area). */
28563
- boolRow(label, value, onChange) {
28564
- return this.toggleRow(label, value, onChange, []);
28657
+ /** A toggle row: the checkbox sits to the LEFT of its label (never in the control area).
28658
+ * `info` (see {@link hint}) rides after the label. */
28659
+ boolRow(label, value, onChange, info) {
28660
+ return this.toggleRow(label, value, onChange, [], void 0, info);
28565
28661
  }
28566
28662
  /** An enable row: checkbox + label in the label column, dependent controls in the
28567
28663
  * shared control column; the control group dims and ignores input while the toggle
28568
28664
  * is off. With `get`, the row re-reads its state on a 'vela-sync' event. */
28569
- toggleRow(label, value, onToggle, controls, get) {
28665
+ toggleRow(label, value, onToggle, controls, get, info) {
28570
28666
  return fieldRow({
28571
28667
  label,
28572
28668
  labelSize: "sm",
28573
28669
  bool: controls.length === 0,
28574
28670
  toggle: { checked: value, onChange: onToggle, get },
28575
28671
  control: controls,
28672
+ info,
28576
28673
  className: controls.length === 0 ? "vela-sd-bool" : "vela-sd-row"
28577
28674
  });
28578
28675
  }
28676
+ /** The shared ⓘ hint after a row label (see {@link chromeHint}). Disposed with the dialog. */
28677
+ hint(text) {
28678
+ const { el, dispose } = chromeHint(text, { host: this.container, theme: () => this.theme });
28679
+ this.hintTips.push(dispose);
28680
+ return el;
28681
+ }
28579
28682
  numberRow(label, value, min, max, step, onChange) {
28580
28683
  return fieldRow({
28581
28684
  label,
@@ -28743,6 +28846,18 @@ var DrawingPainter = class {
28743
28846
  }
28744
28847
  this.paintHandles(ctx, pts);
28745
28848
  }
28849
+ /** The selection box a Ctrl/Cmd-drag on the empty plot sweeps: a faint fill in the handle
28850
+ * accent under a thin dashed outline. */
28851
+ paintMarquee(ctx, rect) {
28852
+ ctx.save();
28853
+ ctx.fillStyle = withAlpha(HANDLE_BORDER, 0.08);
28854
+ ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
28855
+ ctx.strokeStyle = HANDLE_BORDER;
28856
+ ctx.lineWidth = 1;
28857
+ ctx.setLineDash([4, 3]);
28858
+ ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w, rect.h);
28859
+ ctx.restore();
28860
+ }
28746
28861
  /** A hollow ring marking the candle point the magnet (Ctrl) will snap the next anchor to. */
28747
28862
  paintSnapRing(ctx, x, y, theme) {
28748
28863
  ctx.setLineDash([]);
@@ -30211,6 +30326,15 @@ function topDrawingAt(drawings, x, y, proj, tol = HIT_TOLERANCE) {
30211
30326
  }
30212
30327
  return null;
30213
30328
  }
30329
+ function deletableSelection(selected, drawings) {
30330
+ const ids = [...selected];
30331
+ if (ids.length < 2) return ids;
30332
+ return ids.filter((id) => !drawings.find((d) => d.id === id)?.locked);
30333
+ }
30334
+ function deleteTargets(hit, selected, drawings, withSelection) {
30335
+ if (withSelection && selected.size >= 2 && selected.has(hit.id)) return deletableSelection(selected, drawings);
30336
+ return hit.locked ? [] : [hit.id];
30337
+ }
30214
30338
 
30215
30339
  // src/renderers/native/drawings/DrawingInteraction.ts
30216
30340
  var DRAG_SLOP3 = 3;
@@ -30314,10 +30438,32 @@ var DrawingInteraction = class {
30314
30438
  activeDragId() {
30315
30439
  return this.state.kind === "pressed" && this.state.moved ? this.state.id : null;
30316
30440
  }
30441
+ /** The drawing under a press that has not been released yet (drag or click undecided), or null. */
30442
+ pressedId() {
30443
+ return this.state.kind === "pressed" ? this.state.id : null;
30444
+ }
30445
+ /** The copies a Ctrl-drag is moving (transient — not yet in the store), or null. */
30446
+ dragClones() {
30447
+ return this.state.kind === "pressed" && this.state.moved && this.state.clones ? this.state.clones.map((c) => c.d) : null;
30448
+ }
30449
+ /** The marquee box being swept, normalized (or null when none is in progress). */
30450
+ marqueeRect() {
30451
+ if (this.state.kind !== "marquee") return null;
30452
+ const { x0, y0, x1, y1 } = this.state;
30453
+ return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
30454
+ }
30455
+ /** Ctrl/Cmd-press on the empty plot: start sweeping a selection box. False when a tool is
30456
+ * armed or a gesture is in flight (that path owns the press). */
30457
+ beginMarquee(x, y) {
30458
+ if (this.state.kind !== "idle" || this.deps.activeTool() != null) return false;
30459
+ this.state = { kind: "marquee", x0: x, y0: y, x1: x, y1: y };
30460
+ return true;
30461
+ }
30317
30462
  /** A press: place the next anchor, or grab a drawing/handle (resolved as click or drag on release).
30318
30463
  * `shift` over a drawing toggles it in/out of the selection (multi-select) instead of dragging;
30319
- * while placing a line tool it locks the segment angle to 45° steps. */
30320
- down(x, y, mode = "off", shift2 = false) {
30464
+ * while placing a line tool it locks the segment angle to 45° steps. `mod` (Ctrl/Cmd) over a
30465
+ * drawing also toggles on a click, but a body drag then moves a COPY. */
30466
+ down(x, y, mode = "off", shift2 = false, mod = false) {
30321
30467
  const proj = this.deps.projector();
30322
30468
  if (this.state.kind === "placing") {
30323
30469
  if (this.state.draft.placementMode() !== "click") return;
@@ -30345,7 +30491,50 @@ var DrawingInteraction = class {
30345
30491
  return;
30346
30492
  }
30347
30493
  const handle = hit.hitHandle(x, y, proj, HIT_TOLERANCE);
30348
- this.state = { kind: "pressed", id: hit.id, handle, grab: proj.pxToPoint(x, y, hit.paneId), orig: cloneAnchors(hit), px: x, py: y, moved: false };
30494
+ const riders = [];
30495
+ if (handle < 0 && this.deps.selectedIds().has(hit.id)) {
30496
+ for (const id of this.deps.selectedIds()) {
30497
+ const d = id === hit.id ? null : this.byId(id);
30498
+ if (d && !d.locked && d.visible) riders.push({ id, orig: cloneAnchors(d) });
30499
+ }
30500
+ }
30501
+ this.state = { kind: "pressed", id: hit.id, handle, orig: cloneAnchors(hit), px: x, py: y, moved: false, riders, mod, clones: null };
30502
+ }
30503
+ }
30504
+ /** The data-space delta a body drag from the press to (x, y) means on `paneId` — resolved per
30505
+ * pane so drawings riding along on another pane move by the same PIXELS, not the same price. */
30506
+ bodyDelta(paneId, x, y) {
30507
+ if (this.state.kind !== "pressed") return { dt: 0, dp: 0 };
30508
+ const proj = this.deps.projector();
30509
+ const from = proj.pxToPoint(this.state.px, this.state.py, paneId);
30510
+ const to = proj.pxToPoint(x, y, paneId);
30511
+ return { dt: to.time - from.time, dp: to.price - from.price };
30512
+ }
30513
+ /** The pressed drawing and its riders, each with its anchors at the press. */
30514
+ dragSources(d) {
30515
+ if (this.state.kind !== "pressed") return [];
30516
+ const out = [{ d, orig: this.state.orig }];
30517
+ for (const r of this.state.riders) {
30518
+ const rd = this.byId(r.id);
30519
+ if (rd) out.push({ d: rd, orig: r.orig });
30520
+ }
30521
+ return out;
30522
+ }
30523
+ /** Move the pressed drawing's body (and its riders) — or, on a Ctrl-drag, their copies — to the
30524
+ * cursor. The copies are built on the first move (so a Ctrl-click never creates anything), from
30525
+ * sources that have not moved yet, so their own anchors are the origin to translate from. */
30526
+ dragBody(d, x, y) {
30527
+ if (this.state.kind !== "pressed") return;
30528
+ this.snapAt = null;
30529
+ if (this.state.mod && !this.state.clones) {
30530
+ this.state.clones = this.dragSources(d).flatMap((s) => {
30531
+ const c = deserializeDrawing(s.d.serialize());
30532
+ return c ? [{ d: c, orig: cloneAnchors(c) }] : [];
30533
+ });
30534
+ }
30535
+ for (const t of this.state.clones ?? this.dragSources(d)) {
30536
+ const { dt, dp } = this.bodyDelta(t.d.paneId, x, y);
30537
+ t.d.anchors = t.d.translateBody(dt, dp, t.orig);
30349
30538
  }
30350
30539
  }
30351
30540
  /** Pointer move: advance the placing ghost, or live-preview a drag once past the slop.
@@ -30369,12 +30558,20 @@ var DrawingInteraction = class {
30369
30558
  this.deps.changed();
30370
30559
  return;
30371
30560
  }
30561
+ if (this.state.kind === "marquee") {
30562
+ this.state.x1 = x;
30563
+ this.state.y1 = y;
30564
+ this.deps.changed();
30565
+ return;
30566
+ }
30372
30567
  if (this.state.kind !== "pressed") return;
30373
30568
  const d = this.byId(this.state.id);
30374
30569
  if (!d || d.locked) return;
30375
30570
  if (!this.state.moved && Math.abs(x - this.state.px) <= DRAG_SLOP3 && Math.abs(y - this.state.py) <= DRAG_SLOP3) return;
30376
30571
  this.state.moved = true;
30377
- if (this.state.handle >= 0) {
30572
+ if (this.state.handle < 0) {
30573
+ this.dragBody(d, x, y);
30574
+ } else {
30378
30575
  const a = d.anchors[this.state.handle];
30379
30576
  if (a) {
30380
30577
  const pivot = shift2 && ANGLE_SNAP_TYPES.has(d.type) && d.anchors.length === 2 ? d.anchors[1 - this.state.handle] : void 0;
@@ -30382,12 +30579,6 @@ var DrawingInteraction = class {
30382
30579
  applyFree(a, point, d.anchorSchema().slots[this.state.handle]?.free ?? "both");
30383
30580
  }
30384
30581
  d.constrainHandleDrag(this.state.handle);
30385
- } else {
30386
- const pt = this.deps.projector().pxToPoint(x, y, d.paneId);
30387
- this.snapAt = null;
30388
- const dt = pt.time - this.state.grab.time;
30389
- const dp = pt.price - this.state.grab.price;
30390
- d.anchors = d.translateBody(dt, dp, this.state.orig);
30391
30582
  }
30392
30583
  this.deps.changed();
30393
30584
  }
@@ -30407,16 +30598,46 @@ var DrawingInteraction = class {
30407
30598
  }
30408
30599
  return;
30409
30600
  }
30601
+ if (this.state.kind === "marquee") {
30602
+ const rect = this.marqueeRect();
30603
+ this.state = { kind: "idle" };
30604
+ if (rect.w > DRAG_SLOP3 || rect.h > DRAG_SLOP3) this.selectWithin(rect);
30605
+ this.deps.changed();
30606
+ return;
30607
+ }
30410
30608
  if (this.state.kind !== "pressed") return;
30411
- const { id, moved } = this.state;
30609
+ const { id, moved, riders, mod, clones } = this.state;
30412
30610
  this.state = { kind: "idle" };
30413
30611
  const d = this.byId(id);
30414
- if (moved) {
30415
- if (d) this.deps.emit({ kind: "edit", doc: d.serialize() });
30416
- this.deps.changed();
30417
- } else {
30418
- this.deps.openSettings(id, x, y);
30612
+ if (!moved) {
30613
+ if (mod) this.deps.emit({ kind: "select", ids: [id], additive: true });
30614
+ else this.deps.openSettings(id, x, y);
30615
+ return;
30419
30616
  }
30617
+ if (clones) {
30618
+ this.deps.emit({ kind: "clone", docs: clones.map((c) => c.d.serialize()) });
30619
+ } else if (d) {
30620
+ const docs = [d.serialize()];
30621
+ for (const r of riders) {
30622
+ const rd = this.byId(r.id);
30623
+ if (rd) docs.push(rd.serialize());
30624
+ }
30625
+ if (docs.length > 1) this.deps.emit({ kind: "edit-many", docs });
30626
+ else this.deps.emit({ kind: "edit", doc: docs[0] });
30627
+ }
30628
+ this.deps.changed();
30629
+ }
30630
+ /** Add every visible drawing whose on-screen bounds touch `rect` to the selection (a union,
30631
+ * so successive sweeps accumulate). No change when the box touches nothing new. */
30632
+ selectWithin(rect) {
30633
+ const proj = this.deps.projector();
30634
+ const ids = [...this.deps.selectedIds()];
30635
+ for (const d of this.deps.drawings()) {
30636
+ if (!d.visible || ids.includes(d.id)) continue;
30637
+ const b = d.bounds(proj);
30638
+ if (b && rectsIntersect(rect, b)) ids.push(d.id);
30639
+ }
30640
+ if (ids.length > this.deps.selectedIds().size) this.deps.emit({ kind: "select", ids });
30420
30641
  }
30421
30642
  /** Cancel an in-progress placement or drag (Escape). Returns whether anything was cancelled. */
30422
30643
  cancel() {
@@ -30429,8 +30650,15 @@ var DrawingInteraction = class {
30429
30650
  return true;
30430
30651
  }
30431
30652
  if (this.state.kind === "pressed") {
30432
- const d = this.byId(this.state.id);
30433
- if (d && this.state.moved) d.anchors = this.state.orig.map((o) => ({ time: o.time, price: o.price }));
30653
+ if (this.state.moved && !this.state.clones) {
30654
+ const d = this.byId(this.state.id);
30655
+ if (d) for (const s of this.dragSources(d)) s.d.anchors = s.orig.map((o) => ({ time: o.time, price: o.price }));
30656
+ }
30657
+ this.state = { kind: "idle" };
30658
+ this.deps.changed();
30659
+ return true;
30660
+ }
30661
+ if (this.state.kind === "marquee") {
30434
30662
  this.state = { kind: "idle" };
30435
30663
  this.deps.changed();
30436
30664
  return true;
@@ -30545,6 +30773,9 @@ var DrawingInteraction = class {
30545
30773
  function cloneAnchors(d) {
30546
30774
  return d.anchors.map((a) => ({ time: a.time, price: a.price }));
30547
30775
  }
30776
+ function rectsIntersect(a, b) {
30777
+ return a.x <= b.x + b.w && b.x <= a.x + a.w && a.y <= b.y + b.h && b.y <= a.y + a.h;
30778
+ }
30548
30779
  function applyFree(anchor, pt, free) {
30549
30780
  if (free === "both" || free === "x") anchor.time = pt.time;
30550
30781
  if (free === "both" || free === "y") anchor.price = pt.price;
@@ -30930,10 +31161,27 @@ function btn(label, primary, onClick) {
30930
31161
  }
30931
31162
 
30932
31163
  // src/renderers/native/drawings/DrawingSettingsPopup.ts
31164
+ var MIXED = /* @__PURE__ */ Symbol("mixed");
31165
+ function sharedPaths(drawings) {
31166
+ const [first, ...rest] = drawings;
31167
+ const paths = new Set(first?.schema().fields.map((f) => f.path) ?? []);
31168
+ for (const d of rest) {
31169
+ const own = new Set(d.schema().fields.map((f) => f.path));
31170
+ for (const p of paths) if (!own.has(p)) paths.delete(p);
31171
+ }
31172
+ return paths;
31173
+ }
31174
+ function commonValue(drawings, read) {
31175
+ const first = read(drawings[0]);
31176
+ return drawings.every((d) => read(d) === first) ? first : MIXED;
31177
+ }
31178
+ function distinctValues(drawings, read) {
31179
+ return [...new Set(drawings.map(read))];
31180
+ }
30933
31181
  var BTN = 30;
30934
31182
  var ICON = 21;
30935
31183
  var STYLE_ID23 = "vela-drawing-popup-styles";
30936
- var STYLE_REV = "3";
31184
+ var STYLE_REV = "4";
30937
31185
  function ensureStyles5() {
30938
31186
  if (typeof document === "undefined") return;
30939
31187
  const existing = document.getElementById(STYLE_ID23);
@@ -30945,6 +31193,8 @@ function ensureStyles5() {
30945
31193
  .vela-dpop-btn{background:transparent;color:var(--vela-fg-muted);transition:background var(--vela-dur-fast) ease,color var(--vela-dur-fast) ease;}
30946
31194
  .vela-dpop-btn:hover{background:var(--vela-hover-strong);color:var(--vela-fg-bright);}
30947
31195
  .vela-dpop-btn[data-active='1']{background:var(--vela-active);color:var(--vela-fg-bright);}
31196
+ /* Mixed (a multi-selection disagrees): the active fill at half strength behind a dashed ring. */
31197
+ .vela-dpop-btn[data-active='mixed']{background:var(--vela-hover-strong);color:var(--vela-fg-bright);outline:1px dashed var(--vela-fg-muted);outline-offset:-2px;}
30948
31198
  .vela-dpop-item{background:transparent;transition:background var(--vela-dur-fast) ease;}
30949
31199
  .vela-dpop-item:hover{background:var(--vela-hover-strong);}
30950
31200
  .vela-dpop-item[data-active='1']{background:var(--vela-active);}
@@ -30976,7 +31226,7 @@ var DrawingSettingsPopup = class {
30976
31226
  if (this.isOwnChrome(node)) return;
30977
31227
  const cb = this.onClose;
30978
31228
  this.close();
30979
- cb?.();
31229
+ cb?.(e);
30980
31230
  };
30981
31231
  this.theme = theme;
30982
31232
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
@@ -31001,16 +31251,27 @@ var DrawingSettingsPopup = class {
31001
31251
  contains(node) {
31002
31252
  return node != null && (this.isOwnChrome(node) || this.settingsDialog.contains(node));
31003
31253
  }
31004
- /** Open the quick toolbar for `drawing`, floating clear of its `anchor` box.
31005
- * `onClose` fires on an outside-click dismissal (not a programmatic close). */
31006
- open(drawing, anchor, actions, onClose) {
31254
+ /** Open the quick toolbar for `drawings` (one, or a whole multi-selection), floating clear of
31255
+ * their `anchor` box. With several drawings the bar shows only the controls they ALL support,
31256
+ * a control whose values differ reads as mixed, and every edit applies to all of them —
31257
+ * per-drawing panels (levels, position sizing, the text field) stay single-drawing only.
31258
+ * `onClose` fires on a dismissal (an outside press, or Escape in the text field — not a
31259
+ * programmatic close), with the dismissing press when there is one, so the caller can tell a
31260
+ * modifier press (multi-select) from a plain one. */
31261
+ open(drawings, anchor, actions, onClose) {
31007
31262
  this.close();
31263
+ const drawing = drawings[0];
31264
+ if (!drawing) return;
31008
31265
  ensureStyles5();
31009
31266
  this.onClose = onClose ?? null;
31010
31267
  const t = this.theme;
31268
+ const multi = drawings.length > 1;
31011
31269
  const schema = drawing.schema();
31012
- const paths = new Set(schema.fields.map((f) => f.path));
31013
- const textOnBar = schema.textIsContent === true || !paths.has("text.value");
31270
+ const paths = sharedPaths(drawings);
31271
+ const common = (read) => commonValue(drawings, read);
31272
+ const every = (pred) => drawings.every(pred);
31273
+ const swatch = (tip, glyph, read, path) => this.colorButton(tip, glyph, common(read), (v) => actions.patch({ [path]: v }), void 0, distinctValues(drawings, read));
31274
+ const textOnBar = multi || schema.textIsContent === true || !paths.has("text.value");
31014
31275
  const el = document.createElement("div");
31015
31276
  el.className = "vela-dpop";
31016
31277
  el.style.cssText = `position:absolute;z-index:22;background:${t.background};border:1px solid var(--vela-border);border-radius:var(--vela-radius-lg);box-shadow:var(--vela-shadow);color:${t.textColor};font:var(--vela-font-size-md) ${t.fontFamily};display:flex;flex-direction:column;pointer-events:auto;overflow:hidden;`;
@@ -31024,75 +31285,84 @@ var DrawingSettingsPopup = class {
31024
31285
  bar.style.cssText = "display:flex;align-items:center;gap:2px;padding:4px;";
31025
31286
  bar.appendChild(this.dragHandle());
31026
31287
  if (paths.has("glyph")) {
31027
- const cur = drawing.glyph ?? GLYPH_OPTIONS[0];
31288
+ const cur = common((d) => d.glyph ?? GLYPH_OPTIONS[0]);
31028
31289
  bar.appendChild(this.dropdown("Icon", GLYPH_OPTIONS, cur, (g) => glyphIcon(g), (v) => actions.patch({ glyph: v })));
31029
31290
  }
31030
31291
  if (paths.has("size")) {
31031
- const sz = drawing.size ?? "normal";
31292
+ const sz = common((d) => d.size ?? "normal");
31032
31293
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
31033
31294
  }
31034
- if (paths.has("magnifier.timeframe") && drawing instanceof Magnifier) {
31295
+ if (paths.has("magnifier.timeframe") && every((d) => d instanceof Magnifier)) {
31296
+ const mag = (d) => d;
31035
31297
  const options = this.lowerTimeframeOptions();
31036
31298
  if (options.length > 0) {
31037
31299
  bar.appendChild(
31038
- this.dropdown("Lower timeframe", options.map((o) => o.value), drawing.magnifier.timeframe, () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
31300
+ this.dropdown("Lower timeframe", options.map((o) => o.value), common((d) => mag(d).magnifier.timeframe), () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
31039
31301
  label: (v) => magnifierTimeframeLabel(String(v)),
31040
31302
  labelInTrigger: true
31041
31303
  })
31042
31304
  );
31043
31305
  }
31044
- bar.appendChild(this.colorButton("Up candles", BUCKET_ICON, drawing.magnifier.upColor || t.upColor, (v) => actions.patch({ "magnifier.upColor": v })));
31045
- bar.appendChild(this.colorButton("Down candles", BUCKET_ICON, drawing.magnifier.downColor || t.downColor, (v) => actions.patch({ "magnifier.downColor": v })));
31306
+ bar.appendChild(swatch("Up candles", BUCKET_ICON, (d) => mag(d).magnifier.upColor || t.upColor, "magnifier.upColor"));
31307
+ bar.appendChild(swatch("Down candles", BUCKET_ICON, (d) => mag(d).magnifier.downColor || t.downColor, "magnifier.downColor"));
31046
31308
  }
31047
- if (paths.has("style.lineColor")) bar.appendChild(this.colorButton("Line color", BRUSH_ICON, drawing.style.lineColor || (drawing instanceof Magnifier ? contrastColor(this.theme.background) : DEFAULT_DRAWING_COLOR), (v) => actions.patch({ "style.lineColor": v })));
31309
+ if (paths.has("style.lineColor")) bar.appendChild(swatch("Line color", BRUSH_ICON, (d) => d.style.lineColor || (d instanceof Magnifier ? contrastColor(this.theme.background) : DEFAULT_DRAWING_COLOR), "style.lineColor"));
31048
31310
  if (paths.has("style.lineWidth")) {
31049
- const wf = schema.fields.find((f) => f.path === "style.lineWidth");
31050
- if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
31051
- bar.appendChild(this.widthInput("Line width", drawing.style.lineWidth, wf.min ?? 1, wf.max ?? 60, wf.step ?? 1, (v) => actions.patch({ "style.lineWidth": v })));
31311
+ const widthField2 = (d) => d.schema().fields.find((f) => f.path === "style.lineWidth");
31312
+ const wf = widthField2(drawing);
31313
+ const width = common((d) => d.style.lineWidth);
31314
+ const markers = every((d) => {
31315
+ const f = widthField2(d);
31316
+ return f?.kind === "number" && (f.min ?? 1) > 1;
31317
+ });
31318
+ if (markers && wf?.kind === "number") {
31319
+ bar.appendChild(this.widthInput("Line width", width, wf.min ?? 1, wf.max ?? 60, wf.step ?? 1, (v) => actions.patch({ "style.lineWidth": v })));
31052
31320
  } else {
31053
- bar.appendChild(this.dropdown("Line width", [1, 2, 3, 4], drawing.style.lineWidth, (w) => lineIcon(w, "solid"), (v) => actions.patch({ "style.lineWidth": v }), { label: (v) => `${v}px`, labelInTrigger: true }));
31321
+ bar.appendChild(this.dropdown("Line width", [1, 2, 3, 4], width, (w) => lineIcon(w, "solid"), (v) => actions.patch({ "style.lineWidth": v }), { label: (v) => `${v}px`, labelInTrigger: true }));
31054
31322
  }
31055
31323
  }
31056
- if (paths.has("style.lineStyle")) bar.appendChild(this.dropdown("Line style", LINE_STYLE_OPTIONS.map((o) => o.value), drawing.style.lineStyle, (s) => lineIcon(2, s), (v) => actions.patch({ "style.lineStyle": v }), { label: styleLabel2 }));
31057
- if (paths.has("style.fillColor")) bar.appendChild(this.colorButton("Fill", BUCKET_ICON, effectiveFillColor(drawing, this.theme) ?? drawing.style.fillColor ?? DEFAULT_DRAWING_COLOR, (v) => actions.patch({ "style.fillColor": v })));
31324
+ if (paths.has("style.lineStyle")) bar.appendChild(this.dropdown("Line style", LINE_STYLE_OPTIONS.map((o) => o.value), common((d) => d.style.lineStyle), (s) => lineIcon(2, s), (v) => actions.patch({ "style.lineStyle": v }), { label: styleLabel2 }));
31325
+ if (paths.has("style.fillColor")) bar.appendChild(swatch("Fill", BUCKET_ICON, (d) => effectiveFillColor(d, this.theme) ?? d.style.fillColor ?? DEFAULT_DRAWING_COLOR, "style.fillColor"));
31058
31326
  const isFrvp = paths.has("frvp.rows") && drawing instanceof FixedRangeVolumeProfile;
31059
- const isPosition = paths.has("riskPercent") && drawing instanceof PositionTool;
31327
+ const isPosition = paths.has("riskPercent") && every((d) => d instanceof PositionTool);
31060
31328
  if (isPosition) {
31061
- const pos = drawing;
31062
- bar.appendChild(this.colorButton("Profit zone", BUCKET_ICON, pos.profitColor, (v) => actions.patch({ profitColor: v })));
31063
- bar.appendChild(this.colorButton("Loss zone", BUCKET_ICON, pos.lossColor, (v) => actions.patch({ lossColor: v })));
31329
+ const pos = (d) => d;
31330
+ bar.appendChild(swatch("Profit zone", BUCKET_ICON, (d) => pos(d).profitColor, "profitColor"));
31331
+ bar.appendChild(swatch("Loss zone", BUCKET_ICON, (d) => pos(d).lossColor, "lossColor"));
31064
31332
  }
31065
- const reg = drawing.reg;
31066
- if (paths.has("reg.midColor") && reg) {
31333
+ const regOf = (d) => d.reg;
31334
+ if (paths.has("reg.midColor") && every((d) => regOf(d) != null)) {
31335
+ const reg = (d) => regOf(d);
31067
31336
  const styles = LINE_STYLE_OPTIONS.map((o) => o.value);
31068
- bar.appendChild(this.colorButton("Midline color", BRUSH_ICON, reg.midColor, (v) => actions.patch({ "reg.midColor": v })));
31069
- bar.appendChild(this.dropdown("Midline style", styles, reg.midStyle, (s) => lineIcon(2, s), (v) => actions.patch({ "reg.midStyle": v }), { label: styleLabel2 }));
31070
- bar.appendChild(this.colorButton("Upper line color", BRUSH_ICON, reg.upperColor, (v) => actions.patch({ "reg.upperColor": v })));
31071
- bar.appendChild(this.dropdown("Upper line style", styles, reg.upperStyle, (s) => lineIcon(2, s), (v) => actions.patch({ "reg.upperStyle": v }), { label: styleLabel2 }));
31072
- bar.appendChild(this.colorButton("Lower line color", BRUSH_ICON, reg.lowerColor, (v) => actions.patch({ "reg.lowerColor": v })));
31073
- bar.appendChild(this.dropdown("Lower line style", styles, reg.lowerStyle, (s) => lineIcon(2, s), (v) => actions.patch({ "reg.lowerStyle": v }), { label: styleLabel2 }));
31074
- bar.appendChild(this.colorButton("Upper fill", BUCKET_ICON, reg.upperFill, (v) => actions.patch({ "reg.upperFill": v })));
31075
- bar.appendChild(this.colorButton("Lower fill", BUCKET_ICON, reg.lowerFill, (v) => actions.patch({ "reg.lowerFill": v })));
31076
- bar.appendChild(this.toggle("Show R\xB2", R2_ICON, reg.showR2, (v) => actions.patch({ "reg.showR2": v })));
31077
- }
31078
- const vwap2 = drawing.vwap;
31079
- if (paths.has("vwap.midColor") && vwap2) {
31337
+ bar.appendChild(swatch("Midline color", BRUSH_ICON, (d) => reg(d).midColor, "reg.midColor"));
31338
+ bar.appendChild(this.dropdown("Midline style", styles, common((d) => reg(d).midStyle), (s) => lineIcon(2, s), (v) => actions.patch({ "reg.midStyle": v }), { label: styleLabel2 }));
31339
+ bar.appendChild(swatch("Upper line color", BRUSH_ICON, (d) => reg(d).upperColor, "reg.upperColor"));
31340
+ bar.appendChild(this.dropdown("Upper line style", styles, common((d) => reg(d).upperStyle), (s) => lineIcon(2, s), (v) => actions.patch({ "reg.upperStyle": v }), { label: styleLabel2 }));
31341
+ bar.appendChild(swatch("Lower line color", BRUSH_ICON, (d) => reg(d).lowerColor, "reg.lowerColor"));
31342
+ bar.appendChild(this.dropdown("Lower line style", styles, common((d) => reg(d).lowerStyle), (s) => lineIcon(2, s), (v) => actions.patch({ "reg.lowerStyle": v }), { label: styleLabel2 }));
31343
+ bar.appendChild(swatch("Upper fill", BUCKET_ICON, (d) => reg(d).upperFill, "reg.upperFill"));
31344
+ bar.appendChild(swatch("Lower fill", BUCKET_ICON, (d) => reg(d).lowerFill, "reg.lowerFill"));
31345
+ bar.appendChild(this.toggle("Show R\xB2", R2_ICON, common((d) => reg(d).showR2), (v) => actions.patch({ "reg.showR2": v })));
31346
+ }
31347
+ const vwapOf = (d) => d.vwap;
31348
+ if (paths.has("vwap.midColor") && every((d) => vwapOf(d) != null)) {
31349
+ const vwap2 = (d) => vwapOf(d);
31080
31350
  const styles = LINE_STYLE_OPTIONS.map((o) => o.value);
31081
31351
  const MULTS = [0.5, 1, 1.5, 2, 2.5, 3, 4, 5];
31082
- bar.appendChild(this.colorButton("VWAP color", BRUSH_ICON, vwap2.midColor, (v) => actions.patch({ "vwap.midColor": v })));
31083
- bar.appendChild(this.dropdown("VWAP style", styles, vwap2.midStyle, (s) => lineIcon(2, s), (v) => actions.patch({ "vwap.midStyle": v }), { label: styleLabel2 }));
31352
+ bar.appendChild(swatch("VWAP color", BRUSH_ICON, (d) => vwap2(d).midColor, "vwap.midColor"));
31353
+ bar.appendChild(this.dropdown("VWAP style", styles, common((d) => vwap2(d).midStyle), (s) => lineIcon(2, s), (v) => actions.patch({ "vwap.midStyle": v }), { label: styleLabel2 }));
31084
31354
  bar.appendChild(
31085
- this.dropdown("Band multiplier", MULTS, vwap2.multiplier, () => BANDS_ICON, (v) => actions.patch({ "vwap.multiplier": v }), {
31355
+ this.dropdown("Band multiplier", MULTS, common((d) => vwap2(d).multiplier), () => BANDS_ICON, (v) => actions.patch({ "vwap.multiplier": v }), {
31086
31356
  label: (v) => `${v}\u03C3`,
31087
31357
  labelInTrigger: true
31088
31358
  })
31089
31359
  );
31090
- bar.appendChild(this.colorButton("Upper band color", BRUSH_ICON, vwap2.upperColor, (v) => actions.patch({ "vwap.upperColor": v })));
31091
- bar.appendChild(this.colorButton("Lower band color", BRUSH_ICON, vwap2.lowerColor, (v) => actions.patch({ "vwap.lowerColor": v })));
31092
- bar.appendChild(this.colorButton("Band fill", BUCKET_ICON, vwap2.bandFill, (v) => actions.patch({ "vwap.bandFill": v })));
31360
+ bar.appendChild(swatch("Upper band color", BRUSH_ICON, (d) => vwap2(d).upperColor, "vwap.upperColor"));
31361
+ bar.appendChild(swatch("Lower band color", BRUSH_ICON, (d) => vwap2(d).lowerColor, "vwap.lowerColor"));
31362
+ bar.appendChild(swatch("Band fill", BUCKET_ICON, (d) => vwap2(d).bandFill, "vwap.bandFill"));
31093
31363
  }
31094
31364
  if (paths.has("maxCurvature")) {
31095
- const cur = drawing.maxCurvature ?? 24;
31365
+ const cur = common((d) => d.maxCurvature ?? 24);
31096
31366
  bar.appendChild(
31097
31367
  this.dropdown("Max curvature", DEDEKIND_CURVATURE_OPTIONS, cur, () => DEDEKIND_ICON2, (v) => actions.patch({ maxCurvature: v }), {
31098
31368
  label: (v) => `n=${v}`,
@@ -31101,7 +31371,7 @@ var DrawingSettingsPopup = class {
31101
31371
  );
31102
31372
  }
31103
31373
  if (paths.has("mach")) {
31104
- const cur = drawing.mach ?? 2;
31374
+ const cur = common((d) => d.mach ?? 2);
31105
31375
  bar.appendChild(
31106
31376
  this.dropdown("Mach number", MACH_NUMBER_OPTIONS, cur, () => SUPERSONIC_ICON2, (v) => actions.patch({ mach: v }), {
31107
31377
  label: (v) => `M=${v}`,
@@ -31110,7 +31380,7 @@ var DrawingSettingsPopup = class {
31110
31380
  );
31111
31381
  }
31112
31382
  if (paths.has("waveCount")) {
31113
- const cur = drawing.waveCount ?? 6;
31383
+ const cur = common((d) => d.waveCount ?? 6);
31114
31384
  bar.appendChild(
31115
31385
  this.dropdown("Waves", MACH_WAVE_COUNT_OPTIONS, cur, () => SONIC_ICON2, (v) => actions.patch({ waveCount: v }), {
31116
31386
  label: (v) => `${v}`,
@@ -31118,26 +31388,27 @@ var DrawingSettingsPopup = class {
31118
31388
  })
31119
31389
  );
31120
31390
  }
31121
- const toggles = drawing;
31122
- if (paths.has("showPrice")) bar.appendChild(this.toggle("Show price", PRICE_DELTA_ICON, toggles.showPrice !== false, (v) => actions.patch({ showPrice: v })));
31123
- if (paths.has("showDate")) bar.appendChild(this.toggle("Show date", DATE_DELTA_ICON, toggles.showDate !== false, (v) => actions.patch({ showDate: v })));
31124
- if (paths.has("text.color") && textOnBar) bar.appendChild(this.colorButton("Text color", TYPE_ICON, drawing.text?.color || t.textColor, (v) => actions.patch({ "text.color": v })));
31125
- if (paths.has("text.size") && textOnBar) bar.appendChild(this.dropdown("Text size", TEXT_SIZE_OPTIONS.map((o) => o.value), drawing.text?.size ?? "normal", (s) => labelSizeIcon(s), (v) => actions.patch({ "text.size": v }), { label: sizeLabel }));
31126
- if (paths.has("text.bold") && !paths.has("text.value")) bar.appendChild(this.toggle("Bold", BOLD_ICON, !!drawing.text?.bold, (v) => actions.patch({ "text.bold": v })));
31127
- if (paths.has("text.italic") && !paths.has("text.value")) bar.appendChild(this.toggle("Italic", ITALIC_ICON, !!drawing.text?.italic, (v) => actions.patch({ "text.italic": v })));
31128
- if (paths.has("text.value")) bar.appendChild(this.iconBtn("Text", TYPE_ICON, () => this.toggleTextPanel(drawing, actions, !textOnBar)));
31391
+ const flags = (d) => d;
31392
+ if (paths.has("showPrice")) bar.appendChild(this.toggle("Show price", PRICE_DELTA_ICON, common((d) => flags(d).showPrice !== false), (v) => actions.patch({ showPrice: v })));
31393
+ if (paths.has("showDate")) bar.appendChild(this.toggle("Show date", DATE_DELTA_ICON, common((d) => flags(d).showDate !== false), (v) => actions.patch({ showDate: v })));
31394
+ if (paths.has("text.color") && textOnBar) bar.appendChild(swatch("Text color", TYPE_ICON, (d) => d.text?.color || t.textColor, "text.color"));
31395
+ if (paths.has("text.size") && textOnBar) bar.appendChild(this.dropdown("Text size", TEXT_SIZE_OPTIONS.map((o) => o.value), common((d) => d.text?.size ?? "normal"), (s) => labelSizeIcon(s), (v) => actions.patch({ "text.size": v }), { label: sizeLabel }));
31396
+ const textField = !multi && paths.has("text.value");
31397
+ if (paths.has("text.bold") && !textField) bar.appendChild(this.toggle("Bold", BOLD_ICON, common((d) => !!d.text?.bold), (v) => actions.patch({ "text.bold": v })));
31398
+ if (paths.has("text.italic") && !textField) bar.appendChild(this.toggle("Italic", ITALIC_ICON, common((d) => !!d.text?.italic), (v) => actions.patch({ "text.italic": v })));
31399
+ if (textField) bar.appendChild(this.iconBtn("Text", TYPE_ICON, () => this.toggleTextPanel(drawing, actions, !textOnBar)));
31129
31400
  const editableLevels = drawing.editableLevels();
31130
- if (editableLevels && !(drawing instanceof MachFigure)) {
31131
- const fib = drawing;
31401
+ if (editableLevels && every((d) => d.editableLevels() != null && !(d instanceof MachFigure))) {
31402
+ const fib = (d) => d;
31132
31403
  const sizes = TEXT_SIZE_OPTIONS.map((o) => o.value);
31133
- bar.appendChild(this.dropdown("Numbers size", sizes, fib.numbersSize ?? "small", (s) => numbersSizeIcon(s), (v) => actions.patch({ numbersSize: v }), { label: sizeLabel }));
31134
- bar.appendChild(this.dropdown("Label size", sizes, fib.labelsSize ?? "normal", (s) => labelSizeIcon(s), (v) => actions.patch({ labelsSize: v }), { label: sizeLabel }));
31404
+ bar.appendChild(this.dropdown("Numbers size", sizes, common((d) => fib(d).numbersSize ?? "small"), (s) => numbersSizeIcon(s), (v) => actions.patch({ numbersSize: v }), { label: sizeLabel }));
31405
+ bar.appendChild(this.dropdown("Label size", sizes, common((d) => fib(d).labelsSize ?? "normal"), (s) => labelSizeIcon(s), (v) => actions.patch({ labelsSize: v }), { label: sizeLabel }));
31135
31406
  }
31136
31407
  bar.appendChild(this.divider());
31137
- if (isFrvp) bar.appendChild(this.iconBtn("Settings", GEAR_ICON, () => this.settingsDialog.open(drawing, actions, "frvp")));
31138
- if (isPosition) bar.appendChild(this.iconBtn("Position size", GEAR_ICON, () => this.settingsDialog.open(drawing, actions, "position")));
31139
- if (editableLevels) bar.appendChild(this.iconBtn("Levels", GEAR_ICON, () => this.settingsDialog.open(drawing, actions, "levels")));
31140
- bar.appendChild(this.toggle("Lock", LOCK_ICON, drawing.locked, (v) => actions.setLocked(v)));
31408
+ if (!multi && isFrvp) bar.appendChild(this.iconBtn("Settings", GEAR_ICON, () => this.settingsDialog.open(drawing, actions, "frvp")));
31409
+ if (!multi && isPosition) bar.appendChild(this.iconBtn("Position size", GEAR_ICON, () => this.settingsDialog.open(drawing, actions, "position")));
31410
+ if (!multi && editableLevels) bar.appendChild(this.iconBtn("Levels", GEAR_ICON, () => this.settingsDialog.open(drawing, actions, "levels")));
31411
+ bar.appendChild(this.toggle("Lock", LOCK_ICON, common((d) => d.locked), (v) => actions.setLocked(v), UNLOCK_ICON));
31141
31412
  const del = this.iconBtn("Delete", TRASH_ICON, () => actions.remove());
31142
31413
  del.style.color = "var(--vela-danger)";
31143
31414
  bar.appendChild(del);
@@ -31323,7 +31594,7 @@ var DrawingSettingsPopup = class {
31323
31594
  });
31324
31595
  return b;
31325
31596
  }
31326
- /** The trailing kebab overflow — z-order actions + reset settings. */
31597
+ /** The trailing kebab overflow — duplicate, z-order actions, reset settings. */
31327
31598
  kebabButton(actions) {
31328
31599
  const b = this.base("More");
31329
31600
  b.innerHTML = sized(KEBAB_ICON);
@@ -31335,6 +31606,7 @@ var DrawingSettingsPopup = class {
31335
31606
  return;
31336
31607
  }
31337
31608
  this.openActionMenu(b, [
31609
+ { icon: CLONE_ICON, label: "Duplicate", onClick: () => actions.duplicate() },
31338
31610
  { icon: FRONT_ICON, label: "Bring to front", onClick: () => actions.reorder("front") },
31339
31611
  { icon: BACK_ICON, label: "Send to back", onClick: () => actions.reorder("back") },
31340
31612
  { icon: RESET_ICON, label: "Reset settings", onClick: () => actions.resetSettings() }
@@ -31491,26 +31763,30 @@ var DrawingSettingsPopup = class {
31491
31763
  this.tipEl?.remove();
31492
31764
  this.tipEl = null;
31493
31765
  }
31494
- toggle(tip, icon2, active, onChange) {
31766
+ /** An on/off button. A mixed state (the selected drawings disagree) reads as half-lit and
31767
+ * the first click turns it ON for all of them. `off` swaps in a distinct glyph while the
31768
+ * toggle is off (a lock reads as an open padlock until it is engaged); mixed shows `icon`. */
31769
+ toggle(tip, icon2, active, onChange, off) {
31495
31770
  const b = this.base(tip);
31496
- b.innerHTML = sized(icon2);
31497
31771
  const set = (on2) => {
31498
- b.dataset.active = on2 ? "1" : "0";
31772
+ b.dataset.active = on2 === MIXED ? "mixed" : on2 ? "1" : "0";
31773
+ b.innerHTML = sized(off && on2 === false ? off : icon2);
31499
31774
  };
31500
31775
  set(active);
31501
31776
  let on = active;
31502
31777
  b.addEventListener("click", () => {
31503
- on = !on;
31778
+ on = on === MIXED ? true : !on;
31504
31779
  set(on);
31505
31780
  onChange(on);
31506
31781
  });
31507
31782
  return b;
31508
31783
  }
31509
31784
  /** An inline numeric width field for tools whose stroke range outgrows the 1–4px
31510
- * ladder — the value is clamped to the schema's declared min/max on commit. */
31785
+ * ladder — the value is clamped to the schema's declared min/max on commit. A mixed
31786
+ * value shows an empty field with a dash until a number is typed for all. */
31511
31787
  widthInput(tip, value, min, max, step, onChange) {
31512
31788
  const ni = new NumberInput({
31513
- value,
31789
+ value: value === MIXED ? min : value,
31514
31790
  min,
31515
31791
  max,
31516
31792
  step,
@@ -31522,13 +31798,18 @@ var DrawingSettingsPopup = class {
31522
31798
  title: tip,
31523
31799
  onChange
31524
31800
  });
31801
+ if (value === MIXED) {
31802
+ ni.input.value = "";
31803
+ ni.input.placeholder = "\u2014";
31804
+ }
31525
31805
  ni.el.dataset.tip = tip;
31526
31806
  trapChartKeys(ni.el);
31527
31807
  return ni.el;
31528
31808
  }
31529
31809
  /** A pick-one dropdown: the trigger shows the current value's glyph (plus an optional inline
31530
31810
  * text label — e.g. `2px` — for controls that would otherwise look alike), and clicking it
31531
- * opens a floating list of every option so a value is one click away (no cycling through). */
31811
+ * opens a floating list of every option so a value is one click away (no cycling through).
31812
+ * A mixed value shows a dash in the trigger and highlights no row. */
31532
31813
  dropdown(tip, values, current, render, onChange, opts = {}) {
31533
31814
  const b = this.base(tip);
31534
31815
  b.style.width = "auto";
@@ -31538,14 +31819,20 @@ var DrawingSettingsPopup = class {
31538
31819
  let cur = current;
31539
31820
  const paint = (v) => {
31540
31821
  b.replaceChildren();
31541
- const glyph = render(v);
31822
+ if (v === MIXED) {
31823
+ const dash = document.createElement("span");
31824
+ dash.textContent = "\u2014";
31825
+ dash.style.cssText = "min-width:18px;text-align:center;opacity:0.85;";
31826
+ b.appendChild(dash);
31827
+ }
31828
+ const glyph = v === MIXED ? "" : render(v);
31542
31829
  if (glyph) {
31543
31830
  const ic = document.createElement("span");
31544
31831
  ic.style.cssText = "display:flex;";
31545
31832
  ic.innerHTML = sized(glyph);
31546
31833
  b.appendChild(ic);
31547
31834
  }
31548
- if (opts.label && opts.labelInTrigger) {
31835
+ if (opts.label && opts.labelInTrigger && v !== MIXED) {
31549
31836
  const tx = document.createElement("span");
31550
31837
  tx.textContent = opts.label(v);
31551
31838
  tx.style.cssText = "font-size:11px;opacity:0.85;font-variant-numeric:tabular-nums;";
@@ -31615,8 +31902,10 @@ var DrawingSettingsPopup = class {
31615
31902
  }
31616
31903
  /** A color control: an `icon` over a thin **colored underline** showing the current
31617
31904
  * value (the common idiom), with an invisible native picker overlaid to edit it.
31618
- * `iconSize` shrinks it for the floating text controls. */
31619
- colorButton(tip, icon2, color, onChange, iconSize = 17) {
31905
+ * `iconSize` shrinks it for the floating text controls. A mixed color paints the
31906
+ * underline striped with the colors `used` across the selection, and the picker then
31907
+ * leads with those colors so unifying onto one of them is a single click. */
31908
+ colorButton(tip, icon2, color, onChange, iconSize = 17, used = []) {
31620
31909
  const b = document.createElement("button");
31621
31910
  b.type = "button";
31622
31911
  b.dataset.tip = tip;
@@ -31626,13 +31915,14 @@ var DrawingSettingsPopup = class {
31626
31915
  ic.style.cssText = "display:flex;";
31627
31916
  ic.innerHTML = sized(icon2, iconSize);
31628
31917
  const bar = document.createElement("span");
31629
- bar.style.cssText = `display:block;height:3px;width:${Math.round(iconSize * 0.85)}px;border-radius:2px;background:${color};`;
31918
+ bar.style.cssText = `display:block;height:3px;width:${Math.round(iconSize * 0.85)}px;border-radius:2px;background:${color === MIXED ? stripes(used) : color};`;
31630
31919
  b.append(ic, bar);
31631
31920
  let cur = color;
31632
31921
  b.addEventListener("pointerdown", (e) => e.stopPropagation());
31633
31922
  b.addEventListener("click", (e) => {
31634
31923
  e.stopPropagation();
31635
- this.toggleColorPopover(b, cur, (v) => {
31924
+ const seed = cur === MIXED ? used[0] ?? DEFAULT_DRAWING_COLOR : cur;
31925
+ this.toggleColorPopover(b, seed, cur === MIXED ? used : [], (v) => {
31636
31926
  cur = v;
31637
31927
  bar.style.background = v;
31638
31928
  onChange(v);
@@ -31641,20 +31931,42 @@ var DrawingSettingsPopup = class {
31641
31931
  return b;
31642
31932
  }
31643
31933
  /** Open the color popover for `anchor`, or close it if it's already this anchor's (toggle). */
31644
- toggleColorPopover(anchor, color, onChange) {
31934
+ toggleColorPopover(anchor, color, used, onChange) {
31645
31935
  if (this.colorOwner === anchor) {
31646
31936
  this.closeColorPopover();
31647
31937
  return;
31648
31938
  }
31649
- this.openColorPopover(anchor, color, onChange);
31939
+ this.openColorPopover(anchor, color, used, onChange);
31650
31940
  }
31651
- /** A floating RGB picker + opacity slider anchored to a color swatch — emits `#RRGGBB(AA)`. */
31652
- openColorPopover(anchor, color, onChange) {
31941
+ /** A floating RGB picker + opacity slider anchored to a color swatch — emits `#RRGGBB(AA)`.
31942
+ * `used` (a mixed swatch's colors) leads the picker as one-click unify shortcuts. */
31943
+ openColorPopover(anchor, color, used, onChange) {
31653
31944
  const t = this.theme;
31654
31945
  this.colorPop = this.hostFloat(anchor, {
31655
31946
  zIndex: 25,
31656
31947
  padding: "10px",
31657
- fill: (el) => {
31948
+ fill: (el, pop) => {
31949
+ if (used.length > 1) {
31950
+ const row = document.createElement("div");
31951
+ row.style.cssText = "display:flex;align-items:center;gap:6px;padding-bottom:9px;border-bottom:1px solid var(--vela-border);";
31952
+ const lbl = document.createElement("span");
31953
+ lbl.textContent = "In use";
31954
+ lbl.style.cssText = "font:var(--vela-font-size-sm) inherit;opacity:0.7;margin-right:2px;";
31955
+ row.appendChild(lbl);
31956
+ for (const c of used) {
31957
+ const sw = document.createElement("button");
31958
+ sw.type = "button";
31959
+ sw.dataset.tip = c;
31960
+ sw.style.cssText = `width:18px;height:18px;border-radius:4px;border:1px solid var(--vela-border-strong);cursor:pointer;background:${c};padding:0;`;
31961
+ sw.addEventListener("click", (e) => {
31962
+ e.stopPropagation();
31963
+ pop.hide();
31964
+ onChange(c);
31965
+ });
31966
+ row.appendChild(sw);
31967
+ }
31968
+ el.appendChild(row);
31969
+ }
31658
31970
  el.appendChild(buildColorPicker(color, t, onChange));
31659
31971
  }
31660
31972
  });
@@ -31675,6 +31987,7 @@ var TYPE_ICON = icon("type");
31675
31987
  var PRICE_DELTA_ICON = icon("price-delta");
31676
31988
  var DATE_DELTA_ICON = icon("date-delta");
31677
31989
  var LOCK_ICON = icon("lock");
31990
+ var UNLOCK_ICON = icon("unlock");
31678
31991
  var FRONT_ICON = icon("bring-front");
31679
31992
  var BACK_ICON = icon("send-back");
31680
31993
  var TRASH_ICON = icon("trash");
@@ -31682,6 +31995,7 @@ var BOLD_ICON = icon("bold");
31682
31995
  var ITALIC_ICON = icon("italic");
31683
31996
  var GRIP_ICON = icon("grip");
31684
31997
  var KEBAB_ICON = icon("kebab");
31998
+ var CLONE_ICON = icon("clone");
31685
31999
  var RESET_ICON = icon("reset");
31686
32000
  var GEAR_ICON = icon("gear");
31687
32001
  var CHEVRON_ICON = icon("chevron-down");
@@ -31690,6 +32004,12 @@ var BANDS_ICON = icon("bands");
31690
32004
  var DEDEKIND_ICON2 = icon("dedekind");
31691
32005
  var SONIC_ICON2 = icon("sonic");
31692
32006
  var SUPERSONIC_ICON2 = icon("supersonic");
32007
+ function stripes(colors) {
32008
+ const bands = colors.length > 1 ? colors : ["var(--vela-fg-faint)", "var(--vela-fg-muted)"];
32009
+ const step = 100 / bands.length;
32010
+ const stops = bands.map((c, i) => `${c} ${i * step}% ${(i + 1) * step}%`).join(",");
32011
+ return `linear-gradient(90deg,${stops})`;
32012
+ }
31693
32013
  function lineIcon(width, style) {
31694
32014
  const dash = style === "dashed" ? "5,3" : style === "dotted" ? "1.5,3" : "";
31695
32015
  return svg24(`<line x1="3.5" y1="12" x2="20.5" y2="12" stroke-width="${Number(width) || 2}" stroke-dasharray="${dash}"/>`);
@@ -31973,6 +32293,20 @@ var UserDrawingController = class {
31973
32293
  // reported exactly when it actually changes).
31974
32294
  this.textEditor = null;
31975
32295
  this.painter = new DrawingPainter();
32296
+ /** The open popup is the multi-selection bar (vs one drawing's own) — decides what a
32297
+ * shrinking selection does with it. */
32298
+ this.popupMulti = false;
32299
+ /** Dismiss-on-outside-press also clears the selection — except when the press is ABOUT the
32300
+ * selection: it carries a multi-select modifier (Shift / Ctrl / Cmd — it will add to the
32301
+ * selection or sweep a marquee onto it), or it landed on a drawing that is already selected
32302
+ * (the canvas handler ran first and is holding it for a drag or a click), so the drawings
32303
+ * whose bar just closed must stay selected. */
32304
+ this.onPopupDismissed = (e) => {
32305
+ if (e && (e.shiftKey || e.ctrlKey || e.metaKey)) return;
32306
+ const pressed = this.interaction.pressedId();
32307
+ if (pressed && this.selectedIds.has(pressed)) return;
32308
+ this.clearSelection();
32309
+ };
31976
32310
  this.ctx = canvas.getContext("2d");
31977
32311
  this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
31978
32312
  this.toolbar = new DrawingToolbar(
@@ -32149,6 +32483,12 @@ var UserDrawingController = class {
32149
32483
  }
32150
32484
  setSelection(ids) {
32151
32485
  this.selectedIds = new Set(ids);
32486
+ if (ids.length >= 2) this.openSettingsForSelection(ids);
32487
+ else if (this.popupMulti) {
32488
+ this.popupMulti = false;
32489
+ this.popup.close();
32490
+ if (ids.length === 1) this.openSettingsById(ids[0], 0, 0);
32491
+ }
32152
32492
  this.render();
32153
32493
  }
32154
32494
  /** Programmatic twin of clicking the drawing: highlight it + float its settings popup. */
@@ -32194,12 +32534,16 @@ var UserDrawingController = class {
32194
32534
  });
32195
32535
  }
32196
32536
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
32197
- * Shared by the eraser (click + drag) and the middle-click shortcut. */
32198
- deleteAt(x, y) {
32537
+ * Shared by the eraser (click + drag) and the middle-click shortcut; the latter passes
32538
+ * `withSelection` so a hit on a member of a multi-selection removes the selection's
32539
+ * unlocked members — whichever member was hit, locked or not. */
32540
+ deleteAt(x, y, withSelection = false) {
32199
32541
  const hit = topDrawingAt(this.drawings, x, y, this.deps.projector(), HIT_TOLERANCE);
32200
- if (!hit || hit.locked) return false;
32542
+ if (!hit) return false;
32543
+ const ids = deleteTargets(hit, this.selectedIds, this.drawings, withSelection);
32544
+ if (ids.length === 0) return false;
32201
32545
  this.popup.close();
32202
- this.emit({ kind: "delete", ids: [hit.id] });
32546
+ this.emit({ kind: "delete", ids });
32203
32547
  return true;
32204
32548
  }
32205
32549
  /** Shift+press on the empty plot: arm the measure ruler AND start it at (x, y) in one
@@ -32225,7 +32569,14 @@ var UserDrawingController = class {
32225
32569
  this.render();
32226
32570
  }
32227
32571
  }
32228
- pointerDown(x, y, snap = "off", shift2 = false) {
32572
+ /** Ctrl/Cmd+press on the empty plot: sweep a selection box (adds the drawings it touches to
32573
+ * the selection). Returns false when a mode/tool is active (the normal press path owns it). */
32574
+ beginMarqueeAt(x, y) {
32575
+ if (this.measureMode || this.eraserMode || !this.interaction.beginMarquee(x, y)) return false;
32576
+ this.render();
32577
+ return true;
32578
+ }
32579
+ pointerDown(x, y, snap = "off", shift2 = false, mod = false) {
32229
32580
  if (this.eraserMode) {
32230
32581
  this.erasing = true;
32231
32582
  this.deleteAt(x, y);
@@ -32245,9 +32596,9 @@ var UserDrawingController = class {
32245
32596
  return;
32246
32597
  }
32247
32598
  }
32248
- this.interaction.down(x, y, snap, shift2);
32599
+ this.interaction.down(x, y, snap, shift2, mod);
32249
32600
  }
32250
- pointerMove(x, y, snap = "off", shift2 = false) {
32601
+ pointerMove(x, y, snap = "off", shift2 = false, mod = false) {
32251
32602
  if (this.eraserMode) {
32252
32603
  if (this.erasing) this.deleteAt(x, y);
32253
32604
  return;
@@ -32259,12 +32610,14 @@ var UserDrawingController = class {
32259
32610
  return;
32260
32611
  }
32261
32612
  this.interaction.move(x, y, snap, shift2);
32262
- this.updateHover(x, y);
32613
+ this.updateHover(x, y, mod);
32263
32614
  }
32264
- /** Track which drawing is hovered so its handles appear on hover (and only then). */
32265
- updateHover(x, y) {
32615
+ /** Track which drawing is hovered so its handles appear on hover (and only then). While
32616
+ * Ctrl/Cmd is held the cursor is picking a selection — handles then mark only what IS
32617
+ * selected, so a hovered candidate doesn't already read as selected. */
32618
+ updateHover(x, y, mod = false) {
32266
32619
  let id = null;
32267
- if (this.activeTool == null && !this.interaction.isPlacing() && !this.interaction.isDragging()) {
32620
+ if (!mod && this.activeTool == null && !this.interaction.isPlacing() && !this.interaction.isDragging()) {
32268
32621
  id = topDrawingAt(this.drawings, x, y, this.deps.projector(), HIT_TOLERANCE)?.id ?? null;
32269
32622
  }
32270
32623
  if (id !== this.hoveredId) {
@@ -32284,7 +32637,12 @@ var UserDrawingController = class {
32284
32637
  this.render();
32285
32638
  return;
32286
32639
  }
32640
+ const dragged = this.interaction.isDragging() ? this.interaction.pressedId() : null;
32287
32641
  this.interaction.up(x, y);
32642
+ if (dragged && this.selectedIds.has(dragged) && !this.popup.isOpen()) {
32643
+ if (this.selectedIds.size >= 2) this.openSettingsForSelection(this.selectionIds());
32644
+ else this.openSettingsById(dragged, x, y);
32645
+ }
32288
32646
  }
32289
32647
  /** Toggle the transient ruler. Arming it clears any drawing tool + selection. */
32290
32648
  toggleMeasure() {
@@ -32549,7 +32907,7 @@ var UserDrawingController = class {
32549
32907
  this.emit({ kind: "duplicate", ids: this.selectionIds() });
32550
32908
  break;
32551
32909
  case "delete": {
32552
- const ids = this.selectedIds.size ? this.selectionIds() : this.hoveredId ? [this.hoveredId] : [];
32910
+ const ids = this.selectedIds.size ? this.deletableSelection() : this.hoveredId ? [this.hoveredId] : [];
32553
32911
  if (ids.length) {
32554
32912
  this.popup.close();
32555
32913
  this.emit({ kind: "delete", ids });
@@ -32565,6 +32923,9 @@ var UserDrawingController = class {
32565
32923
  selectionIds() {
32566
32924
  return [...this.selectedIds];
32567
32925
  }
32926
+ deletableSelection() {
32927
+ return deletableSelection(this.selectedIds, this.drawings);
32928
+ }
32568
32929
  /** Move every selected (unlocked) drawing by a pixel delta — one edit/edit-many → one undo step. */
32569
32930
  nudgeSelection(dx, dy) {
32570
32931
  const proj = this.deps.projector();
@@ -32673,6 +33034,8 @@ var UserDrawingController = class {
32673
33034
  this.painter.seriesLook = this.deps.seriesLook();
32674
33035
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
32675
33036
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
33037
+ const clones = this.interaction.dragClones();
33038
+ if (clones) this.painter.paintAll(ctx, clones, proj, this.deps.theme(), { selected: new Set(clones.map((c) => c.id)) });
32676
33039
  this.layoutTextEditor();
32677
33040
  const ghost = this.interaction.ghost();
32678
33041
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
@@ -32688,6 +33051,8 @@ var UserDrawingController = class {
32688
33051
  const my = m ? proj.yOf(m.point.price, m.paneId) : null;
32689
33052
  if (m && my != null) this.painter.paintSnapRing(ctx, proj.xOf(m.point.time), my, this.deps.theme());
32690
33053
  if (this.measure.isActive()) this.measure.paint(ctx, proj, this.deps.theme());
33054
+ const marquee = this.interaction.marqueeRect();
33055
+ if (marquee) this.painter.paintMarquee(ctx, marquee);
32691
33056
  }
32692
33057
  destroy() {
32693
33058
  this.closeTextEditor();
@@ -32705,15 +33070,80 @@ var UserDrawingController = class {
32705
33070
  if (this.selectedIds.size) this.emit({ kind: "select", ids: [] });
32706
33071
  this.render();
32707
33072
  }
33073
+ /** Report the live drawings' current state as one edit (one undo step, however many). */
33074
+ emitEdits(drawings) {
33075
+ const docs = drawings.map((d) => d.serialize());
33076
+ if (docs.length === 1) this.emit({ kind: "edit", doc: docs[0] });
33077
+ else if (docs.length > 1) this.emit({ kind: "edit-many", docs });
33078
+ }
33079
+ /** The one bar for a multi-selection: it shows the controls all of its drawings share and
33080
+ * every action applies to all of them. Floats clear of their combined bounds. */
33081
+ openSettingsForSelection(ids) {
33082
+ const live = () => ids.map((id) => this.drawings.find((d) => d.id === id)).filter((d) => d != null);
33083
+ const drawings = live();
33084
+ if (drawings.length < 2) return;
33085
+ const proj = this.deps.projector();
33086
+ let anchor = null;
33087
+ for (const d of drawings) {
33088
+ const b = d.bounds(proj);
33089
+ if (!b) continue;
33090
+ if (!anchor) anchor = { ...b };
33091
+ else {
33092
+ const right = Math.max(anchor.x + anchor.w, b.x + b.w);
33093
+ const bottom = Math.max(anchor.y + anchor.h, b.y + b.h);
33094
+ anchor.x = Math.min(anchor.x, b.x);
33095
+ anchor.y = Math.min(anchor.y, b.y);
33096
+ anchor.w = right - anchor.x;
33097
+ anchor.h = bottom - anchor.y;
33098
+ }
33099
+ }
33100
+ this.popupMulti = true;
33101
+ this.emit({ kind: "settings", id: drawings[0].id });
33102
+ this.popup.open(drawings, anchor, {
33103
+ resolve: () => live()[0] ?? null,
33104
+ patch: (p) => {
33105
+ const ds = live();
33106
+ for (const d of ds) d.applySettings(p);
33107
+ this.render();
33108
+ this.emitEdits(ds);
33109
+ },
33110
+ setLocked: (v) => {
33111
+ const ds = live();
33112
+ for (const d of ds) d.locked = v;
33113
+ this.emitEdits(ds);
33114
+ },
33115
+ reorder: (to) => {
33116
+ for (const d of live()) this.emit({ kind: "reorder", id: d.id, to });
33117
+ },
33118
+ duplicate: () => {
33119
+ this.popup.close();
33120
+ this.emit({ kind: "duplicate", ids: live().map((d) => d.id) });
33121
+ },
33122
+ resetSettings: () => {
33123
+ const ds = live();
33124
+ for (const d of ds) resetDrawingSettings(d);
33125
+ this.render();
33126
+ this.emitEdits(ds);
33127
+ this.openSettingsForSelection(ids);
33128
+ },
33129
+ remove: () => {
33130
+ const ids2 = live().filter((d) => !d.locked).map((d) => d.id);
33131
+ if (ids2.length === 0) return;
33132
+ this.popup.close();
33133
+ this.emit({ kind: "delete", ids: ids2 });
33134
+ }
33135
+ }, this.onPopupDismissed);
33136
+ }
32708
33137
  /** A click on a drawing opens its settings toolbar (text labels edit text here too). */
32709
33138
  openSettingsById(id, _x, _y) {
32710
33139
  const drawing = this.drawings.find((d) => d.id === id);
32711
33140
  if (!drawing) return;
32712
33141
  const live = () => this.drawings.find((d) => d.id === id);
33142
+ this.popupMulti = false;
32713
33143
  this.emit({ kind: "select", ids: [id] });
32714
33144
  this.emit({ kind: "settings", id });
32715
33145
  const anchor = drawing.bounds(this.deps.projector());
32716
- this.popup.open(drawing, anchor, {
33146
+ this.popup.open([drawing], anchor, {
32717
33147
  // Sync rebuilds instances, so a panel that reads values back after a patch (e.g. the
32718
33148
  // position tool's price fields, where one edit can flip another level) resolves fresh.
32719
33149
  resolve: () => live() ?? null,
@@ -32731,6 +33161,11 @@ var UserDrawingController = class {
32731
33161
  this.emit({ kind: "edit", doc: d.serialize() });
32732
33162
  },
32733
33163
  reorder: (to) => this.emit({ kind: "reorder", id, to }),
33164
+ duplicate: () => {
33165
+ this.finishTextEditor(true);
33166
+ this.popup.close();
33167
+ this.emit({ kind: "duplicate", ids: [id] });
33168
+ },
32734
33169
  resetSettings: () => {
32735
33170
  const d = live();
32736
33171
  if (!d) return;
@@ -32753,7 +33188,13 @@ var UserDrawingController = class {
32753
33188
  this.popup.close();
32754
33189
  this.emit({ kind: "delete", ids: [id] });
32755
33190
  }
32756
- }, () => this.clearSelection());
33191
+ }, this.onPopupDismissed);
33192
+ }
33193
+ /** A plain click on the empty plot: drop the selection. The popup's dismiss-on-outside-press
33194
+ * does the same, but a selection can exist with no bar open (a lone Ctrl-click pick, a
33195
+ * selection whose bar was dismissed by a modifier press) — this covers those. */
33196
+ deselect() {
33197
+ this.clearSelection();
32757
33198
  }
32758
33199
  /** Report placement progress upstream (`draft` intent) so the drawings sync can
32759
33200
  * mirror the ghost live on linked charts. Called from every render; only actual
@@ -32779,6 +33220,13 @@ var UserDrawingController = class {
32779
33220
  }, 0);
32780
33221
  return;
32781
33222
  }
33223
+ if (i.kind === "clone") {
33224
+ const before = new Set(this.drawings.map((d) => d.id));
33225
+ this.intentCb?.(i);
33226
+ const fresh = this.drawings.filter((d) => !before.has(d.id));
33227
+ if (fresh.length === 1) this.openSettingsById(fresh[0].id, 0, 0);
33228
+ return;
33229
+ }
32782
33230
  this.intentCb?.(i);
32783
33231
  }
32784
33232
  };
@@ -33807,7 +34255,6 @@ var ZOOM_OUT_MARGIN_BARS = 6;
33807
34255
  var ZOOM_TAU_MS = 70;
33808
34256
  var SCALE_TAU_MS = 80;
33809
34257
  var FLING_TAU_MS = 110;
33810
- var LIVE_BAR_TAU_MS = 90;
33811
34258
  var SCROLL_TO_TAU_MS = 130;
33812
34259
  var FLING_STOP_PX = 0.02;
33813
34260
  var PRICE_SCALE_K = 4e-3;
@@ -33878,9 +34325,13 @@ var NativeRenderer = class {
33878
34325
  /** Drawings layer self-serves Ctrl+Z/Y (see the `historyChords` feature). */
33879
34326
  this.historyChordsEnabled = true;
33880
34327
  this.liveRegion = null;
33881
- // ── animation state (eased zoom + inertial pan) ──
34328
+ // ── animation state (eased zoom + inertial pan + live-bar glide) ──
33882
34329
  this.animZoom = true;
33883
34330
  this.animPan = true;
34331
+ this.animLiveBarMs = 0;
34332
+ // forming-bar OHLC glide time-constant; 0 = each tick snaps
34333
+ this.animLiveBarOnMs = LIVE_BAR_EASE_DEFAULT_MS;
34334
+ // the duration the settings-dialog on/off toggle restores (last non-zero value configured)
33884
34335
  // Brand default candles.
33885
34336
  this.candleUp = BULLISH;
33886
34337
  this.candleDown = BEARISH;
@@ -34005,7 +34456,7 @@ var NativeRenderer = class {
34005
34456
  this.moveIndicatorCbs = /* @__PURE__ */ new Set();
34006
34457
  this.priceStyleCbs = /* @__PURE__ */ new Set();
34007
34458
  this.name = "native";
34008
- this.features = ["logScale", "currentPriceLine", "priceLabel", "countdown", "upColor", "downColor", "glow", "animZoom", "animPan", "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"];
34459
+ 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"];
34009
34460
  /** Track cursor proximity to the scroll button on the plot (bubbles from the button too,
34010
34461
  * so moving onto the button doesn't count as leaving). */
34011
34462
  this.onScrollProximityMove = (e) => {
@@ -34039,6 +34490,7 @@ var NativeRenderer = class {
34039
34490
  this.backendMode = opts.nativeBackend;
34040
34491
  this.animZoom = opts.animZoom;
34041
34492
  this.animPan = opts.animPan;
34493
+ this.setLiveBarEase(resolveLiveBarEaseMs(opts.animLiveBar));
34042
34494
  this.glowAmount = opts.glow;
34043
34495
  this.candleUp = opts.upColor;
34044
34496
  this.candleDown = opts.downColor;
@@ -34088,6 +34540,10 @@ var NativeRenderer = class {
34088
34540
  case "animPan":
34089
34541
  this.animPan = Boolean(value);
34090
34542
  return;
34543
+ case "animLiveBar":
34544
+ this.setLiveBarEase(resolveLiveBarEaseMs(value));
34545
+ return;
34546
+ // affects the next tick only; a glide in flight finishes at the new rate (or snaps at 0)
34091
34547
  case "intro": {
34092
34548
  const s = value === false || value === "none" || value === "off" || value == null ? "" : String(value);
34093
34549
  this.introStyle = s;
@@ -34224,6 +34680,8 @@ var NativeRenderer = class {
34224
34680
  return this.animZoom;
34225
34681
  case "animPan":
34226
34682
  return this.animPan;
34683
+ case "animLiveBar":
34684
+ return this.animLiveBarMs;
34227
34685
  case "intro":
34228
34686
  return this.introStyle;
34229
34687
  case "zoomAnchor":
@@ -34392,7 +34850,8 @@ var NativeRenderer = class {
34392
34850
  labelsVisible: this.scene.showAxisLabels,
34393
34851
  currentPriceLine: this.scene.showPriceLine,
34394
34852
  priceLabel: this.scene.showPriceLabel,
34395
- countdown: this.scene.showCountdown
34853
+ countdown: this.scene.showCountdown,
34854
+ animateLastPrice: this.animLiveBarMs > 0
34396
34855
  },
34397
34856
  panes: { separatorColor: s.separatorColor ?? t.borderColor },
34398
34857
  trades: {
@@ -34499,6 +34958,7 @@ var NativeRenderer = class {
34499
34958
  this.scene.showPriceLabel = next.priceScale.priceLabel;
34500
34959
  this.scene.showCountdown = next.priceScale.countdown;
34501
34960
  this.syncCountdownTimer();
34961
+ this.animLiveBarMs = next.priceScale.animateLastPrice ? this.animLiveBarOnMs : 0;
34502
34962
  s.separatorColor = keepInherit(s.separatorColor, next.panes.separatorColor, prevTheme.borderColor);
34503
34963
  this.scene.tradeMarkers = {
34504
34964
  visible: next.trades.visible,
@@ -34942,7 +35402,10 @@ var NativeRenderer = class {
34942
35402
  zoomTo: (target, anchorLogical, anchorX) => this.zoomTo(target, anchorLogical, anchorX),
34943
35403
  fling: (v) => this.fling(v),
34944
35404
  onPointerMove: (x, y) => this.handlePointerMove(x, y),
34945
- onClick: (x) => this.handleClick(x),
35405
+ onClick: (x) => {
35406
+ this.userDrawings?.deselect();
35407
+ this.handleClick(x);
35408
+ },
34946
35409
  onAxisLongPress: (axis, x, y) => {
34947
35410
  for (const cb of this.axisLongPressCbs) cb({ axis, x, y });
34948
35411
  },
@@ -34960,11 +35423,13 @@ var NativeRenderer = class {
34960
35423
  // User drawings claim a gesture before pan when armed / over a drawing.
34961
35424
  drawingsClaim: (x, y) => this.userDrawings?.claim(x, y) ?? false,
34962
35425
  drawingsMeasureStart: (x, y, snap) => this.userDrawings?.beginMeasureAt(x, y, snap) ?? false,
34963
- drawingsDeleteAt: (x, y) => this.userDrawings?.deleteAt(x, y) ?? false,
35426
+ drawingsMarqueeStart: (x, y) => this.userDrawings?.beginMarqueeAt(x, y) ?? false,
35427
+ drawingsDeleteAt: (x, y) => this.userDrawings?.deleteAt(x, y, true) ?? false,
35428
+ // middle-click: a selected hit takes the whole selection
34964
35429
  drawingsCancelPlacement: () => this.userDrawings?.cancelPlacement() ?? false,
34965
35430
  drawingsSnapMode: () => this.snapMode,
34966
- drawingsPointerDown: (x, y, snap, shift2) => this.userDrawings?.pointerDown(x, y, snap, shift2),
34967
- drawingsPointerMove: (x, y, snap, shift2) => this.userDrawings?.pointerMove(x, y, snap, shift2),
35431
+ drawingsPointerDown: (x, y, snap, shift2, mod) => this.userDrawings?.pointerDown(x, y, snap, shift2, mod),
35432
+ drawingsPointerMove: (x, y, snap, shift2, mod) => this.userDrawings?.pointerMove(x, y, snap, shift2, mod),
34968
35433
  drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
34969
35434
  drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
34970
35435
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
@@ -35315,8 +35780,11 @@ var NativeRenderer = class {
35315
35780
  const last = this.bars[n - 1];
35316
35781
  if (last && bar.time === last.time) {
35317
35782
  this.bars[n - 1] = bar;
35318
- if (this.liveEaseTime !== bar.time) this.syncLiveEase(bar);
35319
- this.animator.start();
35783
+ if (this.animLiveBarMs <= 0 || this.liveEaseTime !== bar.time) {
35784
+ this.syncLiveEase(bar);
35785
+ } else {
35786
+ this.animator.start();
35787
+ }
35320
35788
  } else if (!last || bar.time > last.time) {
35321
35789
  this.bars.push(bar);
35322
35790
  this.syncLiveEase(bar);
@@ -35328,6 +35796,12 @@ var NativeRenderer = class {
35328
35796
  this.scene.bars = this.bars;
35329
35797
  this.scheduler.invalidate(4 /* Full */);
35330
35798
  }
35799
+ /** Set the live-bar glide duration (0 = off). A non-zero value is also remembered as
35800
+ * what the config's on/off toggle (`priceScale.animateLastPrice`) switches back on to. */
35801
+ setLiveBarEase(ms) {
35802
+ this.animLiveBarMs = ms;
35803
+ if (ms > 0) this.animLiveBarOnMs = ms;
35804
+ }
35331
35805
  /** Snap the eased forming-bar state to `bar` — no glide (a fresh bar or the first tick of one). */
35332
35806
  syncLiveEase(bar) {
35333
35807
  this.liveEaseTime = bar.time;
@@ -35340,9 +35814,10 @@ var NativeRenderer = class {
35340
35814
  const target = this.bars[this.bars.length - 1];
35341
35815
  if (!target || this.liveEaseTime !== target.time) return false;
35342
35816
  const eps = Math.max(1e-9, Math.abs(target.close) * 1e-6);
35343
- const nh = easeToward(this.liveEaseHigh, target.high, dtMs, LIVE_BAR_TAU_MS);
35344
- const nl = easeToward(this.liveEaseLow, target.low, dtMs, LIVE_BAR_TAU_MS);
35345
- const nc = easeToward(this.liveEaseClose, target.close, dtMs, LIVE_BAR_TAU_MS);
35817
+ const tau = this.animLiveBarMs;
35818
+ const nh = easeToward(this.liveEaseHigh, target.high, dtMs, tau);
35819
+ const nl = easeToward(this.liveEaseLow, target.low, dtMs, tau);
35820
+ const nc = easeToward(this.liveEaseClose, target.close, dtMs, tau);
35346
35821
  const active = Math.abs(nh - target.high) > eps || Math.abs(nl - target.low) > eps || Math.abs(nc - target.close) > eps;
35347
35822
  this.liveEaseHigh = active ? nh : target.high;
35348
35823
  this.liveEaseLow = active ? nl : target.low;
@@ -37288,6 +37763,10 @@ function str3(inputs, key, fallback) {
37288
37763
  const v = inputs[key];
37289
37764
  return typeof v === "string" && v !== "" ? v : fallback;
37290
37765
  }
37766
+ function bool(inputs, key, fallback) {
37767
+ const v = inputs[key];
37768
+ return typeof v === "boolean" ? v : fallback;
37769
+ }
37291
37770
  function toPoints(bars, values, colors) {
37292
37771
  const pts = new Array(bars.length);
37293
37772
  for (let i = 0; i < bars.length; i++) {
@@ -38819,10 +39298,6 @@ var aroon = {
38819
39298
  plots: [
38820
39299
  { key: "up", title: "Aroon up", values: up, color: BULLISH, width: 2 },
38821
39300
  { key: "down", title: "Aroon down", values: down, color: BEARISH, width: 2 }
38822
- ],
38823
- levels: [
38824
- { key: "upper", price: 100, color: NEUTRAL, lineStyle: "dotted" },
38825
- { key: "lower", price: 0, color: NEUTRAL, lineStyle: "dotted" }
38826
39301
  ]
38827
39302
  };
38828
39303
  }
@@ -39320,8 +39795,10 @@ var volumeSpecs = [
39320
39795
  var DAY_MS2 = 864e5;
39321
39796
  function periodKey(anchor, time) {
39322
39797
  if (anchor === "Week") return Math.floor((Math.floor(time / DAY_MS2) + 3) / 7);
39323
- if (anchor === "Month") {
39798
+ if (anchor === "Month" || anchor === "Quarter" || anchor === "Year") {
39324
39799
  const d = new Date(time);
39800
+ if (anchor === "Year") return d.getUTCFullYear();
39801
+ if (anchor === "Quarter") return d.getUTCFullYear() * 4 + Math.floor(d.getUTCMonth() / 3);
39325
39802
  return d.getUTCFullYear() * 12 + d.getUTCMonth();
39326
39803
  }
39327
39804
  return Math.floor(time / DAY_MS2);
@@ -39373,40 +39850,86 @@ var parabolicSar = {
39373
39850
  return { plots: [{ key: "sar", title: "PSAR", values, kind: "circles", color: SERIES_LINE, colors }] };
39374
39851
  }
39375
39852
  };
39853
+ var VWAP_BANDS_GROUP = "Bands";
39854
+ var VWAP_STYLE = "Style";
39855
+ var VWAP_BANDS = [
39856
+ { on: "band1", mult: "band1Mult", color: "band1Color", fill: "band1Fill", fillColor: "band1FillColor", defMult: 1, defOn: true, defColor: BULLISH },
39857
+ { on: "band2", mult: "band2Mult", color: "band2Color", fill: "band2Fill", fillColor: "band2FillColor", defMult: 2, defOn: false, defColor: WARNING },
39858
+ { on: "band3", mult: "band3Mult", color: "band3Color", fill: "band3Fill", fillColor: "band3FillColor", defMult: 3, defOn: false, defColor: CATEGORICAL[4] }
39859
+ ];
39376
39860
  var vwap = {
39377
39861
  type: "vwap",
39378
39862
  title: "Volume Weighted Average Price",
39379
39863
  shortTitle: "VWAP",
39380
39864
  overlay: true,
39381
39865
  inputs: [
39382
- { key: "anchor", title: "Anchor", type: "string", defval: "Day", options: ["Day", "Week", "Month"], tooltip: "Where the accumulation resets (UTC periods)" },
39383
- { key: "source", title: "Source", type: "string", defval: "HLC3", options: ["HLC3", "OHLC4", "Close"] },
39384
- colorInput(INFO)
39866
+ { key: "anchor", title: "Period", type: "string", defval: "Day", options: ["Day", "Week", "Month", "Quarter", "Year"], tooltip: "Where the accumulation resets (UTC periods)" },
39867
+ sourceInput("HLC3"),
39868
+ // Each band is one row: its toggle leads, the multiplier follows unlabeled.
39869
+ ...VWAP_BANDS.flatMap((b, i) => [
39870
+ { key: b.on, title: `Band ${i + 1} multiplier`, type: "bool", defval: b.defOn, inline: b.on, group: VWAP_BANDS_GROUP },
39871
+ { key: b.mult, title: "", type: "float", defval: b.defMult, min: 0.1, max: 10, step: 0.1, inline: b.on, group: VWAP_BANDS_GROUP }
39872
+ ]),
39873
+ { ...colorInput(INFO, "VWAP color"), group: VWAP_STYLE },
39874
+ // One row per band: its ink, then the fill toggle with the fill's own (translucent) color.
39875
+ ...VWAP_BANDS.flatMap((b, i) => [
39876
+ { ...colorInput(b.defColor, `Band ${i + 1} color`, b.color), inline: b.color, group: VWAP_STYLE },
39877
+ { key: b.fill, title: "Fill", type: "bool", defval: true, inline: b.color, group: VWAP_STYLE },
39878
+ { ...colorInput(withAlpha2(b.defColor), "", b.fillColor), inline: b.color, group: VWAP_STYLE }
39879
+ ])
39385
39880
  ],
39386
39881
  compute: (bars, inputs) => {
39387
39882
  const anchor = str3(inputs, "anchor", "Day");
39388
- const source = str3(inputs, "source", "HLC3");
39389
- const values = new Array(bars.length).fill(Number.NaN);
39883
+ const src = sourceValues(bars, str3(inputs, "source", "HLC3"));
39884
+ const n = bars.length;
39885
+ const values = new Array(n).fill(Number.NaN);
39886
+ const bands = VWAP_BANDS.filter((b) => bool(inputs, b.on, b.defOn)).map((b) => ({
39887
+ mult: Math.max(0, num3(inputs, b.mult, b.defMult)),
39888
+ color: str3(inputs, b.color, b.defColor),
39889
+ fill: bool(inputs, b.fill, true),
39890
+ fillColor: str3(inputs, b.fillColor, withAlpha2(b.defColor)),
39891
+ up: new Array(n).fill(Number.NaN),
39892
+ down: new Array(n).fill(Number.NaN)
39893
+ }));
39390
39894
  let period = Number.NaN;
39391
39895
  let cumPV = 0;
39896
+ let cumPV2 = 0;
39392
39897
  let cumV = 0;
39393
- for (let i = 0; i < bars.length; i++) {
39898
+ for (let i = 0; i < n; i++) {
39394
39899
  const b = bars[i];
39395
39900
  const key = periodKey(anchor, b.time);
39396
39901
  if (key !== period) {
39397
39902
  period = key;
39398
39903
  cumPV = 0;
39904
+ cumPV2 = 0;
39399
39905
  cumV = 0;
39400
39906
  }
39401
39907
  const v = b.volume;
39402
39908
  if (v != null && Number.isFinite(v) && v > 0) {
39403
- const tp = source === "Close" ? b.close : source === "OHLC4" ? (b.open + b.high + b.low + b.close) / 4 : (b.high + b.low + b.close) / 3;
39909
+ const tp = src[i];
39404
39910
  cumPV += tp * v;
39911
+ cumPV2 += tp * tp * v;
39405
39912
  cumV += v;
39406
39913
  }
39407
- if (cumV > 0) values[i] = cumPV / cumV;
39408
- }
39409
- return { plots: [{ key: "vwap", title: "VWAP", values, color: str3(inputs, "color", INFO), width: 2 }] };
39914
+ if (cumV <= 0) continue;
39915
+ const mean = cumPV / cumV;
39916
+ const sd = Math.sqrt(Math.max(0, cumPV2 / cumV - mean * mean));
39917
+ values[i] = mean;
39918
+ for (const band of bands) {
39919
+ band.up[i] = mean + band.mult * sd;
39920
+ band.down[i] = mean - band.mult * sd;
39921
+ }
39922
+ }
39923
+ const plots = [{ key: "vwap", title: "VWAP", values, color: str3(inputs, "color", INFO), width: 2 }];
39924
+ const fills = [];
39925
+ bands.forEach((band, i) => {
39926
+ plots.push(
39927
+ { key: `up${i}`, title: `Upper ${band.mult}\u03C3`, values: band.up, color: band.color },
39928
+ { key: `down${i}`, title: `Lower ${band.mult}\u03C3`, values: band.down, color: band.color }
39929
+ );
39930
+ if (band.fill) fills.push({ key: `band${i}`, from: `up${i}`, to: `down${i}`, color: band.fillColor });
39931
+ });
39932
+ return { plots, bands: fills };
39410
39933
  }
39411
39934
  };
39412
39935
  var pivotPoints = {
@@ -39597,21 +40120,11 @@ var Vela = class {
39597
40120
  registerClassicIndicators();
39598
40121
  const element = resolveElement(container);
39599
40122
  const theme = resolveTheme(options.theme);
39600
- let animZoom = true;
39601
- let animPan = true;
39602
- if (typeof options.animations === "boolean") {
39603
- animZoom = options.animations;
39604
- animPan = options.animations;
39605
- } else if (options.animations) {
39606
- animZoom = options.animations.zoom ?? true;
39607
- animPan = options.animations.pan ?? true;
39608
- }
39609
40123
  const display = {
39610
40124
  currentPriceLine: options.currentPriceLine ?? true,
39611
40125
  logScale: options.logScale ?? false,
39612
40126
  nativeBackend: options.nativeBackend ?? "auto",
39613
- animZoom,
39614
- animPan,
40127
+ ...resolveAnimations(options.animations),
39615
40128
  glow: options.glow ?? 0,
39616
40129
  upColor: options.upColor ?? BULLISH,
39617
40130
  downColor: options.downColor ?? BEARISH,
@@ -39926,13 +40439,28 @@ function resolveElement(container) {
39926
40439
  return element;
39927
40440
  }
39928
40441
 
39929
- // src/core/options.ts
39930
- var normalizeSession = (v) => v === "regular" || v === "extended" ? v : void 0;
39931
-
39932
40442
  // src/widget/statusline-model.ts
39933
40443
  function segmentVisibility(parts, chartHidden = false) {
39934
40444
  return { avatar: parts.logo, symbol: parts.name, meta: parts.name, market: parts.market, ohlc: parts.ohlc && !chartHidden, change: parts.change && !chartHidden, eye: chartHidden };
39935
40445
  }
40446
+ var STATUSLINE_LADDER = [
40447
+ { stacked: false, level: "full" },
40448
+ { stacked: true, level: "full" },
40449
+ { stacked: true, level: "compact" },
40450
+ { stacked: true, level: "minimal" }
40451
+ ];
40452
+ function readoutCells(level, readout) {
40453
+ if (readout === "value") return [{ key: "close", label: "" }];
40454
+ if (level === "full") {
40455
+ return [
40456
+ { key: "open", label: "O" },
40457
+ { key: "high", label: "H" },
40458
+ { key: "low", label: "L" },
40459
+ { key: "close", label: "C" }
40460
+ ];
40461
+ }
40462
+ return [{ key: "close", label: level === "compact" ? "C" : "" }];
40463
+ }
39936
40464
  function statuslineMenuItems(parts, chartVisible) {
39937
40465
  return [
39938
40466
  { id: "part:logo", label: "Symbol logo", checked: parts.logo },
@@ -39961,6 +40489,11 @@ function fmtChange(open2, close) {
39961
40489
  const sign = diff >= 0 ? "+" : "";
39962
40490
  return `${sign}${fmtPrice(diff, decimalsFor(close))} (${sign}${pct.toFixed(2)}%)`;
39963
40491
  }
40492
+ function fmtChangePct(open2, close) {
40493
+ if (open2 == null || close == null || !Number.isFinite(open2) || !Number.isFinite(close) || open2 === 0) return "";
40494
+ const pct = (close - open2) / open2 * 100;
40495
+ return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
40496
+ }
39964
40497
 
39965
40498
  // src/widget/statusline.ts
39966
40499
  var STYLE_ID24 = "vela-widget-statusline";
@@ -39977,6 +40510,11 @@ var CSS21 = `
39977
40510
  display: flex;
39978
40511
  align-items: baseline;
39979
40512
  gap: var(--vela-space-2);
40513
+ /* The chip never grows past the plot: fit() measures overflow against this width to
40514
+ * walk the layout ladder (stack, then shed values), so overflow:hidden only guards
40515
+ * the transient between a resize and the next measure. */
40516
+ max-width: calc(100% - var(--vela-toolbar-gutter, 0px) - var(--vela-scale-gutter, 0px) - 24px);
40517
+ overflow: hidden;
39980
40518
  color: var(--vela-fg);
39981
40519
  font-size: var(--vela-font-size-md);
39982
40520
  /* Same chip treatment as the indicator legend rows (InputsUI): a translucent wash of
@@ -39998,6 +40536,31 @@ var CSS21 = `
39998
40536
  /* Chart hidden (the price series' eye \u2014 renderer 'candleVisible'): the line dims to
39999
40537
  * the same 0.5 wash a hidden indicator's legend row wears. */
40000
40538
  .vela-statusline.vela-sl-chart-hidden { opacity: 0.5; }
40539
+ /* Two rows inside the chip \u2014 the identity (logo / symbol / meta / market badge) and the
40540
+ * value readout (O/H/L/C + change, or the show-chart eye). In the widest layout they sit
40541
+ * side by side on one line; the STACKED layouts (fit() decides, see the ladder in
40542
+ * statusline-model) turn the chip into a column so the values drop under the symbol. */
40543
+ .vela-statusline .vela-sl-row {
40544
+ display: flex;
40545
+ align-items: baseline;
40546
+ gap: var(--vela-space-2);
40547
+ white-space: nowrap;
40548
+ }
40549
+ .vela-statusline.vela-sl-stacked {
40550
+ flex-direction: column;
40551
+ align-items: flex-start;
40552
+ /* The same air between the two rows as between the values row and the legend row
40553
+ * that follows the chip (see the stacked legend shift below). */
40554
+ row-gap: var(--vela-space-1);
40555
+ }
40556
+ /* Stacked, the identity row may shrink (the meta carries the ellipsis) \u2014 the values row
40557
+ * never does, so its overflow is what fit() measures to keep descending the ladder. */
40558
+ .vela-statusline.vela-sl-stacked .vela-sl-identity { max-width: 100%; }
40559
+ .vela-statusline.vela-sl-stacked .vela-sl-meta {
40560
+ min-width: 0;
40561
+ overflow: hidden;
40562
+ text-overflow: ellipsis;
40563
+ }
40001
40564
  .vela-statusline .vela-sl-avatar {
40002
40565
  width: 18px;
40003
40566
  height: 18px;
@@ -40049,59 +40612,13 @@ var CSS21 = `
40049
40612
  * stylesheet is document-global, so a bare attribute selector here would shift every
40050
40613
  * chart on the page, including statusline-less ones. */
40051
40614
  .vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(26px); }
40052
- /* Mobile: two-line chip \u2014 logo / symbol / meta / market status on one aligned row, the
40053
- * bar change on the next. Full O/H/L/C stays hidden (too dense on a phone-width plot).
40054
- * GRID, not a wrapping flexbox: an absolutely positioned wrapping flex container sizes
40055
- * to the one-line sum of ALL segments (max-content), so its background used to stretch
40056
- * far past the market badge; a grid hugs the widest actual row. The meta column may
40057
- * shrink (it carries the ellipsis), the others wrap their content. */
40058
- [data-layout='mobile'] .vela-statusline {
40059
- display: grid;
40060
- grid-template-columns: auto auto minmax(0, auto) auto;
40061
- justify-content: start;
40062
- align-items: center;
40063
- row-gap: 1px;
40064
- max-width: calc(100% - var(--vela-toolbar-gutter, 0px) - var(--vela-scale-gutter, 0px) - 24px);
40065
- font-size: var(--vela-font-size-sm);
40066
- }
40067
- [data-layout='mobile'] .vela-statusline .vela-sl-symbol {
40068
- font-size: var(--vela-font-size-md);
40069
- line-height: 1;
40070
- }
40071
- [data-layout='mobile'] .vela-statusline .vela-sl-meta {
40072
- min-width: 0;
40073
- overflow: hidden;
40074
- text-overflow: ellipsis;
40075
- white-space: nowrap;
40076
- line-height: 1;
40077
- }
40078
- [data-layout='mobile'] .vela-statusline .vela-sl-avatar,
40079
- [data-layout='mobile'] .vela-statusline .vela-sl-market { align-self: center; }
40080
- [data-layout='mobile'] .vela-statusline .vela-sl-ohlc { display: none !important; }
40081
- [data-layout='mobile'] .vela-statusline .vela-sl-change {
40082
- /* Second row, under the text column \u2014 the avatar keeps the first column. */
40083
- grid-column: 2 / -1;
40084
- font-size: var(--vela-font-size-sm);
40085
- line-height: 1.2;
40086
- }
40087
- [data-layout='mobile'] .vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(40px); }
40088
- /* FIT mode (multi-chart cells \u2014 see setFitMode): the line never wraps; segments that
40089
- * don't fit are HIDDEN by fit() (change first, then meta, then the market badge), so
40090
- * overflow:hidden only guards the transient between a resize and the next measure.
40091
- * Placed after the mobile block on purpose: same specificity, later wins. */
40092
- .vela-statusline.vela-sl-fit {
40093
- display: flex; /* undo the mobile grid \u2014 fit mode is one flex row again */
40094
- flex-wrap: nowrap;
40095
- align-items: center;
40096
- max-width: calc(100% - var(--vela-toolbar-gutter, 0px) - var(--vela-scale-gutter, 0px) - 24px);
40097
- overflow: hidden;
40098
- }
40099
- .vela-statusline.vela-sl-fit .vela-sl-change {
40100
- flex-basis: auto;
40101
- padding-left: 0;
40102
- }
40103
- /* One row again \u2014 the mobile two-line shift doesn't apply in fit mode. */
40104
- [data-layout='mobile'] .vela-sl-fit-host.vela-has-statusline [${LEGEND_AT_TOP_ATTR}] { transform: translateY(26px); }
40615
+ /* A stacked chip is two lines tall \u2014 the legend shifts one more line. fit() flags the
40616
+ * host whenever the ladder lands on a stacked layout. */
40617
+ .vela-has-statusline.vela-sl-stacked-host [${LEGEND_AT_TOP_ATTR}] { transform: translateY(40px); }
40618
+ /* Mobile: the same ladder at the phone's type scale \u2014 the width alone decides how much
40619
+ * of the readout fits, exactly as on a narrow desktop chart. */
40620
+ [data-layout='mobile'] .vela-statusline { font-size: var(--vela-font-size-sm); }
40621
+ [data-layout='mobile'] .vela-statusline .vela-sl-symbol { font-size: var(--vela-font-size-md); }
40105
40622
  `;
40106
40623
  function baseOfTicker(ticker) {
40107
40624
  return ticker.replace(/[-_/]?(USDT|USDC|USD1|USDS|BUSD|USD|EUR|PERP)$/i, "") || ticker;
@@ -40172,6 +40689,8 @@ var Statusline = class {
40172
40689
  /** Fit mode (multi-chart cells): one row, overflowing segments hidden — see {@link setFitMode}. */
40173
40690
  this.fitMode = false;
40174
40691
  this.fitRO = null;
40692
+ /** The ladder rung currently applied — see {@link fit}. */
40693
+ this.layout = { stacked: false, level: "full" };
40175
40694
  this.onContextMenu = (e) => {
40176
40695
  if (!this.menu || !this.menuHooks) return;
40177
40696
  e.preventDefault();
@@ -40218,8 +40737,18 @@ var Statusline = class {
40218
40737
  this.menuHooks?.setChartVisible(true);
40219
40738
  this.setChartHidden(false);
40220
40739
  });
40221
- this.el.append(this.avatarEl, this.symbolEl, this.metaEl, this.marketEl, this.ohlcEl, this.changeEl, this.eyeEl);
40740
+ this.identityRow = doc.createElement("span");
40741
+ this.identityRow.className = "vela-sl-row vela-sl-identity";
40742
+ this.identityRow.append(this.avatarEl, this.symbolEl, this.metaEl, this.marketEl);
40743
+ this.valuesRow = doc.createElement("span");
40744
+ this.valuesRow.className = "vela-sl-row vela-sl-values";
40745
+ this.valuesRow.append(this.ohlcEl, this.changeEl, this.eyeEl);
40746
+ this.el.append(this.identityRow, this.valuesRow);
40222
40747
  host.appendChild(this.el);
40748
+ if (typeof ResizeObserver !== "undefined") {
40749
+ this.fitRO = new ResizeObserver(() => this.fit());
40750
+ this.fitRO.observe(host);
40751
+ }
40223
40752
  this.marketTip = new Tooltip(this.marketEl, { content: MARKET_LABELS.open, placement: "bottom" });
40224
40753
  this.eyeTip = new Tooltip(this.eyeEl, { content: "Show chart", placement: "bottom" });
40225
40754
  this.setMarketStatus("open");
@@ -40235,27 +40764,15 @@ var Statusline = class {
40235
40764
  this.fit();
40236
40765
  }
40237
40766
  /**
40238
- * Multi-chart cells: keep the line on ONE row whatever the cell width — never wrap.
40239
- * Segments that don't fit are hidden outright rather than clipped mid-glyph, least
40240
- * important first: OHLC, then the bar change, the venue/timeframe meta, and the
40241
- * market badge; the logo + ticker always stay. Re-fits live on host resizes.
40767
+ * Multi-chart cells: the same width ladder as a single chart, plus a last resort for
40768
+ * cells too narrow for even its bottom rung — whatever still overflows is hidden
40769
+ * outright rather than clipped mid-glyph, least important first: the values, the
40770
+ * venue/timeframe meta, then the market badge; the logo + ticker always stay.
40242
40771
  */
40243
40772
  setFitMode(on) {
40244
40773
  if (on === this.fitMode) return;
40245
40774
  this.fitMode = on;
40246
- this.el.classList.toggle("vela-sl-fit", on);
40247
- this.host.classList.toggle("vela-sl-fit-host", on);
40248
- if (on) {
40249
- if (typeof ResizeObserver !== "undefined" && !this.fitRO) {
40250
- this.fitRO = new ResizeObserver(() => this.fit());
40251
- this.fitRO.observe(this.host);
40252
- }
40253
- this.fit();
40254
- } else {
40255
- this.fitRO?.disconnect();
40256
- this.fitRO = null;
40257
- this.syncParts();
40258
- }
40775
+ this.fit();
40259
40776
  }
40260
40777
  /** Project the parts config onto the segments (the baseline fit() prunes from). */
40261
40778
  syncParts() {
@@ -40267,22 +40784,42 @@ var Statusline = class {
40267
40784
  this.ohlcEl.style.display = seg.ohlc ? "" : "none";
40268
40785
  this.changeEl.style.display = seg.change ? "" : "none";
40269
40786
  this.eyeEl.style.display = seg.eye ? "inline-flex" : "none";
40787
+ this.valuesRow.style.display = seg.ohlc || seg.change || seg.eye ? "" : "none";
40788
+ }
40789
+ /** Apply one ladder rung: the stacked/inline shape and the readout's level. */
40790
+ applyLayout(layout) {
40791
+ this.layout = layout;
40792
+ this.el.classList.toggle("vela-sl-stacked", layout.stacked);
40793
+ this.host.classList.toggle("vela-sl-stacked-host", layout.stacked);
40794
+ this.renderValues();
40270
40795
  }
40271
- /** Hide overflowing segments until the row fits its max-width (fit mode only). */
40796
+ /**
40797
+ * Walk the width ladder (see {@link STATUSLINE_LADDER}) until the chip fits its
40798
+ * max-width: the full one-line readout, then the values stacked under the symbol
40799
+ * line, then O/H/L shed, then the absolute change. In fit mode, when the last rung
40800
+ * still overflows, whole segments hide, least important first. Without layout
40801
+ * (detached host, node tests) nothing overflows, so the widest rung stays.
40802
+ */
40272
40803
  fit() {
40273
- if (!this.fitMode) return;
40274
40804
  this.syncParts();
40275
40805
  const seg = segmentVisibility(this.parts, this.chartHidden);
40806
+ const overflows = () => this.el.scrollWidth > this.el.clientWidth;
40807
+ const hasValues = seg.ohlc || seg.change;
40808
+ for (const rung of STATUSLINE_LADDER) {
40809
+ this.applyLayout(rung);
40810
+ if (!hasValues || !overflows()) break;
40811
+ }
40812
+ if (!this.fitMode) return;
40276
40813
  const order = [
40277
- [this.ohlcEl, seg.ohlc],
40278
- [this.changeEl, seg.change],
40814
+ [this.valuesRow, hasValues],
40279
40815
  [this.metaEl, seg.meta],
40280
40816
  [this.marketEl, seg.market]
40281
40817
  ];
40282
40818
  for (const [el, shown] of order) {
40283
- if (this.el.scrollWidth <= this.el.clientWidth) break;
40819
+ if (!overflows()) break;
40284
40820
  if (shown) el.style.display = "none";
40285
40821
  }
40822
+ if (this.valuesRow.style.display === "none") this.applyLayout({ ...this.layout, stacked: false });
40286
40823
  }
40287
40824
  /** Shape + color the value readout after the active price style: its own up/down
40288
40825
  * colors (candle bodies, bar ticks, the line color, …), the direction rule that
@@ -40395,7 +40932,7 @@ var Statusline = class {
40395
40932
  this.marketTip.destroy();
40396
40933
  this.eyeTip.destroy();
40397
40934
  this.marketBubble.destroy();
40398
- this.host.classList.remove("vela-has-statusline", "vela-sl-fit-host");
40935
+ this.host.classList.remove("vela-has-statusline", "vela-sl-stacked-host");
40399
40936
  this.el.remove();
40400
40937
  }
40401
40938
  detach() {
@@ -40404,6 +40941,10 @@ var Statusline = class {
40404
40941
  }
40405
40942
  render() {
40406
40943
  if (this.menuHooks) this.setChartHidden(!this.menuHooks.chartVisible());
40944
+ this.fit();
40945
+ }
40946
+ /** Write the value readout for the current bar at the current ladder level. */
40947
+ renderValues() {
40407
40948
  const bar = this.hoverBar ?? this.lastBar;
40408
40949
  if (!bar) {
40409
40950
  this.ohlcEl.replaceChildren();
@@ -40423,12 +40964,11 @@ var Statusline = class {
40423
40964
  s.appendChild(b);
40424
40965
  return s;
40425
40966
  };
40426
- if (this.readout === "value") this.ohlcEl.replaceChildren(cell("", bar.close));
40427
- else this.ohlcEl.replaceChildren(cell("O", bar.open), cell("H", bar.high), cell("L", bar.low), cell("C", bar.close));
40428
- this.changeEl.textContent = fmtChange(bar.open, bar.close);
40967
+ const { level } = this.layout;
40968
+ this.ohlcEl.replaceChildren(...readoutCells(level, this.readout).map((c) => cell(c.label, bar[c.key])));
40969
+ this.changeEl.textContent = level === "minimal" ? fmtChangePct(bar.open, bar.close) : fmtChange(bar.open, bar.close);
40429
40970
  this.changeEl.dataset.dir = up ? "up" : "down";
40430
40971
  this.changeEl.style.color = ink;
40431
- this.fit();
40432
40972
  }
40433
40973
  };
40434
40974
 
@@ -42568,6 +43108,17 @@ function nextAutoCellId(taken) {
42568
43108
  if (!taken.has(`c${i}`)) return `c${i}`;
42569
43109
  }
42570
43110
  }
43111
+ function resolveTyping(key, open2) {
43112
+ if (open2.dialogs > 0) {
43113
+ if (key.length !== 1) return null;
43114
+ if (open2.symbolSearch) return { target: "symbol", text: key.toUpperCase() };
43115
+ if (open2.timeframeEntry) return { target: "timeframe", text: key };
43116
+ return null;
43117
+ }
43118
+ if (/^[a-zA-Z]$/.test(key)) return { target: "symbol", text: key.toUpperCase() };
43119
+ if (/^[0-9]$/.test(key)) return { target: "timeframe", text: key };
43120
+ return null;
43121
+ }
42571
43122
  var VelaWorkspace = class {
42572
43123
  constructor(container, opts = {}) {
42573
43124
  this.events = new TypedEventBus();
@@ -44201,17 +44752,14 @@ var VelaWorkspace = class {
44201
44752
  }
44202
44753
  /** Bare-typing router: letters → symbol search (seeded), digits → timeframe entry. */
44203
44754
  routeTyping(ev) {
44204
- if (this.destroyed || this.openDialogs > 0) return;
44755
+ if (this.destroyed) return;
44205
44756
  if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
44206
44757
  if (isEditableTarget(ev)) return;
44207
- const key = ev.key;
44208
- if (/^[a-zA-Z]$/.test(key)) {
44209
- ev.preventDefault();
44210
- this.symbolPicker.open(key.toUpperCase());
44211
- } else if (/^[0-9]$/.test(key)) {
44212
- ev.preventDefault();
44213
- this.tfQuick.open(key);
44214
- }
44758
+ const hit = resolveTyping(ev.key, { dialogs: this.openDialogs, symbolSearch: this.symbolPicker.isOpen, timeframeEntry: this.tfQuick.isOpen });
44759
+ if (!hit) return;
44760
+ ev.preventDefault();
44761
+ if (hit.target === "symbol") this.symbolPicker.type(hit.text);
44762
+ else this.tfQuick.type(hit.text);
44215
44763
  }
44216
44764
  trackDialog(open2) {
44217
44765
  this.openDialogs = Math.max(0, this.openDialogs + (open2 ? 1 : -1));