@luxalgo/vela 0.6.10 → 0.6.12

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 (42) hide show
  1. package/README.md +110 -46
  2. package/dist/{DataProvider-CWmp31dA.d.ts → DataProvider-DhzstpQb.d.ts} +1 -1
  3. package/dist/{DataProvider-l_eLMly_.d.cts → DataProvider-DlMtrwqM.d.cts} +1 -1
  4. package/dist/{chunk-4KZVZ7QQ.js → chunk-F4M24ANM.js} +130 -1
  5. package/dist/{chunk-NMTQXNT4.js → chunk-IZV3CW5N.js} +245 -43
  6. package/dist/{chunk-FFOP37FQ.js → chunk-KV7FDWSL.js} +748 -70
  7. package/dist/{contributions-BCz6Dr6a.d.ts → contributions-37nni40G.d.ts} +20 -4
  8. package/dist/{contributions-D9vTzm5p.d.cts → contributions-lPojhTxI.d.cts} +20 -4
  9. package/dist/index.cjs +866 -58
  10. package/dist/index.d.cts +12 -6
  11. package/dist/index.d.ts +12 -6
  12. package/dist/index.js +3 -3
  13. package/dist/{options-DSqHsQyN.d.cts → options-CYS5Wmlx.d.cts} +78 -2
  14. package/dist/{options-DSqHsQyN.d.ts → options-CYS5Wmlx.d.ts} +78 -2
  15. package/dist/{plugin-Bt8hLR8Z.d.cts → plugin-Cwikpz1m.d.cts} +10 -3
  16. package/dist/{plugin-CH7pfMrE.d.ts → plugin-DHyaoMjW.d.ts} +10 -3
  17. package/dist/plugin.cjs +119 -0
  18. package/dist/plugin.d.cts +4 -4
  19. package/dist/plugin.d.ts +4 -4
  20. package/dist/plugin.js +2 -2
  21. package/dist/providers/binance.d.cts +2 -2
  22. package/dist/providers/binance.d.ts +2 -2
  23. package/dist/providers/coinbase.d.cts +2 -2
  24. package/dist/providers/coinbase.d.ts +2 -2
  25. package/dist/providers/hyperliquid.d.cts +2 -2
  26. package/dist/providers/hyperliquid.d.ts +2 -2
  27. package/dist/{statusline-5K7y4Ya2.d.ts → statusline-model-CiJm-riV.d.cts} +17 -87
  28. package/dist/{statusline-TYLQmoeh.d.cts → statusline-model-D-q_OOx9.d.ts} +17 -87
  29. package/dist/ui.d.cts +1 -1
  30. package/dist/ui.d.ts +1 -1
  31. package/dist/vela.global.js +866 -58
  32. package/dist/vela.global.min.js +52 -52
  33. package/dist/widget.cjs +1109 -100
  34. package/dist/widget.d.cts +118 -7
  35. package/dist/widget.d.ts +118 -7
  36. package/dist/widget.js +4 -4
  37. package/dist/workspace.cjs +1109 -100
  38. package/dist/workspace.d.cts +20 -11
  39. package/dist/workspace.d.ts +20 -11
  40. package/dist/workspace.js +3 -3
  41. package/package.json +1 -1
  42. /package/dist/{chunk-AOGZBKUE.js → chunk-H26BEHF4.js} +0 -0
@@ -130,6 +130,12 @@ var Vela = (function (exports) {
130
130
  get visible() {
131
131
  return this.visibleState;
132
132
  }
133
+ inputValues() {
134
+ return this.controller.inputValuesOf(this.id);
135
+ }
136
+ propValues() {
137
+ return this.controller.propValuesOf(this.id);
138
+ }
133
139
  setInput(key, value) {
134
140
  this.controller.applyInputs(this.id, { [key]: value });
135
141
  }
@@ -5394,6 +5400,111 @@ var Vela = (function (exports) {
5394
5400
  return Math.round(n * 1e8) / 1e8;
5395
5401
  }
5396
5402
 
5403
+ // src/core/drawings/types/Magnifier.ts
5404
+ var MAGNIFIER_TIMEFRAME_OPTIONS = [
5405
+ { value: "auto", label: "Auto", ms: 0 },
5406
+ { value: "1", label: "1m", ms: 6e4 },
5407
+ { value: "5", label: "5m", ms: 3e5 },
5408
+ { value: "15", label: "15m", ms: 9e5 },
5409
+ { value: "30", label: "30m", ms: 18e5 },
5410
+ { value: "60", label: "1h", ms: 36e5 },
5411
+ { value: "240", label: "4h", ms: 144e5 },
5412
+ { value: "D", label: "1D", ms: 864e5 }
5413
+ ];
5414
+ function magnifierTimeframeLabel(value) {
5415
+ const opt = MAGNIFIER_TIMEFRAME_OPTIONS.find((o) => o.value === value);
5416
+ if (opt) return opt.label;
5417
+ const n = Number(value);
5418
+ if (Number.isFinite(n) && n > 0) {
5419
+ if (n % 1440 === 0) return `${n / 1440}D`;
5420
+ if (n % 60 === 0) return `${n / 60}h`;
5421
+ return `${n}m`;
5422
+ }
5423
+ return value;
5424
+ }
5425
+ function defaultMagnifierStyle() {
5426
+ return { timeframe: "auto", upColor: "", downColor: "" };
5427
+ }
5428
+ var Magnifier = class extends Drawing {
5429
+ constructor(init) {
5430
+ super(init);
5431
+ this.type = "magnifier";
5432
+ /** Pixel rect of the timeframe chip as painted last frame, caret included — the chip is
5433
+ * an interactive dropdown trigger, so the interaction layer needs the exact rect the
5434
+ * painter measured. Renderer-transient: never serialized, null while unpainted. */
5435
+ this.chipRect = null;
5436
+ if (!this.magnifier) this.magnifier = defaultMagnifierStyle();
5437
+ }
5438
+ anchorSchema() {
5439
+ return { min: 2, max: 2, slots: [{ role: "c1", free: "both" }, { role: "c2", free: "both" }] };
5440
+ }
5441
+ placementMode() {
5442
+ return "drag";
5443
+ }
5444
+ /** The pixel rectangle between the two corner anchors (painter + hit-test share it). */
5445
+ rect(proj) {
5446
+ const a = this.anchors[0];
5447
+ const b = this.anchors[1];
5448
+ if (!a || !b) return null;
5449
+ const ya = proj.yOf(a.price, this.paneId);
5450
+ const yb = proj.yOf(b.price, this.paneId);
5451
+ if (ya == null || yb == null) return null;
5452
+ return { x1: proj.xOf(a.time), y1: ya, x2: proj.xOf(b.time), y2: yb };
5453
+ }
5454
+ hitTest(px, py, proj, tol) {
5455
+ const r = this.rect(proj);
5456
+ if (!r) return false;
5457
+ if (pointInBox(px, py, r.x1, r.y1, r.x2, r.y2)) return true;
5458
+ const edges = [
5459
+ [r.x1, r.y1, r.x2, r.y1],
5460
+ [r.x2, r.y1, r.x2, r.y2],
5461
+ [r.x2, r.y2, r.x1, r.y2],
5462
+ [r.x1, r.y2, r.x1, r.y1]
5463
+ ];
5464
+ return edges.some((e) => distToSegment(px, py, e[0], e[1], e[2], e[3]) <= tol);
5465
+ }
5466
+ handlePoints(proj) {
5467
+ const r = this.rect(proj);
5468
+ return r ? [[r.x1, r.y1], [r.x2, r.y2]] : [];
5469
+ }
5470
+ hitHandle(px, py, proj, tol) {
5471
+ return handleAt(px, py, this.handlePoints(proj), tol + 3);
5472
+ }
5473
+ bounds(proj) {
5474
+ const r = this.rect(proj);
5475
+ if (!r) return null;
5476
+ return { x: Math.min(r.x1, r.x2), y: Math.min(r.y1, r.y2), w: Math.abs(r.x2 - r.x1), h: Math.abs(r.y2 - r.y1) };
5477
+ }
5478
+ priceRange() {
5479
+ const a = this.anchors[0];
5480
+ const b = this.anchors[1];
5481
+ if (!a || !b) return null;
5482
+ return { min: Math.min(a.price, b.price), max: Math.max(a.price, b.price) };
5483
+ }
5484
+ schema() {
5485
+ return {
5486
+ fields: [
5487
+ {
5488
+ path: "magnifier.timeframe",
5489
+ label: "Timeframe",
5490
+ kind: "select",
5491
+ options: MAGNIFIER_TIMEFRAME_OPTIONS,
5492
+ group: "behavior"
5493
+ },
5494
+ ...LINE_FIELDS.map((f) => ({ ...f, label: f.label.replace("Line", "Border") })),
5495
+ { path: "magnifier.upColor", label: "Up candles", kind: "color", group: "fill" },
5496
+ { path: "magnifier.downColor", label: "Down candles", kind: "color", group: "fill" }
5497
+ ]
5498
+ };
5499
+ }
5500
+ writeProps() {
5501
+ return { ...this.magnifier };
5502
+ }
5503
+ readProps(props) {
5504
+ this.magnifier = { ...defaultMagnifierStyle(), ...props };
5505
+ }
5506
+ };
5507
+
5397
5508
  // src/core/drawings/registry.ts
5398
5509
  var REGISTRY2 = /* @__PURE__ */ new Map();
5399
5510
  function registerDrawingType(meta) {
@@ -6053,6 +6164,22 @@ var Vela = (function (exports) {
6053
6164
  defaultStyle: { lineColor: DEFAULT_DRAWING_COLOR, lineWidth: 1, lineStyle: "solid" },
6054
6165
  create: (init) => new PositionTool(init)
6055
6166
  });
6167
+ var MAGNIFIER_ICON = svg24(
6168
+ '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.3 15.3 5.2 5.2"/><path d="M8 12.5v-3M10.5 13.5v-5.5M13 12v-2"/>'
6169
+ );
6170
+ registerDrawingType({
6171
+ type: "magnifier",
6172
+ group: "measure",
6173
+ label: "Magnifier",
6174
+ icon: MAGNIFIER_ICON,
6175
+ // An empty border color means the THEME's contrast ink (white on dark, black on
6176
+ // light), resolved at paint time so it follows theme switches; a user pick wins.
6177
+ defaultStyle: { lineColor: "", lineWidth: 1, lineStyle: "solid" },
6178
+ coversSeries: true,
6179
+ // the inset's backdrop must sit over the base candles it replaces
6180
+ placementHint: "Drag an area on the chart to view it at a lower timeframe",
6181
+ create: (init) => new Magnifier(init)
6182
+ });
6056
6183
  var VWAP_ICON = svg24('<line x1="5" y1="3" x2="5" y2="21"/><path d="M5 16c4 0 5-9 8-9s3 5 8 3"/>');
6057
6184
  registerDrawingType({
6058
6185
  type: "anchoredvwap",
@@ -6092,7 +6219,7 @@ var Vela = (function (exports) {
6092
6219
  var PATTERN_TYPES = ["xabcd", "abcd", "headshoulders"];
6093
6220
  var ELLIOTT_TYPES = ["elliottimpulse", "elliottcorrection"];
6094
6221
  var HARMONIC_TYPES = ["gartley", "bat", "butterfly", "crab", "shark", "cypher"];
6095
- var MEASUREMENT_TYPES = ["position", "datepricerange"];
6222
+ var MEASUREMENT_TYPES = ["position", "datepricerange", "magnifier"];
6096
6223
  var VOLUME_TYPES = ["anchoredvwap", "fixedrangevp"];
6097
6224
  var BRUSH_TYPES = ["freehand", "highlighter"];
6098
6225
  var ARROW_TYPES = ["arrow", "arrowmarkup", "arrowmarkdown"];
@@ -6456,7 +6583,7 @@ var Vela = (function (exports) {
6456
6583
 
6457
6584
  // src/core/drawings/DrawingController.ts
6458
6585
  var DrawingController = class {
6459
- constructor(renderer, events, option) {
6586
+ constructor(renderer, events, option, seriesGateway) {
6460
6587
  this.events = events;
6461
6588
  this.store = new DrawingStore();
6462
6589
  this.history = new DrawingHistory();
@@ -6482,6 +6609,7 @@ var Vela = (function (exports) {
6482
6609
  const { definition, visible } = buildToolbar(option);
6483
6610
  this.port.setToolbar(definition);
6484
6611
  this.port.showToolbar(visible);
6612
+ if (seriesGateway) this.port.setSeriesGateway?.(seriesGateway);
6485
6613
  this.subs.push(this.port.onDrawingIntent((i) => this.onIntent(i)));
6486
6614
  this.subs.push(this.store.onChange(() => this.sync()));
6487
6615
  }
@@ -6587,7 +6715,7 @@ var Vela = (function (exports) {
6587
6715
  style,
6588
6716
  text: init.text,
6589
6717
  props: init.props,
6590
- zIndex: init.zIndex ?? this.startZ(init.paneId ?? "price")
6718
+ zIndex: init.zIndex ?? this.startZ(type, init.paneId ?? "price")
6591
6719
  });
6592
6720
  if (!d) return null;
6593
6721
  this.history.record(this.store.serialize());
@@ -6662,10 +6790,14 @@ var Vela = (function (exports) {
6662
6790
  * (falling back to just under the pane's top series where there is no price — a study
6663
6791
  * pane). Half a key down never ties a series; drawings tying each other paint in insertion
6664
6792
  * order, so consecutive new drawings still stack newest-in-front. Undefined without a
6665
- * shared z space — the store then places it over the other drawings, its own layer's top. */
6666
- startZ(paneId) {
6793
+ * shared z space — the store then places it over the other drawings, its own layer's top.
6794
+ * A type that COVERS the series (an opaque inset, `coversSeries`) instead starts just
6795
+ * above the whole stack — under the candles its content would be buried. */
6796
+ startZ(type, paneId) {
6667
6797
  const range = this.port?.stackRange?.(paneId);
6668
- return range ? (range.price ?? range.front) - 0.5 : void 0;
6798
+ if (!range) return void 0;
6799
+ if (getDrawingType(type)?.coversSeries) return range.front + 0.5;
6800
+ return (range.price ?? range.front) - 0.5;
6669
6801
  }
6670
6802
  /** Programmatically select drawings (host UI → chart): shows the on-chart handles + toolbar.
6671
6803
  * `additive` toggles membership (matching shift-click) instead of replacing. */
@@ -6810,7 +6942,7 @@ var Vela = (function (exports) {
6810
6942
  const style = last2 ? { ...i.doc.style, ...last2 } : i.doc.style;
6811
6943
  const d = deserializeDrawing({ ...i.doc, id: this.store.nextId(), style });
6812
6944
  if (!d) return;
6813
- if (!d.zIndex) d.zIndex = this.startZ(d.paneId) ?? 0;
6945
+ if (!d.zIndex) d.zIndex = this.startZ(d.type, d.paneId) ?? 0;
6814
6946
  this.history.record(before);
6815
6947
  this.store.add(d);
6816
6948
  this.captureStyle(d.id);
@@ -6908,6 +7040,180 @@ var Vela = (function (exports) {
6908
7040
  return 36e5;
6909
7041
  }
6910
7042
 
7043
+ // src/core/engine/DrawingSeriesService.ts
7044
+ var MAX_BARS = 5e3;
7045
+ var PAD_FRAC = 0.25;
7046
+ var MAX_ENTRIES = 16;
7047
+ var RETRY_MS = 15e3;
7048
+ var AUTO_STEPS = ["240", "60", "30", "15", "5", "1"];
7049
+ var DrawingSeriesService = class {
7050
+ constructor(deps) {
7051
+ this.deps = deps;
7052
+ /** Cached windows per `market|timeframe` key, newest-used last (LRU across keys). */
7053
+ this.cache = /* @__PURE__ */ new Map();
7054
+ this.listeners = /* @__PURE__ */ new Set();
7055
+ }
7056
+ seriesInRange(timeframe, from, to) {
7057
+ if (!this.deps.canFetch()) return { state: "unavailable", reason: "no-source" };
7058
+ const resolved = this.resolveTimeframe(timeframe);
7059
+ if (typeof resolved !== "string") return { state: "unavailable", reason: resolved.reason };
7060
+ const barMs = timeframeToMs(resolved);
7061
+ const lo = Math.min(from, to);
7062
+ const hi = Math.max(from, to);
7063
+ if (!(hi > lo) || !(barMs > 0)) return { state: "unavailable", reason: "not-lower" };
7064
+ if ((hi - lo) / barMs > MAX_BARS) return { state: "unavailable", reason: "too-wide" };
7065
+ const key = `${this.deps.marketKey()}|${resolved}`;
7066
+ const entries = this.cache.get(key) ?? [];
7067
+ const covering = entries.find((e) => e.from <= lo && e.to >= hi);
7068
+ if (covering) {
7069
+ if (covering.pending) return this.loading(entries, resolved, barMs, lo, hi);
7070
+ if (covering.failedAt > 0) {
7071
+ if (Date.now() - covering.failedAt < RETRY_MS) return this.loading(entries, resolved, barMs, lo, hi);
7072
+ entries.splice(entries.indexOf(covering), 1);
7073
+ } else {
7074
+ this.maybeRefresh(key, covering, resolved, barMs, hi);
7075
+ return { state: "ready", bars: this.slice(covering.bars, lo, hi), timeframe: resolved, barMs };
7076
+ }
7077
+ }
7078
+ this.fetchWindow(key, entries, resolved, lo, hi);
7079
+ return this.loading(entries, resolved, barMs, lo, hi);
7080
+ }
7081
+ onUpdate(listener) {
7082
+ this.listeners.add(listener);
7083
+ return () => this.listeners.delete(listener);
7084
+ }
7085
+ // ── internals ──
7086
+ /** `'auto'` → the largest standard step at least 4× finer than the chart (else the finest
7087
+ * step still below it); an explicit timeframe passes only when strictly finer. Failures
7088
+ * distinguish "this pick isn't lower" from "NOTHING lower exists" (the chart is already
7089
+ * at the finest offered step) so the consumer can word its notice honestly. */
7090
+ resolveTimeframe(timeframe) {
7091
+ const chartMs = timeframeToMs(this.deps.chartTimeframe());
7092
+ const finest = AUTO_STEPS[AUTO_STEPS.length - 1];
7093
+ if (timeframeToMs(finest) >= chartMs) return { reason: "none-lower" };
7094
+ const tf = timeframe.trim() || "auto";
7095
+ if (tf === "auto") {
7096
+ for (const step of AUTO_STEPS) {
7097
+ if (timeframeToMs(step) <= chartMs / 4) return step;
7098
+ }
7099
+ return finest;
7100
+ }
7101
+ return timeframeToMs(tf) < chartMs ? tf : { reason: "not-lower" };
7102
+ }
7103
+ /** The `loading` answer, carrying best-effort PARTIAL bars from settled overlapping
7104
+ * windows — a widened window keeps painting what it already has while it fetches. */
7105
+ loading(entries, timeframe, barMs, lo, hi) {
7106
+ const partial = /* @__PURE__ */ new Map();
7107
+ for (const e of entries) {
7108
+ if (e.pending || e.failedAt > 0) continue;
7109
+ if (e.to < lo || e.from > hi) continue;
7110
+ for (const b of this.slice(e.bars, lo, hi)) partial.set(b.time, b);
7111
+ }
7112
+ if (partial.size === 0) return { state: "loading", timeframe, barMs };
7113
+ const bars = [...partial.values()].sort((a, b) => a.time - b.time);
7114
+ return { state: "loading", timeframe, barMs, bars };
7115
+ }
7116
+ /** Kick ONE background fetch for the padded window. Any OVERLAPPING in-flight fetch
7117
+ * defers this one (a corner drag repaints per pointer move — kicking a window per
7118
+ * frame would spam the provider); when it lands, the next paint re-evaluates. */
7119
+ fetchWindow(key, entries, timeframe, lo, hi) {
7120
+ if (entries.some((e) => e.pending && e.to >= lo && e.from <= hi)) return;
7121
+ const pad = (hi - lo) * PAD_FRAC;
7122
+ const entry = { from: lo - pad, to: hi + pad, bars: [], fetchedAt: 0, pending: true, failedAt: 0 };
7123
+ entries.push(entry);
7124
+ this.cache.set(key, entries);
7125
+ this.evict();
7126
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
7127
+ entry.bars = bars;
7128
+ entry.fetchedAt = Date.now();
7129
+ entry.pending = false;
7130
+ this.absorbOverlaps(key, entry);
7131
+ this.fire();
7132
+ }).catch(() => {
7133
+ entry.pending = false;
7134
+ entry.failedAt = Date.now();
7135
+ this.fire();
7136
+ });
7137
+ }
7138
+ /** A window whose right edge reaches the newest fetched bar refreshes at most once per
7139
+ * bar interval — new closed bars ride the feed's cache, only the live tail re-fetches. */
7140
+ maybeRefresh(key, entry, timeframe, barMs, hi) {
7141
+ if (entry.pending) return;
7142
+ const lastBar = entry.bars.length > 0 ? entry.bars[entry.bars.length - 1].time : entry.from;
7143
+ if (hi < lastBar) return;
7144
+ if (Date.now() - entry.fetchedAt < barMs) return;
7145
+ entry.pending = true;
7146
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
7147
+ entry.bars = bars;
7148
+ entry.fetchedAt = Date.now();
7149
+ entry.pending = false;
7150
+ this.fire();
7151
+ }).catch(() => {
7152
+ entry.pending = false;
7153
+ entry.fetchedAt = Date.now();
7154
+ });
7155
+ }
7156
+ /** Merge windows that overlap `entry` into it (dedupe by bar time) so a key's list
7157
+ * converges instead of accumulating slivers. */
7158
+ absorbOverlaps(key, entry) {
7159
+ const entries = this.cache.get(key);
7160
+ if (!entries) return;
7161
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
7162
+ const other = entries[i];
7163
+ if (other === entry || other.pending || other.failedAt > 0) continue;
7164
+ if (other.to < entry.from || other.from > entry.to) continue;
7165
+ const byTime = /* @__PURE__ */ new Map();
7166
+ for (const b of other.bars) byTime.set(b.time, b);
7167
+ for (const b of entry.bars) byTime.set(b.time, b);
7168
+ entry.bars = [...byTime.values()].sort((a, b) => a.time - b.time);
7169
+ entry.from = Math.min(entry.from, other.from);
7170
+ entry.to = Math.max(entry.to, other.to);
7171
+ entry.fetchedAt = Math.min(entry.fetchedAt, other.fetchedAt || entry.fetchedAt);
7172
+ entries.splice(i, 1);
7173
+ }
7174
+ }
7175
+ /** Drop the oldest settled windows once the global count passes {@link MAX_ENTRIES}. */
7176
+ evict() {
7177
+ let total = 0;
7178
+ for (const entries of this.cache.values()) total += entries.length;
7179
+ while (total > MAX_ENTRIES) {
7180
+ let oldestKey = null;
7181
+ let oldestIdx = -1;
7182
+ let oldestAt = Infinity;
7183
+ for (const [key, entries2] of this.cache) {
7184
+ for (let i = 0; i < entries2.length; i += 1) {
7185
+ const e = entries2[i];
7186
+ if (e.pending) continue;
7187
+ const at = e.fetchedAt || e.failedAt;
7188
+ if (at < oldestAt) {
7189
+ oldestKey = key;
7190
+ oldestIdx = i;
7191
+ oldestAt = at;
7192
+ }
7193
+ }
7194
+ }
7195
+ if (oldestKey == null) return;
7196
+ const entries = this.cache.get(oldestKey);
7197
+ entries.splice(oldestIdx, 1);
7198
+ if (entries.length === 0) this.cache.delete(oldestKey);
7199
+ total -= 1;
7200
+ }
7201
+ }
7202
+ /** Bars whose open time falls within `[lo, hi]` (ascending input → linear scan is fine). */
7203
+ slice(bars, lo, hi) {
7204
+ const out = [];
7205
+ for (const b of bars) {
7206
+ if (b.time < lo) continue;
7207
+ if (b.time > hi) break;
7208
+ out.push(b);
7209
+ }
7210
+ return out;
7211
+ }
7212
+ fire() {
7213
+ for (const l of [...this.listeners]) l();
7214
+ }
7215
+ };
7216
+
6911
7217
  // src/data/symbol-groups.ts
6912
7218
  function isGroupRow(d) {
6913
7219
  return d.group != null && d.ticker === d.group;
@@ -7272,6 +7578,7 @@ var Vela = (function (exports) {
7272
7578
  var PREVIEW_BARS = 300;
7273
7579
  var SINGLE_LOAD_BARS = 5e3;
7274
7580
  var CHUNK_BARS = 1e4;
7581
+ var FIRST_PAINT_BARS = 100;
7275
7582
  var GAP_FACTOR = 1.5;
7276
7583
  var HEAL_COOLDOWN_MS = 5e3;
7277
7584
  var EngineOrchestrator = class _EngineOrchestrator {
@@ -7389,7 +7696,13 @@ var Vela = (function (exports) {
7389
7696
  const initialStyle = this.renderer.readFeature("priceStyle");
7390
7697
  if (typeof initialStyle === "string") this.priceStyle = initialStyle;
7391
7698
  this.barTransform = barTransformFor(initialStyle);
7392
- this.drawings = new DrawingController(this.renderer, this.events, config.drawings);
7699
+ const drawingSeries = new DrawingSeriesService({
7700
+ fetchBars: (tf, range) => this.fetchSeries(this.config.market.symbol ?? "", tf, range),
7701
+ canFetch: () => !!this.feed.loadRange && !this.config.market.data?.length && !!this.config.market.symbol,
7702
+ chartTimeframe: () => this.config.market.timeframe ?? "60",
7703
+ marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
7704
+ });
7705
+ this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
7393
7706
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7394
7707
  this.endLoad();
7395
7708
  this.events.emit("data:unresolved", info);
@@ -7541,6 +7854,7 @@ var Vela = (function (exports) {
7541
7854
  let painted = false;
7542
7855
  const paint = (bars, final) => {
7543
7856
  if (this.generation !== gen || !final && bars.length === 0) return;
7857
+ if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
7544
7858
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
7545
7859
  if (!painted && bars.length > 0) {
7546
7860
  painted = true;
@@ -8171,6 +8485,15 @@ var Vela = (function (exports) {
8171
8485
  record.pendingCause = "inputs";
8172
8486
  if (record.session) record.session.update(record.inputValues);
8173
8487
  else if (record.native && !record.hidden) record.native.instance.setInputs(record.inputValues);
8488
+ this.events.emit("indicator:inputs", { id });
8489
+ }
8490
+ /** IndicatorController: the CURRENT stored input values (defaults merged with edits). */
8491
+ inputValuesOf(id) {
8492
+ return { ...this.registry.get(id)?.inputValues };
8493
+ }
8494
+ /** IndicatorController: the CURRENT declaration-prop overrides. */
8495
+ propValuesOf(id) {
8496
+ return { ...this.registry.get(id)?.propValues };
8174
8497
  }
8175
8498
  /** IndicatorController: re-run an indicator with merged declaration-prop overrides.
8176
8499
  * Same lifecycle as {@link applyInputs} — a prop change replays the whole script.
@@ -8184,6 +8507,7 @@ var Vela = (function (exports) {
8184
8507
  if (!record.hidden) this.setLoading(record, true);
8185
8508
  record.pendingCause = "inputs";
8186
8509
  record.session.update(record.inputValues, record.propValues);
8510
+ this.events.emit("indicator:inputs", { id });
8187
8511
  }
8188
8512
  /** IndicatorController: tear down an indicator and (if now empty) its pane. */
8189
8513
  /** Live handles of every indicator on the chart (script + native), insertion order. */
@@ -8371,8 +8695,8 @@ var Vela = (function (exports) {
8371
8695
  onModel: (model) => {
8372
8696
  const first2 = !record.announced;
8373
8697
  const cause = record.pendingCause ?? "history";
8698
+ if (!this.applyModel(id, model)) return;
8374
8699
  record.pendingCause = void 0;
8375
- this.applyModel(id, model);
8376
8700
  this.emitContextChanged(id);
8377
8701
  this.emitScriptRun(id, cause, first2);
8378
8702
  },
@@ -8665,10 +8989,16 @@ var Vela = (function (exports) {
8665
8989
  * Apply an emitted model. First emission mounts (and routes the pane); a pending
8666
8990
  * structural change (after an input edit) remounts idempotently; everything else
8667
8991
  * (live tick / viewport re-run) value-patches.
8992
+ *
8993
+ * Returns false when the model was DEFERRED — an output-free model arriving while
8994
+ * the record is still loading and the chart has no bars (see below); every other
8995
+ * outcome, including the hidden drop, returns true so the caller's event semantics
8996
+ * stay unchanged.
8668
8997
  */
8669
8998
  applyModel(id, model) {
8670
8999
  const record = this.registry.get(id);
8671
- if (!record || record.hidden) return;
9000
+ if (!record || record.hidden) return true;
9001
+ if (record.loading && this.bars.length === 0 && !_EngineOrchestrator.modelHasOutput(model)) return false;
8672
9002
  const handle = this.handles.get(id);
8673
9003
  if (!record.renderHandle) {
8674
9004
  const paneId2 = this.routePane(id, model, record.options ?? {});
@@ -8678,7 +9008,7 @@ var Vela = (function (exports) {
8678
9008
  record.renderHandle = this.renderer.mountIndicator(model);
8679
9009
  record.pendingStructural = false;
8680
9010
  this.announce(record, handle);
8681
- return;
9011
+ return true;
8682
9012
  }
8683
9013
  let paneId = record.model?.paneId ?? "price";
8684
9014
  const prevOwnScale = record.model?.ownScale === true;
@@ -8706,6 +9036,11 @@ var Vela = (function (exports) {
8706
9036
  }
8707
9037
  if (record.loading) this.setLoading(record, false);
8708
9038
  this.announce(record, handle);
9039
+ return true;
9040
+ }
9041
+ /** True when the model carries ANY executed output — series, drawings, bar colors, or trades. */
9042
+ static modelHasOutput(model) {
9043
+ return model.series.length > 0 || model.fills.length > 0 || model.backgrounds.length > 0 || model.priceLines.length > 0 || (model.lines?.length ?? 0) > 0 || (model.boxes?.length ?? 0) > 0 || (model.labels?.length ?? 0) > 0 || (model.polylines?.length ?? 0) > 0 || (model.linefills?.length ?? 0) > 0 || (model.tables?.length ?? 0) > 0 || (model.barColors?.length ?? 0) > 0 || (model.trades?.length ?? 0) > 0;
8709
9044
  }
8710
9045
  routePane(id, model, options) {
8711
9046
  if (options.pane === "new") return `pane-${id}`;
@@ -9408,6 +9743,7 @@ var Vela = (function (exports) {
9408
9743
  "4h": 144e5,
9409
9744
  "1d": 864e5,
9410
9745
  "1w": 6048e5,
9746
+ "1M": 2592e6,
9411
9747
  "1": 6e4,
9412
9748
  "5": 3e5,
9413
9749
  "15": 9e5,
@@ -9415,7 +9751,8 @@ var Vela = (function (exports) {
9415
9751
  "60": 36e5,
9416
9752
  "240": 144e5,
9417
9753
  D: 864e5,
9418
- W: 6048e5
9754
+ W: 6048e5,
9755
+ M: 2592e6
9419
9756
  };
9420
9757
  return map[timeframe] ?? 36e5;
9421
9758
  }
@@ -17222,6 +17559,14 @@ ${STATIC_DECLS}
17222
17559
  const conds = Array.isArray(when) ? when : [when];
17223
17560
  return conds.every((c) => c.anyOf ? c.anyOf.some((x) => x === values[c.key]) : values[c.key] === c.equals);
17224
17561
  }
17562
+ function inputDeltas(schema, values) {
17563
+ const out = {};
17564
+ for (const s of schema) {
17565
+ const v = values[s.key];
17566
+ if (v !== void 0 && JSON.stringify(v) !== JSON.stringify(s.defval)) out[s.key] = v;
17567
+ }
17568
+ return Object.keys(out).length > 0 ? out : void 0;
17569
+ }
17225
17570
 
17226
17571
  // src/ui/components/select/controller.ts
17227
17572
  function selectController(opts) {
@@ -24194,6 +24539,7 @@ void main() {
24194
24539
  var DOUBLE_TAP_SLOP = 30;
24195
24540
  var TIME_SCALE_K = 4e-3;
24196
24541
  var WHEEL_ZOOM_K = 4e-3;
24542
+ var WHEEL_PRICE_DRAG_PX = 0.25;
24197
24543
  function wheelZoomAnchor(coords, cursorX, rightEdge) {
24198
24544
  if (rightEdge) return { logical: coords.rightEdgeLogical, x: coords.width };
24199
24545
  return { logical: coords.xToLogical(cursorX), x: cursorX };
@@ -24324,7 +24670,7 @@ void main() {
24324
24670
  this.capture(e.pointerId);
24325
24671
  return;
24326
24672
  }
24327
- if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y)) {
24673
+ if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y, this.snapMode(e))) {
24328
24674
  this.region = "drawing";
24329
24675
  this.capture(e.pointerId);
24330
24676
  return;
@@ -24438,7 +24784,7 @@ void main() {
24438
24784
  const wasTouch = e.pointerType === "touch";
24439
24785
  const tapRelease = this.dragging && !this.moved && (!wasTouch || Math.hypot(x - this.startX, y - this.startY) <= TOUCH_TAP_SLOP);
24440
24786
  if (this.dragging && this.region === "drawing") {
24441
- this.deps.drawingsPointerUp?.(x, y);
24787
+ this.deps.drawingsPointerUp?.(x, y, this.snapMode(e));
24442
24788
  } else if (tapRelease && this.region === "data") {
24443
24789
  this.deps.onClick(x, y);
24444
24790
  } else if (this.dragging && this.region === "data") {
@@ -24459,7 +24805,7 @@ void main() {
24459
24805
  if (e.pointerType === "touch") this.touches.delete(e.pointerId);
24460
24806
  this.cancelLongPress();
24461
24807
  if (!this.dragging) return;
24462
- if (this.region === "drawing" && !Number.isNaN(this.cursorX)) this.deps.drawingsPointerUp?.(this.cursorX, this.cursorY);
24808
+ if (this.region === "drawing" && !Number.isNaN(this.cursorX)) this.deps.drawingsPointerUp?.(this.cursorX, this.cursorY, this.snapMode(e));
24463
24809
  if (this.region === "crosshair" || e.pointerType === "touch") this.deps.onPointerMove(null, null);
24464
24810
  this.endGesture(e);
24465
24811
  };
@@ -24475,6 +24821,12 @@ void main() {
24475
24821
  this.onWheel = (e) => {
24476
24822
  e.preventDefault();
24477
24823
  this.deps.drawingsClearTransient?.();
24824
+ const { x, y } = this.local(e);
24825
+ if (this.regionAt(x, y) === "price" && e.deltaY !== 0) {
24826
+ this.deps.beginPriceScale(x, y);
24827
+ this.deps.priceScaleBy(e.deltaY * WHEEL_PRICE_DRAG_PX);
24828
+ return;
24829
+ }
24478
24830
  const coords = this.deps.getCoords();
24479
24831
  const vp = coords.getViewport();
24480
24832
  const pan = wheelPanDelta(e.deltaX, e.deltaY, e.shiftKey);
@@ -24482,9 +24834,8 @@ void main() {
24482
24834
  this.deps.apply({ barSpacing: vp.barSpacing, rightOffset: wheelPanRightOffset(vp.rightOffset, pan, coords.pxPerBar()) });
24483
24835
  return;
24484
24836
  }
24485
- const cursorX = this.local(e).x;
24486
24837
  const rightEdge = this.rightEdgeZoom && !(e.ctrlKey || e.metaKey);
24487
- const anchor = wheelZoomAnchor(coords, cursorX, rightEdge);
24838
+ const anchor = wheelZoomAnchor(coords, x, rightEdge);
24488
24839
  const target = clampBarSpacing(vp.barSpacing * Math.exp(-e.deltaY * WHEEL_ZOOM_K));
24489
24840
  this.deps.zoomTo(target, anchor.logical, anchor.x);
24490
24841
  };
@@ -25966,6 +26317,15 @@ void main() {
25966
26317
  function fontSizePx(size3) {
25967
26318
  return size3 === "auto" ? 12 : namedFontSize(size3);
25968
26319
  }
26320
+ function lineCoversWindow(a, b, extend, lo, hi) {
26321
+ const minX = Math.min(a, b);
26322
+ const maxX = Math.max(a, b);
26323
+ if (a === b) return a >= lo && a <= hi;
26324
+ if (extend === "both") return true;
26325
+ if (extend === "left") return maxX >= lo;
26326
+ if (extend === "right") return minX <= hi;
26327
+ return maxX >= lo && minX <= hi;
26328
+ }
25969
26329
  var DrawingSceneRenderer = class {
25970
26330
  constructor(deps, set = EMPTY_DRAWING_SET) {
25971
26331
  this.deps = deps;
@@ -26019,7 +26379,9 @@ void main() {
26019
26379
  const lo = Math.min(from, to);
26020
26380
  const hi = Math.max(from, to);
26021
26381
  const visible = (a, b, extend) => {
26022
- if (extend !== "none") return true;
26382
+ if (extend === "both") return true;
26383
+ if (extend === "left") return Math.max(a, b) >= lo;
26384
+ if (extend === "right") return Math.min(a, b) <= hi;
26023
26385
  return Math.max(a, b) >= lo && Math.min(a, b) <= hi;
26024
26386
  };
26025
26387
  let min2 = Infinity;
@@ -26030,7 +26392,7 @@ void main() {
26030
26392
  };
26031
26393
  for (const ln of this.set.lines) {
26032
26394
  if (ln.invisible) continue;
26033
- if (!visible(this.logicalOf(ln.xloc, ln.x1), this.logicalOf(ln.xloc, ln.x2), ln.extend)) continue;
26395
+ if (!lineCoversWindow(this.logicalOf(ln.xloc, ln.x1), this.logicalOf(ln.xloc, ln.x2), ln.extend, lo, hi)) continue;
26034
26396
  fold(ln.y1);
26035
26397
  fold(ln.y2);
26036
26398
  }
@@ -28583,6 +28945,17 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28583
28945
  constructor() {
28584
28946
  /** The current `paintAll` call's interaction state, visible to the per-type painters. */
28585
28947
  this.targets = {};
28948
+ /** The chart's active series LOOK — style + resolved series colors — pushed by the
28949
+ * controller before each paint. The magnifier's inset mirrors both: candles/bars/line/
28950
+ * area restyle the paint (bar-transform styles like Heikin Ashi transform the fetched
28951
+ * bars; unknown/custom styles fall back to candles), and the colors default to the main
28952
+ * series' own so the inset reads as a finer copy of the chart. */
28953
+ this.seriesLook = {
28954
+ style: "candles",
28955
+ upColor: BULLISH,
28956
+ downColor: BEARISH,
28957
+ lineColor: BULLISH
28958
+ };
28586
28959
  }
28587
28960
  /** Paint every visible drawing, then selection handles for the targeted ones.
28588
28961
  * Each drawing is clipped to its own pane's rect (and skipped entirely while that pane
@@ -28627,6 +29000,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28627
29000
  ctx.globalAlpha = GHOST_ALPHA;
28628
29001
  if (ghost instanceof RegressionChannel || ghost instanceof FixedRangeVolumeProfile) {
28629
29002
  this.paintTimeSpanGhost(ctx, ghost, proj);
29003
+ } else if (ghost instanceof Magnifier) {
29004
+ this.paintMagnifierGhost(ctx, ghost, proj, theme);
28630
29005
  } else this.paintOne(ctx, ghost, proj, theme);
28631
29006
  ctx.globalAlpha = 1;
28632
29007
  }
@@ -28760,6 +29135,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28760
29135
  this.paintLabel(ctx, d, proj, theme);
28761
29136
  return;
28762
29137
  }
29138
+ if (d instanceof Magnifier) {
29139
+ this.paintMagnifier(ctx, d, proj, theme);
29140
+ return;
29141
+ }
28763
29142
  if (d instanceof PatternDrawing) {
28764
29143
  this.paintPattern(ctx, d, proj, theme);
28765
29144
  return;
@@ -29252,6 +29631,216 @@ ${overlayScrollbarCss(".vela-sd-pane")}
29252
29631
  ctx.textBaseline = "alphabetic";
29253
29632
  }
29254
29633
  }
29634
+ /** Placement preview for the magnifier: a dashed rectangle outline only — no backdrop and
29635
+ * no series read, so dragging the area open never kicks a fetch per cursor move. */
29636
+ paintMagnifierGhost(ctx, d, proj, theme) {
29637
+ const r = d.rect(proj);
29638
+ if (!r) return;
29639
+ ctx.save();
29640
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
29641
+ ctx.lineWidth = 1;
29642
+ ctx.setLineDash([4, 4]);
29643
+ ctx.strokeRect(Math.min(r.x1, r.x2), Math.min(r.y1, r.y2), Math.abs(r.x2 - r.x1), Math.abs(r.y2 - r.y1));
29644
+ ctx.restore();
29645
+ }
29646
+ /** Paint a magnifier: an opaque theme-background inset whose interior shows the chart's
29647
+ * market at a finer timeframe — candles at their true time/price positions, clipped to
29648
+ * the rectangle. Bars come through `Projector.seriesInRange` (cache read; `loading` and
29649
+ * `unavailable` states paint a short notice instead). The lower-timeframe candles shift
29650
+ * half a chart bar LEFT of their raw time pixel so each chart candle's visual cell —
29651
+ * centered on its open time — subdivides in place. */
29652
+ paintMagnifier(ctx, d, proj, theme) {
29653
+ const r = d.rect(proj);
29654
+ const a = d.anchors[0];
29655
+ const b = d.anchors[1];
29656
+ if (!r || !a || !b) return;
29657
+ const x0 = Math.min(r.x1, r.x2);
29658
+ const x1 = Math.max(r.x1, r.x2);
29659
+ const y0 = Math.min(r.y1, r.y2);
29660
+ const y1 = Math.max(r.y1, r.y2);
29661
+ const w = x1 - x0;
29662
+ const h = y1 - y0;
29663
+ ctx.save();
29664
+ ctx.globalAlpha = 1;
29665
+ ctx.fillStyle = theme.background;
29666
+ ctx.fillRect(x0, y0, w, h);
29667
+ ctx.restore();
29668
+ const from = Math.min(a.time, b.time);
29669
+ const to = Math.max(a.time, b.time);
29670
+ const chartBars = proj.barsBetween ? proj.barsBetween(from, to) : 0;
29671
+ const chartMs = chartBars > 0 ? (to - from) / chartBars : 0;
29672
+ const res = proj.seriesInRange && chartMs > 0 && w > 1 && h > 1 ? proj.seriesInRange(d.magnifier.timeframe, from, to + chartMs) : void 0;
29673
+ let seriesBars = res?.state === "ready" || res?.state === "loading" ? res.bars ?? [] : [];
29674
+ if (res && (res.state === "ready" || res.state === "loading") && seriesBars.length > 0) {
29675
+ const look = this.seriesLook;
29676
+ const transform = look.style !== "candles" ? barTransformFor(look.style) : null;
29677
+ if (transform) seriesBars = transform.full(seriesBars);
29678
+ const mode = look.style === "bars" ? "bars" : look.style === "line" || look.style === "baseline" ? "line" : look.style === "area" ? "area" : "candles";
29679
+ const halfPitch = (proj.xOf(from + chartMs) - proj.xOf(from)) / 2;
29680
+ ctx.save();
29681
+ ctx.beginPath();
29682
+ ctx.rect(x0, y0, w, h);
29683
+ ctx.clip();
29684
+ if (mode === "line" || mode === "area") {
29685
+ this.paintMagnifierLine(ctx, d, proj, seriesBars, res.barMs, halfPitch, y1, mode === "area", d.magnifier.upColor || look.lineColor);
29686
+ } else {
29687
+ this.paintMagnifierBars(ctx, d, proj, seriesBars, res.barMs, halfPitch, x0, x1, mode, d.magnifier.upColor || look.upColor, d.magnifier.downColor || look.downColor);
29688
+ }
29689
+ ctx.restore();
29690
+ } else if (res) {
29691
+ const notice = res.state === "loading" ? "Loading\u2026" : res.state === "ready" ? "No lower-timeframe data" : res.reason === "too-wide" ? "Area too wide for this timeframe" : res.reason === "none-lower" ? "No lower timeframe available" : res.reason === "not-lower" ? "Pick a timeframe below the chart" : "No data source";
29692
+ this.paintMagnifierNotice(ctx, notice, x0, y0, w, h, theme);
29693
+ }
29694
+ ctx.save();
29695
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
29696
+ ctx.lineWidth = d.style.lineWidth || 1;
29697
+ ctx.setLineDash(dashPattern(d.style.lineStyle, d.style.lineWidth || 1));
29698
+ ctx.strokeRect(x0, y0, w, h);
29699
+ ctx.restore();
29700
+ if (w > 44) {
29701
+ const label = magnifierTimeframeLabel(res?.state === "ready" || res?.state === "loading" ? res.timeframe : d.magnifier.timeframe);
29702
+ const chipH = 17;
29703
+ const gap = 4;
29704
+ const pane = proj.paneRect?.(d.paneId);
29705
+ const paneBottom = pane ? pane.top + pane.height : proj.height;
29706
+ const below = y1 + gap + chipH <= paneBottom;
29707
+ const chipY = below ? y1 + gap : y1 - gap - chipH;
29708
+ ctx.save();
29709
+ ctx.font = `10px ${theme.fontFamily}`;
29710
+ const tw = ctx.measureText(label).width;
29711
+ const caretW = 11;
29712
+ const chipW = tw + 12 + caretW;
29713
+ roundRect(ctx, x0, chipY, chipW, chipH, 3);
29714
+ ctx.fillStyle = theme.background;
29715
+ ctx.fill();
29716
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
29717
+ ctx.lineWidth = 1;
29718
+ ctx.setLineDash([]);
29719
+ ctx.stroke();
29720
+ ctx.fillStyle = theme.textColor;
29721
+ ctx.textAlign = "left";
29722
+ ctx.textBaseline = "middle";
29723
+ ctx.fillText(label, x0 + 6, chipY + chipH / 2 + 0.5);
29724
+ const cxr = x0 + 6 + tw + 5;
29725
+ const cyr = chipY + chipH / 2;
29726
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.7);
29727
+ ctx.lineWidth = 1.2;
29728
+ ctx.beginPath();
29729
+ ctx.moveTo(cxr, cyr - 1.5);
29730
+ ctx.lineTo(cxr + 2.5, cyr + 1.5);
29731
+ ctx.lineTo(cxr + 5, cyr - 1.5);
29732
+ ctx.stroke();
29733
+ ctx.restore();
29734
+ d.chipRect = { x: x0, y: chipY, w: chipW, h: chipH };
29735
+ } else {
29736
+ d.chipRect = null;
29737
+ }
29738
+ }
29739
+ /** The magnifier's candle/bar loop: each bar's cell spans its open→close time (shifted left
29740
+ * by half a chart bar). Candles: wick always, body once the cell is wide enough to carry
29741
+ * one. OHLC bars: the high–low spine with open/close ticks once the cell has the room. */
29742
+ paintMagnifierBars(ctx, d, proj, bars, barMs, halfPitch, x0, x1, mode, upColor, downColor) {
29743
+ ctx.setLineDash([]);
29744
+ ctx.lineWidth = 1;
29745
+ for (const bar of bars) {
29746
+ const cx0 = proj.xOf(bar.time) - halfPitch;
29747
+ const cx1 = proj.xOf(bar.time + barMs) - halfPitch;
29748
+ if (cx1 < x0 || cx0 > x1) continue;
29749
+ const yHigh = proj.yOf(bar.high, d.paneId);
29750
+ const yLow = proj.yOf(bar.low, d.paneId);
29751
+ const yOpen = proj.yOf(bar.open, d.paneId);
29752
+ const yClose = proj.yOf(bar.close, d.paneId);
29753
+ if (yHigh == null || yLow == null || yOpen == null || yClose == null) continue;
29754
+ const color = bar.close >= bar.open ? upColor : downColor;
29755
+ const cellW = cx1 - cx0;
29756
+ const cx = (cx0 + cx1) / 2;
29757
+ ctx.strokeStyle = color;
29758
+ ctx.beginPath();
29759
+ ctx.moveTo(cx, yHigh);
29760
+ ctx.lineTo(cx, yLow);
29761
+ ctx.stroke();
29762
+ if (cellW < 3) continue;
29763
+ if (mode === "bars") {
29764
+ const tick = Math.max(1, cellW * 0.35);
29765
+ ctx.beginPath();
29766
+ ctx.moveTo(cx - tick, yOpen);
29767
+ ctx.lineTo(cx, yOpen);
29768
+ ctx.moveTo(cx, yClose);
29769
+ ctx.lineTo(cx + tick, yClose);
29770
+ ctx.stroke();
29771
+ } else {
29772
+ const bw = Math.max(1, cellW * 0.7);
29773
+ ctx.fillStyle = color;
29774
+ ctx.fillRect(cx - bw / 2, Math.min(yOpen, yClose), bw, Math.max(1, Math.abs(yClose - yOpen)));
29775
+ }
29776
+ }
29777
+ }
29778
+ /** The magnifier's line/area rendering: a close polyline through each cell's center (same
29779
+ * half-chart-bar shift as the candles), with an optional translucent fill down to the
29780
+ * rectangle's bottom edge for the area style. Colored like the chart's own line series. */
29781
+ paintMagnifierLine(ctx, d, proj, bars, barMs, halfPitch, yBottom, area, color) {
29782
+ const pts = [];
29783
+ for (const bar of bars) {
29784
+ const y = proj.yOf(bar.close, d.paneId);
29785
+ if (y == null) continue;
29786
+ pts.push([proj.xOf(bar.time + barMs / 2) - halfPitch, y]);
29787
+ }
29788
+ if (pts.length < 2) return;
29789
+ if (area) {
29790
+ ctx.beginPath();
29791
+ ctx.moveTo(pts[0][0], yBottom);
29792
+ for (const [px, py] of pts) ctx.lineTo(px, py);
29793
+ ctx.lineTo(pts[pts.length - 1][0], yBottom);
29794
+ ctx.closePath();
29795
+ ctx.fillStyle = withAlpha(color, 0.15);
29796
+ ctx.fill();
29797
+ }
29798
+ ctx.setLineDash([]);
29799
+ ctx.lineWidth = 1.5;
29800
+ ctx.strokeStyle = color;
29801
+ ctx.beginPath();
29802
+ ctx.moveTo(pts[0][0], pts[0][1]);
29803
+ for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
29804
+ ctx.stroke();
29805
+ }
29806
+ /** Centered muted notice inside the magnifier rect (loading / unavailable states). */
29807
+ paintMagnifierNotice(ctx, text, x0, y0, w, h, theme) {
29808
+ if (w < 60 || h < 20) return;
29809
+ ctx.save();
29810
+ ctx.beginPath();
29811
+ ctx.rect(x0, y0, w, h);
29812
+ ctx.clip();
29813
+ ctx.font = `11px ${theme.fontFamily}`;
29814
+ ctx.fillStyle = withAlpha(theme.textColor, 0.55);
29815
+ ctx.textAlign = "center";
29816
+ ctx.textBaseline = "middle";
29817
+ ctx.fillText(text, x0 + w / 2, y0 + h / 2);
29818
+ ctx.restore();
29819
+ }
29820
+ /** A bottom-center pill prompting the armed tool's placement gesture (e.g. the magnifier's
29821
+ * "drag an area"). Painted by the drawings layer while the tool is armed and no placement
29822
+ * is in progress; chart-background fill so it reads as chrome over any content. */
29823
+ paintPlacementHint(ctx, text, theme, width, height) {
29824
+ ctx.save();
29825
+ ctx.font = `11px ${theme.fontFamily}`;
29826
+ const tw = ctx.measureText(text).width;
29827
+ const pillW = tw + 24;
29828
+ const pillH = 24;
29829
+ const x = (width - pillW) / 2;
29830
+ const y = height - pillH - 14;
29831
+ roundRect(ctx, x, y, pillW, pillH, pillH / 2);
29832
+ ctx.fillStyle = theme.background;
29833
+ ctx.fill();
29834
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
29835
+ ctx.lineWidth = 1;
29836
+ ctx.setLineDash([]);
29837
+ ctx.stroke();
29838
+ ctx.fillStyle = theme.textColor;
29839
+ ctx.textAlign = "center";
29840
+ ctx.textBaseline = "middle";
29841
+ ctx.fillText(text, width / 2, y + pillH / 2 + 0.5);
29842
+ ctx.restore();
29843
+ }
29255
29844
  /** Paint a fixed-range volume profile: horizontal histogram rows (up/down split) anchored to
29256
29845
  * the left or right of the time span, optional VAH / VAL / POC levels across the range, and
29257
29846
  * optional developing POC / VA polylines. Recomputes from the two anchors on every paint. */
@@ -29970,6 +30559,22 @@ ${overlayScrollbarCss(".vela-sd-pane")}
29970
30559
  this.snapAt = changed ? { point: snapped, paneId } : null;
29971
30560
  return snapped;
29972
30561
  }
30562
+ /**
30563
+ * Resolve a cursor pixel through the magnet and return the snapped pixel — the same
30564
+ * conversion drawing placement uses. Updates the snap-ring marker. The measure
30565
+ * ruler goes through this so its endpoints follow weak/strong/Ctrl magnet too.
30566
+ */
30567
+ snapCursor(x, y, mode) {
30568
+ const proj = this.deps.projector();
30569
+ const paneId = proj.paneIdAtY(y) ?? "price";
30570
+ const point = this.resolve(x, y, paneId, mode);
30571
+ const sy = proj.yOf(point.price, paneId);
30572
+ return { x: proj.xOf(point.time), y: sy ?? y };
30573
+ }
30574
+ /** Drop the snap-ring marker (a transient mode ended without going through `up`). */
30575
+ clearSnapMarker() {
30576
+ this.snapAt = null;
30577
+ }
29973
30578
  /** Resolve a pixel to a data point with the segment angle locked to 45° steps around
29974
30579
  * `pivot` (Shift held on a line tool). Works in PIXEL space — the user reasons about
29975
30580
  * the angle they see, not about time/price units. The magnet is bypassed: snapping
@@ -30651,8 +31256,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30651
31256
  if (!existing) document.head.appendChild(s);
30652
31257
  }
30653
31258
  var DrawingSettingsPopup = class {
30654
- constructor(host, theme) {
31259
+ constructor(host, theme, chartBarMs = () => 0) {
30655
31260
  this.host = host;
31261
+ this.chartBarMs = chartBarMs;
30656
31262
  this.el = null;
30657
31263
  this.tipEl = null;
30658
31264
  // floating hover-label (above/below the toolbar)
@@ -30677,6 +31283,14 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30677
31283
  this.theme = theme;
30678
31284
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
30679
31285
  }
31286
+ /** The magnifier timeframe choices strictly below the chart's own bar duration
31287
+ * (`auto` rides along while at least one concrete lower step exists). */
31288
+ lowerTimeframeOptions() {
31289
+ const chartMs = this.chartBarMs();
31290
+ if (!(chartMs > 0)) return [...MAGNIFIER_TIMEFRAME_OPTIONS];
31291
+ const lower = MAGNIFIER_TIMEFRAME_OPTIONS.filter((o) => o.ms > 0 && o.ms < chartMs);
31292
+ return lower.length > 0 ? [MAGNIFIER_TIMEFRAME_OPTIONS[0], ...lower] : [];
31293
+ }
30680
31294
  setTheme(theme) {
30681
31295
  this.theme = theme;
30682
31296
  this.settingsDialog.setTheme(theme);
@@ -30719,7 +31333,20 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30719
31333
  const sz = drawing.size ?? "normal";
30720
31334
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
30721
31335
  }
30722
- if (paths.has("style.lineColor")) bar.appendChild(this.colorButton("Line color", BRUSH_ICON, drawing.style.lineColor || DEFAULT_DRAWING_COLOR, (v) => actions.patch({ "style.lineColor": v })));
31336
+ if (paths.has("magnifier.timeframe") && drawing instanceof Magnifier) {
31337
+ const options = this.lowerTimeframeOptions();
31338
+ if (options.length > 0) {
31339
+ bar.appendChild(
31340
+ this.dropdown("Lower timeframe", options.map((o) => o.value), drawing.magnifier.timeframe, () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
31341
+ label: (v) => magnifierTimeframeLabel(String(v)),
31342
+ labelInTrigger: true
31343
+ })
31344
+ );
31345
+ }
31346
+ bar.appendChild(this.colorButton("Up candles", BUCKET_ICON, drawing.magnifier.upColor || t.upColor, (v) => actions.patch({ "magnifier.upColor": v })));
31347
+ bar.appendChild(this.colorButton("Down candles", BUCKET_ICON, drawing.magnifier.downColor || t.downColor, (v) => actions.patch({ "magnifier.downColor": v })));
31348
+ }
31349
+ 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 })));
30723
31350
  if (paths.has("style.lineWidth")) {
30724
31351
  const wf = schema.fields.find((f) => f.path === "style.lineWidth");
30725
31352
  if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
@@ -31041,6 +31668,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31041
31668
  this.colorPop = null;
31042
31669
  this.colorOwner = null;
31043
31670
  }
31671
+ opts.onClose?.();
31044
31672
  }
31045
31673
  });
31046
31674
  const el = pop.el;
@@ -31059,6 +31687,49 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31059
31687
  pop.show();
31060
31688
  return pop;
31061
31689
  }
31690
+ /**
31691
+ * A standalone timeframe menu for the magnifier's ON-CHART chip. The chip lives on
31692
+ * canvas, so a transient invisible anchor is dropped at its pixel rect for the popover
31693
+ * to position against, and removed again when the menu closes. Independent of the
31694
+ * quick toolbar — the chip works without selecting the drawing first.
31695
+ */
31696
+ openMagnifierTimeframeMenu(rect, current, onPick) {
31697
+ ensureStyles3();
31698
+ closeOpenPopovers();
31699
+ const options = this.lowerTimeframeOptions();
31700
+ const anchor = document.createElement("div");
31701
+ anchor.style.cssText = `position:absolute;left:${rect.x}px;top:${rect.y}px;width:${rect.w}px;height:${rect.h}px;pointer-events:none;`;
31702
+ this.host.appendChild(anchor);
31703
+ this.menuPop = this.hostFloat(anchor, {
31704
+ zIndex: 26,
31705
+ padding: "4px",
31706
+ onClose: () => anchor.remove(),
31707
+ fill: (menu, pop) => {
31708
+ if (options.length === 0) {
31709
+ const note = document.createElement("div");
31710
+ note.style.cssText = "padding:6px 10px;opacity:0.65;white-space:nowrap;";
31711
+ note.textContent = "No lower timeframe available";
31712
+ menu.appendChild(note);
31713
+ return;
31714
+ }
31715
+ for (const o of options) {
31716
+ const item = document.createElement("button");
31717
+ item.type = "button";
31718
+ item.className = "vela-dpop-item";
31719
+ item.dataset.active = o.value === current ? "1" : "0";
31720
+ item.style.cssText = "display:flex;align-items:center;min-width:88px;padding:5px 10px;border:none;border-radius:5px;color:inherit;cursor:pointer;text-align:left;font:inherit;font-variant-numeric:tabular-nums;";
31721
+ item.textContent = o.label;
31722
+ item.addEventListener("click", (e) => {
31723
+ e.stopPropagation();
31724
+ pop.hide();
31725
+ onPick(o.value);
31726
+ });
31727
+ menu.appendChild(item);
31728
+ }
31729
+ }
31730
+ });
31731
+ this.menuOwner = anchor;
31732
+ }
31062
31733
  /** A floating list of one-shot actions (icon + label rows) opened by the kebab. */
31063
31734
  openActionMenu(anchor, rows) {
31064
31735
  this.menuPop = this.hostFloat(anchor, {
@@ -31169,10 +31840,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31169
31840
  let cur = current;
31170
31841
  const paint = (v) => {
31171
31842
  b.replaceChildren();
31172
- const ic = document.createElement("span");
31173
- ic.style.cssText = "display:flex;";
31174
- ic.innerHTML = sized(render(v));
31175
- b.appendChild(ic);
31843
+ const glyph = render(v);
31844
+ if (glyph) {
31845
+ const ic = document.createElement("span");
31846
+ ic.style.cssText = "display:flex;";
31847
+ ic.innerHTML = sized(glyph);
31848
+ b.appendChild(ic);
31849
+ }
31176
31850
  if (opts.label && opts.labelInTrigger) {
31177
31851
  const tx = document.createElement("span");
31178
31852
  tx.textContent = opts.label(v);
@@ -31214,10 +31888,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31214
31888
  item.className = "vela-dpop-item";
31215
31889
  item.dataset.active = active ? "1" : "0";
31216
31890
  item.style.cssText = `display:flex;align-items:center;gap:8px;${label ? "min-width:118px;" : ""}padding:5px 8px;border:none;border-radius:5px;color:inherit;cursor:pointer;text-align:left;font:inherit;`;
31217
- const ic = document.createElement("span");
31218
- ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
31219
- ic.innerHTML = sized(render(v), 18);
31220
- item.appendChild(ic);
31891
+ const glyph = render(v);
31892
+ if (glyph) {
31893
+ const ic = document.createElement("span");
31894
+ ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
31895
+ ic.innerHTML = sized(glyph, 18);
31896
+ item.appendChild(ic);
31897
+ }
31221
31898
  if (label) {
31222
31899
  const tx = document.createElement("span");
31223
31900
  tx.textContent = label(v);
@@ -32007,30 +32684,39 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32007
32684
  isFinished() {
32008
32685
  return this.state === "finished";
32009
32686
  }
32010
- /** A press: begin the measurement, or finish it on the second click. */
32011
- down(x, y) {
32687
+ /** A press: begin the measurement, or finish it on the second click.
32688
+ * `x,y` are the raw cursor (drag-slop vs click-move-click). `gx,gy` are the
32689
+ * graphic endpoints — magnet-snapped when the magnet is on, else the same as `x,y`. */
32690
+ down(x, y, gx = x, gy = y) {
32012
32691
  if (this.state === "measuring") {
32013
- this.end = { x, y };
32692
+ this.end = { x: gx, y: gy };
32014
32693
  this.state = "finished";
32015
32694
  return;
32016
32695
  }
32017
- this.start = { x, y };
32018
- this.end = { x, y };
32696
+ this.start = { x: gx, y: gy };
32697
+ this.end = { x: gx, y: gy };
32019
32698
  this.pressX = x;
32020
32699
  this.pressY = y;
32021
32700
  this.state = "measuring";
32022
32701
  }
32702
+ /** Size the in-progress ruler. `x,y` are the graphic (magnet-snapped) cursor. */
32023
32703
  move(x, y) {
32024
32704
  if (this.state === "measuring") this.end = { x, y };
32025
32705
  }
32026
32706
  /** A release: finish if the press was actually dragged (press-drag-release), else wait
32027
- * for the second click (click-move-click). */
32028
- up(x, y) {
32707
+ * for the second click (click-move-click). `x,y` are the raw cursor (slop); `gx,gy`
32708
+ * are the graphic end (magnet-snapped when the magnet is on). */
32709
+ up(x, y, gx = x, gy = y) {
32029
32710
  if (this.state === "measuring" && Math.hypot(x - this.pressX, y - this.pressY) > DRAG_SLOP3) {
32030
- this.end = { x, y };
32711
+ this.end = { x: gx, y: gy };
32031
32712
  this.state = "finished";
32032
32713
  }
32033
32714
  }
32715
+ /** Current graphic endpoints in media pixels, or null when idle. */
32716
+ points() {
32717
+ if (!this.start || !this.end) return null;
32718
+ return { start: this.start, end: this.end };
32719
+ }
32034
32720
  clear() {
32035
32721
  this.state = "idle";
32036
32722
  this.start = null;
@@ -32192,6 +32878,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32192
32878
  this.intentCb = null;
32193
32879
  /** Another chart's in-progress placement, mirrored here as a ghost (drawings sync). */
32194
32880
  this.externalGhost = null;
32881
+ /** Core-pushed series gateway (finer-timeframe bars for data-driven drawings). */
32882
+ this.seriesGw = null;
32883
+ this.seriesGwUnsub = null;
32195
32884
  /** Last draft fingerprint reported upstream — gates the per-render emission to actual changes. */
32196
32885
  this.lastDraftKey = null;
32197
32886
  this.measure = new MeasureOverlay();
@@ -32219,7 +32908,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32219
32908
  this.textEditor = null;
32220
32909
  this.painter = new DrawingPainter();
32221
32910
  this.ctx = canvas.getContext("2d");
32222
- this.popup = new DrawingSettingsPopup(overlayHost, deps.theme());
32911
+ this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
32223
32912
  this.toolbar = new DrawingToolbar(
32224
32913
  toolbarHost,
32225
32914
  deps.theme(),
@@ -32277,6 +32966,22 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32277
32966
  const shown = this.toolbarVisible && !this.mobileLayout;
32278
32967
  this.deps.setToolbarGutter(shown ? this.toolbarCollapsed ? TOOLBAR_COLLAPSED_WIDTH : TOOLBAR_WIDTH : 0);
32279
32968
  }
32969
+ /** Core push: the series gateway data-driven drawings read finer-timeframe bars
32970
+ * through (surfaced to them as `Projector.seriesInRange`). A landed background
32971
+ * fetch repaints both this layer and the interleave slices under the series. */
32972
+ setSeriesGateway(gateway) {
32973
+ this.seriesGwUnsub?.();
32974
+ this.seriesGw = gateway;
32975
+ this.seriesGwUnsub = gateway.onUpdate(() => {
32976
+ this.invalidateSlices();
32977
+ this.render();
32978
+ this.deps.requestDataPaint();
32979
+ });
32980
+ }
32981
+ /** The pushed series gateway, or null before the core provides one. */
32982
+ get seriesGateway() {
32983
+ return this.seriesGw;
32984
+ }
32280
32985
  /** Core push: mirror (or clear) another chart's in-progress placement as a ghost. */
32281
32986
  setExternalGhost(doc) {
32282
32987
  this.externalGhost = doc ? deserializeDrawing(doc) : null;
@@ -32394,8 +33099,34 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32394
33099
  /** Should the drawing layer win this press (vs pan)? */
32395
33100
  claim(x, y) {
32396
33101
  if (this.measureMode || this.eraserMode) return true;
33102
+ if (this.magnifierChipAt(x, y)) return true;
32397
33103
  return this.interaction.claim(x, y);
32398
33104
  }
33105
+ /** The topmost visible (unlocked) magnifier whose timeframe chip contains (x, y) —
33106
+ * the chip's rect is what the painter measured last frame. */
33107
+ magnifierChipAt(x, y) {
33108
+ for (let i = this.drawings.length - 1; i >= 0; i -= 1) {
33109
+ const d = this.drawings[i];
33110
+ if (!(d instanceof Magnifier) || !d.visible || d.locked) continue;
33111
+ const r = d.chipRect;
33112
+ if (r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return d;
33113
+ }
33114
+ return null;
33115
+ }
33116
+ /** Open the on-chart chip's timeframe menu; the pick patches the drawing like a
33117
+ * settings-popup edit (same intent, same undo step). */
33118
+ openMagnifierChipMenu(drawing) {
33119
+ const rect = drawing.chipRect;
33120
+ if (!rect) return;
33121
+ const id = drawing.id;
33122
+ this.popup.openMagnifierTimeframeMenu(rect, drawing.magnifier.timeframe, (value) => {
33123
+ const d = this.drawings.find((x) => x.id === id);
33124
+ if (!(d instanceof Magnifier)) return;
33125
+ d.applySettings({ "magnifier.timeframe": value });
33126
+ this.render();
33127
+ this.emit({ kind: "edit", doc: d.serialize() });
33128
+ });
33129
+ }
32399
33130
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
32400
33131
  * Shared by the eraser (click + drag) and the middle-click shortcut. */
32401
33132
  deleteAt(x, y) {
@@ -32407,11 +33138,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32407
33138
  }
32408
33139
  /** Shift+press on the empty plot: arm the measure ruler AND start it at (x, y) in one
32409
33140
  * gesture — the equivalent of clicking the toolbar's Measure button, then pressing.
32410
- * Returns false when a mode/tool is already active (the normal press path owns it). */
32411
- beginMeasureAt(x, y) {
33141
+ * `snap` is the effective magnet (sticky mode, or Ctrl/Cmd-forced strong). Returns
33142
+ * false when a mode/tool is already active (the normal press path owns it). */
33143
+ beginMeasureAt(x, y, snap = "off") {
32412
33144
  if (this.measureMode || this.eraserMode || this.activeTool != null) return false;
32413
33145
  this.withModeIntent(() => this.toggleMeasure());
32414
- this.measure.down(x, y);
33146
+ const g = this.interaction.snapCursor(x, y, snap);
33147
+ this.measure.down(x, y, g.x, g.y);
32415
33148
  this.render();
32416
33149
  return true;
32417
33150
  }
@@ -32433,11 +33166,19 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32433
33166
  return;
32434
33167
  }
32435
33168
  if (this.measureMode) {
32436
- this.measure.down(x, y);
33169
+ const g = this.interaction.snapCursor(x, y, snap);
33170
+ this.measure.down(x, y, g.x, g.y);
32437
33171
  if (this.measure.isFinished()) this.withModeIntent(() => this.exitMeasure(false));
32438
33172
  this.render();
32439
33173
  return;
32440
33174
  }
33175
+ if (this.activeTool == null) {
33176
+ const chipOwner = this.magnifierChipAt(x, y);
33177
+ if (chipOwner) {
33178
+ this.openMagnifierChipMenu(chipOwner);
33179
+ return;
33180
+ }
33181
+ }
32441
33182
  this.interaction.down(x, y, snap, shift3);
32442
33183
  }
32443
33184
  pointerMove(x, y, snap = "off", shift3 = false) {
@@ -32446,7 +33187,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32446
33187
  return;
32447
33188
  }
32448
33189
  if (this.measureMode) {
32449
- this.measure.move(x, y);
33190
+ const g = this.interaction.snapCursor(x, y, snap);
33191
+ this.measure.move(g.x, g.y);
32450
33192
  this.render();
32451
33193
  return;
32452
33194
  }
@@ -32464,13 +33206,14 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32464
33206
  this.render();
32465
33207
  }
32466
33208
  }
32467
- pointerUp(x, y) {
33209
+ pointerUp(x, y, snap = "off") {
32468
33210
  if (this.eraserMode) {
32469
33211
  this.erasing = false;
32470
33212
  return;
32471
33213
  }
32472
33214
  if (this.measureMode) {
32473
- this.measure.up(x, y);
33215
+ const g = this.interaction.snapCursor(x, y, snap);
33216
+ this.measure.up(x, y, g.x, g.y);
32474
33217
  if (this.measure.isFinished()) this.withModeIntent(() => this.exitMeasure(false));
32475
33218
  this.render();
32476
33219
  return;
@@ -32515,6 +33258,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32515
33258
  /** Leave ruler mode. `clearGraphic` keeps a just-finished measurement on screen (false). */
32516
33259
  exitMeasure(clearGraphic = true) {
32517
33260
  this.measureMode = false;
33261
+ this.interaction.clearSnapMarker();
32518
33262
  if (clearGraphic) this.measure.clear();
32519
33263
  this.toolbar.setMeasureActive(false);
32520
33264
  this.render();
@@ -32653,17 +33397,31 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32653
33397
  /** Cursor hint while hovering — `'pointer'` over a drawing/handle, else null. */
32654
33398
  cursorAt(x, y) {
32655
33399
  if (this.eraserMode) return "pointer";
33400
+ if (this.activeTool == null && this.magnifierChipAt(x, y)) return "pointer";
32656
33401
  return this.interaction.cursorAt(x, y);
32657
33402
  }
32658
- /** Right-click while placing: cancel the in-progress drawing and revert to the
32659
- * pointer the gesture is an explicit escape, so it disarms even in
32660
- * stay-in-drawing-mode (where Escape would leave the tool armed). Returns whether
32661
- * the press was consumed; false lets the host's context menu open normally. */
33403
+ /** Right-click: an explicit escape back to the pointer. Cancels an in-progress
33404
+ * placement or measurement, and also plain-disarms an armed-but-idle drawing
33405
+ * tool or the eraser — so a right-click ALWAYS reverts to the pointer, even in
33406
+ * stay-in-drawing-mode (where Escape would leave a drawing tool armed).
33407
+ * Persistent toggles (magnet, stay-mode, favorites) are untouched. Returns
33408
+ * whether the press was consumed; false lets the host's context menu open
33409
+ * normally. */
32662
33410
  cancelPlacement() {
32663
- if (!this.interaction.isPlacing()) return false;
32664
- this.interaction.cancel();
32665
- if (this.activeTool != null) this.emit({ kind: "arm", type: null });
32666
- return true;
33411
+ if (this.measureMode || this.eraserMode) {
33412
+ this.withModeIntent(() => this.measureMode ? this.exitMeasure() : this.exitEraser());
33413
+ return true;
33414
+ }
33415
+ if (this.interaction.isPlacing()) {
33416
+ this.interaction.cancel();
33417
+ if (this.activeTool != null) this.emit({ kind: "arm", type: null });
33418
+ return true;
33419
+ }
33420
+ if (this.activeTool != null) {
33421
+ this.emit({ kind: "arm", type: null });
33422
+ return true;
33423
+ }
33424
+ return false;
32667
33425
  }
32668
33426
  /** Double-click over a drawing → suppress the chart's view reset (single-click already
32669
33427
  * opens settings). Returns true only when a drawing is under the cursor. */
@@ -32689,6 +33447,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32689
33447
  return true;
32690
33448
  }
32691
33449
  if (this.interaction.cancel()) return true;
33450
+ if (this.measureMode) {
33451
+ this.withModeIntent(() => this.exitMeasure());
33452
+ return true;
33453
+ }
32692
33454
  if (this.selectedIds.size) {
32693
33455
  this.clearSelection();
32694
33456
  return true;
@@ -32814,6 +33576,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32814
33576
  if (!sctx) continue;
32815
33577
  sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32816
33578
  sctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
33579
+ this.painter.seriesLook = this.deps.seriesLook();
32817
33580
  this.painter.paintAll(sctx, drawings, proj, theme, EMPTY_TARGETS);
32818
33581
  const slices = out.get(paneId) ?? [];
32819
33582
  slices.push({ beforeZ, canvas });
@@ -32841,6 +33604,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32841
33604
  dragged: this.interaction.activeDragId(),
32842
33605
  mutedLabel: edited instanceof TextLabel ? edited.id : null
32843
33606
  };
33607
+ this.painter.seriesLook = this.deps.seriesLook();
32844
33608
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
32845
33609
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
32846
33610
  this.layoutTextEditor();
@@ -32848,6 +33612,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32848
33612
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
32849
33613
  if (this.externalGhost) this.painter.paintGhost(ctx, this.externalGhost, proj, this.deps.theme());
32850
33614
  this.emitDraft(ghost);
33615
+ if (this.activeTool && !ghost) {
33616
+ const hint = getDrawingType(this.activeTool)?.placementHint;
33617
+ if (hint) this.painter.paintPlacementHint(ctx, hint, this.deps.theme(), proj.width, proj.height);
33618
+ }
32851
33619
  const markers = this.interaction.placingMarkers(proj);
32852
33620
  if (markers) this.painter.paintHandles(ctx, markers);
32853
33621
  const m = this.interaction.snapMarker();
@@ -32859,6 +33627,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32859
33627
  this.closeTextEditor();
32860
33628
  this.popup.destroy();
32861
33629
  this.toolbar.destroy();
33630
+ this.seriesGwUnsub?.();
33631
+ this.seriesGwUnsub = null;
33632
+ this.seriesGw = null;
32862
33633
  this.intentCb = null;
32863
33634
  this.drawings = [];
32864
33635
  }
@@ -33256,7 +34027,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
33256
34027
  }
33257
34028
 
33258
34029
  // src/renderers/native/drawings/Projector.ts
33259
- function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
34030
+ function createProjector(coords, paneOf, paneIdAtY, barsInRange, seriesInRange) {
33260
34031
  return {
33261
34032
  xOf: (time) => coords.timeToX(time),
33262
34033
  yOf: (price, paneId) => {
@@ -33277,6 +34048,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
33277
34048
  },
33278
34049
  barsBetween: (t1, t2) => Math.abs(coords.timeToLogical(t2) - coords.timeToLogical(t1)),
33279
34050
  barsInRange: barsInRange ? (from, to) => barsInRange(from, to) : void 0,
34051
+ seriesInRange,
33280
34052
  width: coords.width,
33281
34053
  height: coords.height
33282
34054
  };
@@ -35247,13 +36019,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35247
36019
  resetView: () => this.resetView(),
35248
36020
  // User drawings claim a gesture before pan when armed / over a drawing.
35249
36021
  drawingsClaim: (x, y) => this.userDrawings?.claim(x, y) ?? false,
35250
- drawingsMeasureStart: (x, y) => this.userDrawings?.beginMeasureAt(x, y) ?? false,
36022
+ drawingsMeasureStart: (x, y, snap) => this.userDrawings?.beginMeasureAt(x, y, snap) ?? false,
35251
36023
  drawingsDeleteAt: (x, y) => this.userDrawings?.deleteAt(x, y) ?? false,
35252
36024
  drawingsCancelPlacement: () => this.userDrawings?.cancelPlacement() ?? false,
35253
36025
  drawingsSnapMode: () => this.snapMode,
35254
36026
  drawingsPointerDown: (x, y, snap, shift3) => this.userDrawings?.pointerDown(x, y, snap, shift3),
35255
36027
  drawingsPointerMove: (x, y, snap, shift3) => this.userDrawings?.pointerMove(x, y, snap, shift3),
35256
- drawingsPointerUp: (x, y) => this.userDrawings?.pointerUp(x, y),
36028
+ drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
35257
36029
  drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
35258
36030
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
35259
36031
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
@@ -35280,6 +36052,23 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35280
36052
  seriesBoundaries: (paneId) => this.scene.seriesBoundaries(paneId),
35281
36053
  priceZ: (paneId) => paneId === PRICE_PANE_ID ? this.scene.candleZ : null,
35282
36054
  requestDataPaint: () => this.scheduler.invalidate(3 /* Light */),
36055
+ // The look the price series ACTUALLY paints with: candle colors resolved through
36056
+ // the per-style override, line/area colors through their configured styles — so
36057
+ // series-mirroring content (the magnifier inset) matches the chart exactly.
36058
+ seriesLook: () => {
36059
+ const st = this.scene.style;
36060
+ const paint = effectiveCandlePaint(st.candle, this.scene.candleOverride, this.theme.upColor, this.theme.downColor);
36061
+ const barsUp = st.bars.upColor ?? this.theme.upColor;
36062
+ const barsDown = st.bars.downColor ?? this.theme.downColor;
36063
+ const style = this.scene.priceStyle;
36064
+ return {
36065
+ style,
36066
+ upColor: style === "bars" ? barsUp : paint.up,
36067
+ downColor: style === "bars" ? barsDown : paint.down,
36068
+ lineColor: style === "area" ? st.area.lineColor ?? this.theme.upColor : st.line.color ?? this.theme.upColor
36069
+ };
36070
+ },
36071
+ chartBarMs: () => this.coords.barInterval,
35283
36072
  snap: (pt2, paneId, mode, cursorPx) => this.snapToCandle(pt2, paneId, mode, cursorPx),
35284
36073
  setSnapMode: (mode) => this.setSnapMode(mode),
35285
36074
  setToolbarGutter: (px) => this.setToolbarGutter(px)
@@ -35568,7 +36357,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35568
36357
  }
35569
36358
  const skipFit = opts?.preserveView === true && this.didInitialFit;
35570
36359
  if (this.coords.width > 0 && !skipFit) {
35571
- this.fitContent();
36360
+ if (this.didInitialFit) this.reframeKeepZoom();
36361
+ else this.fitContent();
35572
36362
  this.didInitialFit = true;
35573
36363
  }
35574
36364
  if (!this.introPlayed && this.bars.length > 0) {
@@ -36216,6 +37006,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36216
37006
  this.scaleDragHeight = res.height;
36217
37007
  this.scaleDragStart = { ...res.holder.scale };
36218
37008
  res.holder.manualScale = { ...res.holder.scale };
37009
+ this.axisScaleButtons?.reposition();
36219
37010
  this.scheduler.invalidate(4 /* Full */);
36220
37011
  }
36221
37012
  /** Rescale the grabbed scale around its center by the total drag (down ⇒ zoom out). */
@@ -36247,6 +37038,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36247
37038
  const res = this.resolveScaleHolder(x, y);
36248
37039
  if (!res) return;
36249
37040
  res.holder.manualScale = null;
37041
+ this.axisScaleButtons?.reposition();
36250
37042
  this.scheduler.invalidate(4 /* Full */);
36251
37043
  }
36252
37044
  /**
@@ -36670,7 +37462,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36670
37462
  return p ? { scale: p.scale, bounds: p.bounds, collapsed: p.collapsed } : null;
36671
37463
  },
36672
37464
  (y) => this.paneNodeAtY(y)?.id ?? null,
36673
- (from, to) => this.barsInTimeRange(from, to)
37465
+ (from, to) => this.barsInTimeRange(from, to),
37466
+ this.userDrawings?.seriesGateway ? (tf, from, to) => this.userDrawings.seriesGateway.seriesInRange(tf, from, to) : void 0
36674
37467
  );
36675
37468
  }
36676
37469
  /** OHLC bars whose open-time falls within `[from, to]` (inclusive) — the data a regression
@@ -36884,6 +37677,20 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36884
37677
  this.coords.setViewport(v);
36885
37678
  this.targetBarSpacing = v.barSpacing;
36886
37679
  }
37680
+ /** Re-frame after a series replacement (a symbol/timeframe switch): keep the user's
37681
+ * zoom (bar spacing), re-anchor the newest bars at the default right offset.
37682
+ * `clampViewport`'s fit-all-bars floor deliberately does NOT apply — a progressive
37683
+ * head may still be backfilling toward the previous depth, and raising the spacing
37684
+ * to its temporary bar count would lose the zoom this exists to keep. */
37685
+ reframeKeepZoom() {
37686
+ this.animator?.stop();
37687
+ this.panVelocity = 0;
37688
+ for (const pane of this.scene.panes.values()) pane.manualScale = null;
37689
+ for (const sl of this.scene.indicatorScales.values()) sl.manualScale = null;
37690
+ const v = { barSpacing: clampBarSpacing(this.coords.getViewport().barSpacing), rightOffset: defaultViewport().rightOffset };
37691
+ this.coords.setViewport(v);
37692
+ this.targetBarSpacing = v.barSpacing;
37693
+ }
36887
37694
  paneBoundsFor(paneId) {
36888
37695
  const p = this.scene.panes.get(paneId);
36889
37696
  return { top: p?.bounds.top ?? 0, height: p?.bounds.height ?? 0, rightAxis: this.rightAxisW };
@@ -39313,6 +40120,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
39313
40120
  exports.getDrawingType = getDrawingType;
39314
40121
  exports.getNativeIndicator = getNativeIndicator;
39315
40122
  exports.iconMarkup = iconMarkup;
40123
+ exports.inputDeltas = inputDeltas;
39316
40124
  exports.inputVisible = inputVisible;
39317
40125
  exports.legendActions = legendActions;
39318
40126
  exports.legendCallouts = legendCallouts;