@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
package/dist/index.cjs CHANGED
@@ -154,6 +154,12 @@ var IndicatorHandleImpl = class {
154
154
  get visible() {
155
155
  return this.visibleState;
156
156
  }
157
+ inputValues() {
158
+ return this.controller.inputValuesOf(this.id);
159
+ }
160
+ propValues() {
161
+ return this.controller.propValuesOf(this.id);
162
+ }
157
163
  setInput(key, value) {
158
164
  this.controller.applyInputs(this.id, { [key]: value });
159
165
  }
@@ -5418,6 +5424,111 @@ function roundFloat(n) {
5418
5424
  return Math.round(n * 1e8) / 1e8;
5419
5425
  }
5420
5426
 
5427
+ // src/core/drawings/types/Magnifier.ts
5428
+ var MAGNIFIER_TIMEFRAME_OPTIONS = [
5429
+ { value: "auto", label: "Auto", ms: 0 },
5430
+ { value: "1", label: "1m", ms: 6e4 },
5431
+ { value: "5", label: "5m", ms: 3e5 },
5432
+ { value: "15", label: "15m", ms: 9e5 },
5433
+ { value: "30", label: "30m", ms: 18e5 },
5434
+ { value: "60", label: "1h", ms: 36e5 },
5435
+ { value: "240", label: "4h", ms: 144e5 },
5436
+ { value: "D", label: "1D", ms: 864e5 }
5437
+ ];
5438
+ function magnifierTimeframeLabel(value) {
5439
+ const opt = MAGNIFIER_TIMEFRAME_OPTIONS.find((o) => o.value === value);
5440
+ if (opt) return opt.label;
5441
+ const n = Number(value);
5442
+ if (Number.isFinite(n) && n > 0) {
5443
+ if (n % 1440 === 0) return `${n / 1440}D`;
5444
+ if (n % 60 === 0) return `${n / 60}h`;
5445
+ return `${n}m`;
5446
+ }
5447
+ return value;
5448
+ }
5449
+ function defaultMagnifierStyle() {
5450
+ return { timeframe: "auto", upColor: "", downColor: "" };
5451
+ }
5452
+ var Magnifier = class extends Drawing {
5453
+ constructor(init) {
5454
+ super(init);
5455
+ this.type = "magnifier";
5456
+ /** Pixel rect of the timeframe chip as painted last frame, caret included — the chip is
5457
+ * an interactive dropdown trigger, so the interaction layer needs the exact rect the
5458
+ * painter measured. Renderer-transient: never serialized, null while unpainted. */
5459
+ this.chipRect = null;
5460
+ if (!this.magnifier) this.magnifier = defaultMagnifierStyle();
5461
+ }
5462
+ anchorSchema() {
5463
+ return { min: 2, max: 2, slots: [{ role: "c1", free: "both" }, { role: "c2", free: "both" }] };
5464
+ }
5465
+ placementMode() {
5466
+ return "drag";
5467
+ }
5468
+ /** The pixel rectangle between the two corner anchors (painter + hit-test share it). */
5469
+ rect(proj) {
5470
+ const a = this.anchors[0];
5471
+ const b = this.anchors[1];
5472
+ if (!a || !b) return null;
5473
+ const ya = proj.yOf(a.price, this.paneId);
5474
+ const yb = proj.yOf(b.price, this.paneId);
5475
+ if (ya == null || yb == null) return null;
5476
+ return { x1: proj.xOf(a.time), y1: ya, x2: proj.xOf(b.time), y2: yb };
5477
+ }
5478
+ hitTest(px, py, proj, tol) {
5479
+ const r = this.rect(proj);
5480
+ if (!r) return false;
5481
+ if (pointInBox(px, py, r.x1, r.y1, r.x2, r.y2)) return true;
5482
+ const edges = [
5483
+ [r.x1, r.y1, r.x2, r.y1],
5484
+ [r.x2, r.y1, r.x2, r.y2],
5485
+ [r.x2, r.y2, r.x1, r.y2],
5486
+ [r.x1, r.y2, r.x1, r.y1]
5487
+ ];
5488
+ return edges.some((e) => distToSegment(px, py, e[0], e[1], e[2], e[3]) <= tol);
5489
+ }
5490
+ handlePoints(proj) {
5491
+ const r = this.rect(proj);
5492
+ return r ? [[r.x1, r.y1], [r.x2, r.y2]] : [];
5493
+ }
5494
+ hitHandle(px, py, proj, tol) {
5495
+ return handleAt(px, py, this.handlePoints(proj), tol + 3);
5496
+ }
5497
+ bounds(proj) {
5498
+ const r = this.rect(proj);
5499
+ if (!r) return null;
5500
+ 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) };
5501
+ }
5502
+ priceRange() {
5503
+ const a = this.anchors[0];
5504
+ const b = this.anchors[1];
5505
+ if (!a || !b) return null;
5506
+ return { min: Math.min(a.price, b.price), max: Math.max(a.price, b.price) };
5507
+ }
5508
+ schema() {
5509
+ return {
5510
+ fields: [
5511
+ {
5512
+ path: "magnifier.timeframe",
5513
+ label: "Timeframe",
5514
+ kind: "select",
5515
+ options: MAGNIFIER_TIMEFRAME_OPTIONS,
5516
+ group: "behavior"
5517
+ },
5518
+ ...LINE_FIELDS.map((f) => ({ ...f, label: f.label.replace("Line", "Border") })),
5519
+ { path: "magnifier.upColor", label: "Up candles", kind: "color", group: "fill" },
5520
+ { path: "magnifier.downColor", label: "Down candles", kind: "color", group: "fill" }
5521
+ ]
5522
+ };
5523
+ }
5524
+ writeProps() {
5525
+ return { ...this.magnifier };
5526
+ }
5527
+ readProps(props) {
5528
+ this.magnifier = { ...defaultMagnifierStyle(), ...props };
5529
+ }
5530
+ };
5531
+
5421
5532
  // src/core/drawings/registry.ts
5422
5533
  var REGISTRY2 = /* @__PURE__ */ new Map();
5423
5534
  function registerDrawingType(meta) {
@@ -6077,6 +6188,22 @@ registerDrawingType({
6077
6188
  defaultStyle: { lineColor: DEFAULT_DRAWING_COLOR, lineWidth: 1, lineStyle: "solid" },
6078
6189
  create: (init) => new PositionTool(init)
6079
6190
  });
6191
+ var MAGNIFIER_ICON = svg24(
6192
+ '<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"/>'
6193
+ );
6194
+ registerDrawingType({
6195
+ type: "magnifier",
6196
+ group: "measure",
6197
+ label: "Magnifier",
6198
+ icon: MAGNIFIER_ICON,
6199
+ // An empty border color means the THEME's contrast ink (white on dark, black on
6200
+ // light), resolved at paint time so it follows theme switches; a user pick wins.
6201
+ defaultStyle: { lineColor: "", lineWidth: 1, lineStyle: "solid" },
6202
+ coversSeries: true,
6203
+ // the inset's backdrop must sit over the base candles it replaces
6204
+ placementHint: "Drag an area on the chart to view it at a lower timeframe",
6205
+ create: (init) => new Magnifier(init)
6206
+ });
6080
6207
  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"/>');
6081
6208
  registerDrawingType({
6082
6209
  type: "anchoredvwap",
@@ -6116,7 +6243,7 @@ var GEOMETRY_TYPES = ["dedekind", "sonic", "supersonic", "goldensonic", "goldens
6116
6243
  var PATTERN_TYPES = ["xabcd", "abcd", "headshoulders"];
6117
6244
  var ELLIOTT_TYPES = ["elliottimpulse", "elliottcorrection"];
6118
6245
  var HARMONIC_TYPES = ["gartley", "bat", "butterfly", "crab", "shark", "cypher"];
6119
- var MEASUREMENT_TYPES = ["position", "datepricerange"];
6246
+ var MEASUREMENT_TYPES = ["position", "datepricerange", "magnifier"];
6120
6247
  var VOLUME_TYPES = ["anchoredvwap", "fixedrangevp"];
6121
6248
  var BRUSH_TYPES = ["freehand", "highlighter"];
6122
6249
  var ARROW_TYPES = ["arrow", "arrowmarkup", "arrowmarkdown"];
@@ -6480,7 +6607,7 @@ var DrawingHistory = class {
6480
6607
 
6481
6608
  // src/core/drawings/DrawingController.ts
6482
6609
  var DrawingController = class {
6483
- constructor(renderer, events, option) {
6610
+ constructor(renderer, events, option, seriesGateway) {
6484
6611
  this.events = events;
6485
6612
  this.store = new DrawingStore();
6486
6613
  this.history = new DrawingHistory();
@@ -6506,6 +6633,7 @@ var DrawingController = class {
6506
6633
  const { definition, visible } = buildToolbar(option);
6507
6634
  this.port.setToolbar(definition);
6508
6635
  this.port.showToolbar(visible);
6636
+ if (seriesGateway) this.port.setSeriesGateway?.(seriesGateway);
6509
6637
  this.subs.push(this.port.onDrawingIntent((i) => this.onIntent(i)));
6510
6638
  this.subs.push(this.store.onChange(() => this.sync()));
6511
6639
  }
@@ -6611,7 +6739,7 @@ var DrawingController = class {
6611
6739
  style,
6612
6740
  text: init.text,
6613
6741
  props: init.props,
6614
- zIndex: init.zIndex ?? this.startZ(init.paneId ?? "price")
6742
+ zIndex: init.zIndex ?? this.startZ(type, init.paneId ?? "price")
6615
6743
  });
6616
6744
  if (!d) return null;
6617
6745
  this.history.record(this.store.serialize());
@@ -6686,10 +6814,14 @@ var DrawingController = class {
6686
6814
  * (falling back to just under the pane's top series where there is no price — a study
6687
6815
  * pane). Half a key down never ties a series; drawings tying each other paint in insertion
6688
6816
  * order, so consecutive new drawings still stack newest-in-front. Undefined without a
6689
- * shared z space — the store then places it over the other drawings, its own layer's top. */
6690
- startZ(paneId) {
6817
+ * shared z space — the store then places it over the other drawings, its own layer's top.
6818
+ * A type that COVERS the series (an opaque inset, `coversSeries`) instead starts just
6819
+ * above the whole stack — under the candles its content would be buried. */
6820
+ startZ(type, paneId) {
6691
6821
  const range = this.port?.stackRange?.(paneId);
6692
- return range ? (range.price ?? range.front) - 0.5 : void 0;
6822
+ if (!range) return void 0;
6823
+ if (getDrawingType(type)?.coversSeries) return range.front + 0.5;
6824
+ return (range.price ?? range.front) - 0.5;
6693
6825
  }
6694
6826
  /** Programmatically select drawings (host UI → chart): shows the on-chart handles + toolbar.
6695
6827
  * `additive` toggles membership (matching shift-click) instead of replacing. */
@@ -6834,7 +6966,7 @@ var DrawingController = class {
6834
6966
  const style = last ? { ...i.doc.style, ...last } : i.doc.style;
6835
6967
  const d = deserializeDrawing({ ...i.doc, id: this.store.nextId(), style });
6836
6968
  if (!d) return;
6837
- if (!d.zIndex) d.zIndex = this.startZ(d.paneId) ?? 0;
6969
+ if (!d.zIndex) d.zIndex = this.startZ(d.type, d.paneId) ?? 0;
6838
6970
  this.history.record(before);
6839
6971
  this.store.add(d);
6840
6972
  this.captureStyle(d.id);
@@ -6932,6 +7064,180 @@ function timeframeToMs(timeframe) {
6932
7064
  return 36e5;
6933
7065
  }
6934
7066
 
7067
+ // src/core/engine/DrawingSeriesService.ts
7068
+ var MAX_BARS = 5e3;
7069
+ var PAD_FRAC = 0.25;
7070
+ var MAX_ENTRIES = 16;
7071
+ var RETRY_MS = 15e3;
7072
+ var AUTO_STEPS = ["240", "60", "30", "15", "5", "1"];
7073
+ var DrawingSeriesService = class {
7074
+ constructor(deps) {
7075
+ this.deps = deps;
7076
+ /** Cached windows per `market|timeframe` key, newest-used last (LRU across keys). */
7077
+ this.cache = /* @__PURE__ */ new Map();
7078
+ this.listeners = /* @__PURE__ */ new Set();
7079
+ }
7080
+ seriesInRange(timeframe, from, to) {
7081
+ if (!this.deps.canFetch()) return { state: "unavailable", reason: "no-source" };
7082
+ const resolved = this.resolveTimeframe(timeframe);
7083
+ if (typeof resolved !== "string") return { state: "unavailable", reason: resolved.reason };
7084
+ const barMs = timeframeToMs(resolved);
7085
+ const lo = Math.min(from, to);
7086
+ const hi = Math.max(from, to);
7087
+ if (!(hi > lo) || !(barMs > 0)) return { state: "unavailable", reason: "not-lower" };
7088
+ if ((hi - lo) / barMs > MAX_BARS) return { state: "unavailable", reason: "too-wide" };
7089
+ const key = `${this.deps.marketKey()}|${resolved}`;
7090
+ const entries = this.cache.get(key) ?? [];
7091
+ const covering = entries.find((e) => e.from <= lo && e.to >= hi);
7092
+ if (covering) {
7093
+ if (covering.pending) return this.loading(entries, resolved, barMs, lo, hi);
7094
+ if (covering.failedAt > 0) {
7095
+ if (Date.now() - covering.failedAt < RETRY_MS) return this.loading(entries, resolved, barMs, lo, hi);
7096
+ entries.splice(entries.indexOf(covering), 1);
7097
+ } else {
7098
+ this.maybeRefresh(key, covering, resolved, barMs, hi);
7099
+ return { state: "ready", bars: this.slice(covering.bars, lo, hi), timeframe: resolved, barMs };
7100
+ }
7101
+ }
7102
+ this.fetchWindow(key, entries, resolved, lo, hi);
7103
+ return this.loading(entries, resolved, barMs, lo, hi);
7104
+ }
7105
+ onUpdate(listener) {
7106
+ this.listeners.add(listener);
7107
+ return () => this.listeners.delete(listener);
7108
+ }
7109
+ // ── internals ──
7110
+ /** `'auto'` → the largest standard step at least 4× finer than the chart (else the finest
7111
+ * step still below it); an explicit timeframe passes only when strictly finer. Failures
7112
+ * distinguish "this pick isn't lower" from "NOTHING lower exists" (the chart is already
7113
+ * at the finest offered step) so the consumer can word its notice honestly. */
7114
+ resolveTimeframe(timeframe) {
7115
+ const chartMs = timeframeToMs(this.deps.chartTimeframe());
7116
+ const finest = AUTO_STEPS[AUTO_STEPS.length - 1];
7117
+ if (timeframeToMs(finest) >= chartMs) return { reason: "none-lower" };
7118
+ const tf = timeframe.trim() || "auto";
7119
+ if (tf === "auto") {
7120
+ for (const step of AUTO_STEPS) {
7121
+ if (timeframeToMs(step) <= chartMs / 4) return step;
7122
+ }
7123
+ return finest;
7124
+ }
7125
+ return timeframeToMs(tf) < chartMs ? tf : { reason: "not-lower" };
7126
+ }
7127
+ /** The `loading` answer, carrying best-effort PARTIAL bars from settled overlapping
7128
+ * windows — a widened window keeps painting what it already has while it fetches. */
7129
+ loading(entries, timeframe, barMs, lo, hi) {
7130
+ const partial = /* @__PURE__ */ new Map();
7131
+ for (const e of entries) {
7132
+ if (e.pending || e.failedAt > 0) continue;
7133
+ if (e.to < lo || e.from > hi) continue;
7134
+ for (const b of this.slice(e.bars, lo, hi)) partial.set(b.time, b);
7135
+ }
7136
+ if (partial.size === 0) return { state: "loading", timeframe, barMs };
7137
+ const bars = [...partial.values()].sort((a, b) => a.time - b.time);
7138
+ return { state: "loading", timeframe, barMs, bars };
7139
+ }
7140
+ /** Kick ONE background fetch for the padded window. Any OVERLAPPING in-flight fetch
7141
+ * defers this one (a corner drag repaints per pointer move — kicking a window per
7142
+ * frame would spam the provider); when it lands, the next paint re-evaluates. */
7143
+ fetchWindow(key, entries, timeframe, lo, hi) {
7144
+ if (entries.some((e) => e.pending && e.to >= lo && e.from <= hi)) return;
7145
+ const pad = (hi - lo) * PAD_FRAC;
7146
+ const entry = { from: lo - pad, to: hi + pad, bars: [], fetchedAt: 0, pending: true, failedAt: 0 };
7147
+ entries.push(entry);
7148
+ this.cache.set(key, entries);
7149
+ this.evict();
7150
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
7151
+ entry.bars = bars;
7152
+ entry.fetchedAt = Date.now();
7153
+ entry.pending = false;
7154
+ this.absorbOverlaps(key, entry);
7155
+ this.fire();
7156
+ }).catch(() => {
7157
+ entry.pending = false;
7158
+ entry.failedAt = Date.now();
7159
+ this.fire();
7160
+ });
7161
+ }
7162
+ /** A window whose right edge reaches the newest fetched bar refreshes at most once per
7163
+ * bar interval — new closed bars ride the feed's cache, only the live tail re-fetches. */
7164
+ maybeRefresh(key, entry, timeframe, barMs, hi) {
7165
+ if (entry.pending) return;
7166
+ const lastBar = entry.bars.length > 0 ? entry.bars[entry.bars.length - 1].time : entry.from;
7167
+ if (hi < lastBar) return;
7168
+ if (Date.now() - entry.fetchedAt < barMs) return;
7169
+ entry.pending = true;
7170
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
7171
+ entry.bars = bars;
7172
+ entry.fetchedAt = Date.now();
7173
+ entry.pending = false;
7174
+ this.fire();
7175
+ }).catch(() => {
7176
+ entry.pending = false;
7177
+ entry.fetchedAt = Date.now();
7178
+ });
7179
+ }
7180
+ /** Merge windows that overlap `entry` into it (dedupe by bar time) so a key's list
7181
+ * converges instead of accumulating slivers. */
7182
+ absorbOverlaps(key, entry) {
7183
+ const entries = this.cache.get(key);
7184
+ if (!entries) return;
7185
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
7186
+ const other = entries[i];
7187
+ if (other === entry || other.pending || other.failedAt > 0) continue;
7188
+ if (other.to < entry.from || other.from > entry.to) continue;
7189
+ const byTime = /* @__PURE__ */ new Map();
7190
+ for (const b of other.bars) byTime.set(b.time, b);
7191
+ for (const b of entry.bars) byTime.set(b.time, b);
7192
+ entry.bars = [...byTime.values()].sort((a, b) => a.time - b.time);
7193
+ entry.from = Math.min(entry.from, other.from);
7194
+ entry.to = Math.max(entry.to, other.to);
7195
+ entry.fetchedAt = Math.min(entry.fetchedAt, other.fetchedAt || entry.fetchedAt);
7196
+ entries.splice(i, 1);
7197
+ }
7198
+ }
7199
+ /** Drop the oldest settled windows once the global count passes {@link MAX_ENTRIES}. */
7200
+ evict() {
7201
+ let total = 0;
7202
+ for (const entries of this.cache.values()) total += entries.length;
7203
+ while (total > MAX_ENTRIES) {
7204
+ let oldestKey = null;
7205
+ let oldestIdx = -1;
7206
+ let oldestAt = Infinity;
7207
+ for (const [key, entries2] of this.cache) {
7208
+ for (let i = 0; i < entries2.length; i += 1) {
7209
+ const e = entries2[i];
7210
+ if (e.pending) continue;
7211
+ const at = e.fetchedAt || e.failedAt;
7212
+ if (at < oldestAt) {
7213
+ oldestKey = key;
7214
+ oldestIdx = i;
7215
+ oldestAt = at;
7216
+ }
7217
+ }
7218
+ }
7219
+ if (oldestKey == null) return;
7220
+ const entries = this.cache.get(oldestKey);
7221
+ entries.splice(oldestIdx, 1);
7222
+ if (entries.length === 0) this.cache.delete(oldestKey);
7223
+ total -= 1;
7224
+ }
7225
+ }
7226
+ /** Bars whose open time falls within `[lo, hi]` (ascending input → linear scan is fine). */
7227
+ slice(bars, lo, hi) {
7228
+ const out = [];
7229
+ for (const b of bars) {
7230
+ if (b.time < lo) continue;
7231
+ if (b.time > hi) break;
7232
+ out.push(b);
7233
+ }
7234
+ return out;
7235
+ }
7236
+ fire() {
7237
+ for (const l of [...this.listeners]) l();
7238
+ }
7239
+ };
7240
+
6935
7241
  // src/data/symbol-groups.ts
6936
7242
  function isGroupRow(d) {
6937
7243
  return d.group != null && d.ticker === d.group;
@@ -7296,6 +7602,7 @@ var RUN_EMIT_THROTTLE_MS = 1e3;
7296
7602
  var PREVIEW_BARS = 300;
7297
7603
  var SINGLE_LOAD_BARS = 5e3;
7298
7604
  var CHUNK_BARS = 1e4;
7605
+ var FIRST_PAINT_BARS = 100;
7299
7606
  var GAP_FACTOR = 1.5;
7300
7607
  var HEAL_COOLDOWN_MS = 5e3;
7301
7608
  var EngineOrchestrator = class _EngineOrchestrator {
@@ -7413,7 +7720,13 @@ var EngineOrchestrator = class _EngineOrchestrator {
7413
7720
  const initialStyle = this.renderer.readFeature("priceStyle");
7414
7721
  if (typeof initialStyle === "string") this.priceStyle = initialStyle;
7415
7722
  this.barTransform = barTransformFor(initialStyle);
7416
- this.drawings = new DrawingController(this.renderer, this.events, config.drawings);
7723
+ const drawingSeries = new DrawingSeriesService({
7724
+ fetchBars: (tf, range) => this.fetchSeries(this.config.market.symbol ?? "", tf, range),
7725
+ canFetch: () => !!this.feed.loadRange && !this.config.market.data?.length && !!this.config.market.symbol,
7726
+ chartTimeframe: () => this.config.market.timeframe ?? "60",
7727
+ marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
7728
+ });
7729
+ this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
7417
7730
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7418
7731
  this.endLoad();
7419
7732
  this.events.emit("data:unresolved", info);
@@ -7565,6 +7878,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
7565
7878
  let painted = false;
7566
7879
  const paint = (bars, final) => {
7567
7880
  if (this.generation !== gen || !final && bars.length === 0) return;
7881
+ if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
7568
7882
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
7569
7883
  if (!painted && bars.length > 0) {
7570
7884
  painted = true;
@@ -8195,6 +8509,15 @@ var EngineOrchestrator = class _EngineOrchestrator {
8195
8509
  record.pendingCause = "inputs";
8196
8510
  if (record.session) record.session.update(record.inputValues);
8197
8511
  else if (record.native && !record.hidden) record.native.instance.setInputs(record.inputValues);
8512
+ this.events.emit("indicator:inputs", { id });
8513
+ }
8514
+ /** IndicatorController: the CURRENT stored input values (defaults merged with edits). */
8515
+ inputValuesOf(id) {
8516
+ return { ...this.registry.get(id)?.inputValues };
8517
+ }
8518
+ /** IndicatorController: the CURRENT declaration-prop overrides. */
8519
+ propValuesOf(id) {
8520
+ return { ...this.registry.get(id)?.propValues };
8198
8521
  }
8199
8522
  /** IndicatorController: re-run an indicator with merged declaration-prop overrides.
8200
8523
  * Same lifecycle as {@link applyInputs} — a prop change replays the whole script.
@@ -8208,6 +8531,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8208
8531
  if (!record.hidden) this.setLoading(record, true);
8209
8532
  record.pendingCause = "inputs";
8210
8533
  record.session.update(record.inputValues, record.propValues);
8534
+ this.events.emit("indicator:inputs", { id });
8211
8535
  }
8212
8536
  /** IndicatorController: tear down an indicator and (if now empty) its pane. */
8213
8537
  /** Live handles of every indicator on the chart (script + native), insertion order. */
@@ -8395,8 +8719,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
8395
8719
  onModel: (model) => {
8396
8720
  const first = !record.announced;
8397
8721
  const cause = record.pendingCause ?? "history";
8722
+ if (!this.applyModel(id, model)) return;
8398
8723
  record.pendingCause = void 0;
8399
- this.applyModel(id, model);
8400
8724
  this.emitContextChanged(id);
8401
8725
  this.emitScriptRun(id, cause, first);
8402
8726
  },
@@ -8689,10 +9013,16 @@ var EngineOrchestrator = class _EngineOrchestrator {
8689
9013
  * Apply an emitted model. First emission mounts (and routes the pane); a pending
8690
9014
  * structural change (after an input edit) remounts idempotently; everything else
8691
9015
  * (live tick / viewport re-run) value-patches.
9016
+ *
9017
+ * Returns false when the model was DEFERRED — an output-free model arriving while
9018
+ * the record is still loading and the chart has no bars (see below); every other
9019
+ * outcome, including the hidden drop, returns true so the caller's event semantics
9020
+ * stay unchanged.
8692
9021
  */
8693
9022
  applyModel(id, model) {
8694
9023
  const record = this.registry.get(id);
8695
- if (!record || record.hidden) return;
9024
+ if (!record || record.hidden) return true;
9025
+ if (record.loading && this.bars.length === 0 && !_EngineOrchestrator.modelHasOutput(model)) return false;
8696
9026
  const handle = this.handles.get(id);
8697
9027
  if (!record.renderHandle) {
8698
9028
  const paneId2 = this.routePane(id, model, record.options ?? {});
@@ -8702,7 +9032,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8702
9032
  record.renderHandle = this.renderer.mountIndicator(model);
8703
9033
  record.pendingStructural = false;
8704
9034
  this.announce(record, handle);
8705
- return;
9035
+ return true;
8706
9036
  }
8707
9037
  let paneId = record.model?.paneId ?? "price";
8708
9038
  const prevOwnScale = record.model?.ownScale === true;
@@ -8730,6 +9060,11 @@ var EngineOrchestrator = class _EngineOrchestrator {
8730
9060
  }
8731
9061
  if (record.loading) this.setLoading(record, false);
8732
9062
  this.announce(record, handle);
9063
+ return true;
9064
+ }
9065
+ /** True when the model carries ANY executed output — series, drawings, bar colors, or trades. */
9066
+ static modelHasOutput(model) {
9067
+ 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;
8733
9068
  }
8734
9069
  routePane(id, model, options) {
8735
9070
  if (options.pane === "new") return `pane-${id}`;
@@ -9432,6 +9767,7 @@ function intervalMs(timeframe) {
9432
9767
  "4h": 144e5,
9433
9768
  "1d": 864e5,
9434
9769
  "1w": 6048e5,
9770
+ "1M": 2592e6,
9435
9771
  "1": 6e4,
9436
9772
  "5": 3e5,
9437
9773
  "15": 9e5,
@@ -9439,7 +9775,8 @@ function intervalMs(timeframe) {
9439
9775
  "60": 36e5,
9440
9776
  "240": 144e5,
9441
9777
  D: 864e5,
9442
- W: 6048e5
9778
+ W: 6048e5,
9779
+ M: 2592e6
9443
9780
  };
9444
9781
  return map[timeframe] ?? 36e5;
9445
9782
  }
@@ -10995,6 +11332,14 @@ function inputVisible(when, values) {
10995
11332
  const conds = Array.isArray(when) ? when : [when];
10996
11333
  return conds.every((c) => c.anyOf ? c.anyOf.some((x) => x === values[c.key]) : values[c.key] === c.equals);
10997
11334
  }
11335
+ function inputDeltas(schema, values) {
11336
+ const out = {};
11337
+ for (const s of schema) {
11338
+ const v = values[s.key];
11339
+ if (v !== void 0 && JSON.stringify(v) !== JSON.stringify(s.defval)) out[s.key] = v;
11340
+ }
11341
+ return Object.keys(out).length > 0 ? out : void 0;
11342
+ }
10998
11343
 
10999
11344
  // src/ui/components/select/controller.ts
11000
11345
  function selectController(opts) {
@@ -16778,6 +17123,7 @@ var DOUBLE_TAP_MS = 350;
16778
17123
  var DOUBLE_TAP_SLOP = 30;
16779
17124
  var TIME_SCALE_K = 4e-3;
16780
17125
  var WHEEL_ZOOM_K = 4e-3;
17126
+ var WHEEL_PRICE_DRAG_PX = 0.25;
16781
17127
  function wheelZoomAnchor(coords, cursorX, rightEdge) {
16782
17128
  if (rightEdge) return { logical: coords.rightEdgeLogical, x: coords.width };
16783
17129
  return { logical: coords.xToLogical(cursorX), x: cursorX };
@@ -16908,7 +17254,7 @@ var InputController = class {
16908
17254
  this.capture(e.pointerId);
16909
17255
  return;
16910
17256
  }
16911
- if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y)) {
17257
+ if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y, this.snapMode(e))) {
16912
17258
  this.region = "drawing";
16913
17259
  this.capture(e.pointerId);
16914
17260
  return;
@@ -17022,7 +17368,7 @@ var InputController = class {
17022
17368
  const wasTouch = e.pointerType === "touch";
17023
17369
  const tapRelease = this.dragging && !this.moved && (!wasTouch || Math.hypot(x - this.startX, y - this.startY) <= TOUCH_TAP_SLOP);
17024
17370
  if (this.dragging && this.region === "drawing") {
17025
- this.deps.drawingsPointerUp?.(x, y);
17371
+ this.deps.drawingsPointerUp?.(x, y, this.snapMode(e));
17026
17372
  } else if (tapRelease && this.region === "data") {
17027
17373
  this.deps.onClick(x, y);
17028
17374
  } else if (this.dragging && this.region === "data") {
@@ -17043,7 +17389,7 @@ var InputController = class {
17043
17389
  if (e.pointerType === "touch") this.touches.delete(e.pointerId);
17044
17390
  this.cancelLongPress();
17045
17391
  if (!this.dragging) return;
17046
- if (this.region === "drawing" && !Number.isNaN(this.cursorX)) this.deps.drawingsPointerUp?.(this.cursorX, this.cursorY);
17392
+ if (this.region === "drawing" && !Number.isNaN(this.cursorX)) this.deps.drawingsPointerUp?.(this.cursorX, this.cursorY, this.snapMode(e));
17047
17393
  if (this.region === "crosshair" || e.pointerType === "touch") this.deps.onPointerMove(null, null);
17048
17394
  this.endGesture(e);
17049
17395
  };
@@ -17059,6 +17405,12 @@ var InputController = class {
17059
17405
  this.onWheel = (e) => {
17060
17406
  e.preventDefault();
17061
17407
  this.deps.drawingsClearTransient?.();
17408
+ const { x, y } = this.local(e);
17409
+ if (this.regionAt(x, y) === "price" && e.deltaY !== 0) {
17410
+ this.deps.beginPriceScale(x, y);
17411
+ this.deps.priceScaleBy(e.deltaY * WHEEL_PRICE_DRAG_PX);
17412
+ return;
17413
+ }
17062
17414
  const coords = this.deps.getCoords();
17063
17415
  const vp = coords.getViewport();
17064
17416
  const pan = wheelPanDelta(e.deltaX, e.deltaY, e.shiftKey);
@@ -17066,9 +17418,8 @@ var InputController = class {
17066
17418
  this.deps.apply({ barSpacing: vp.barSpacing, rightOffset: wheelPanRightOffset(vp.rightOffset, pan, coords.pxPerBar()) });
17067
17419
  return;
17068
17420
  }
17069
- const cursorX = this.local(e).x;
17070
17421
  const rightEdge = this.rightEdgeZoom && !(e.ctrlKey || e.metaKey);
17071
- const anchor = wheelZoomAnchor(coords, cursorX, rightEdge);
17422
+ const anchor = wheelZoomAnchor(coords, x, rightEdge);
17072
17423
  const target = clampBarSpacing(vp.barSpacing * Math.exp(-e.deltaY * WHEEL_ZOOM_K));
17073
17424
  this.deps.zoomTo(target, anchor.logical, anchor.x);
17074
17425
  };
@@ -18550,6 +18901,15 @@ function drawingSetEmpty(s) {
18550
18901
  function fontSizePx(size) {
18551
18902
  return size === "auto" ? 12 : namedFontSize(size);
18552
18903
  }
18904
+ function lineCoversWindow(a, b, extend, lo, hi) {
18905
+ const minX = Math.min(a, b);
18906
+ const maxX = Math.max(a, b);
18907
+ if (a === b) return a >= lo && a <= hi;
18908
+ if (extend === "both") return true;
18909
+ if (extend === "left") return maxX >= lo;
18910
+ if (extend === "right") return minX <= hi;
18911
+ return maxX >= lo && minX <= hi;
18912
+ }
18553
18913
  var DrawingSceneRenderer = class {
18554
18914
  constructor(deps, set = EMPTY_DRAWING_SET) {
18555
18915
  this.deps = deps;
@@ -18603,7 +18963,9 @@ var DrawingSceneRenderer = class {
18603
18963
  const lo = Math.min(from, to);
18604
18964
  const hi = Math.max(from, to);
18605
18965
  const visible = (a, b, extend) => {
18606
- if (extend !== "none") return true;
18966
+ if (extend === "both") return true;
18967
+ if (extend === "left") return Math.max(a, b) >= lo;
18968
+ if (extend === "right") return Math.min(a, b) <= hi;
18607
18969
  return Math.max(a, b) >= lo && Math.min(a, b) <= hi;
18608
18970
  };
18609
18971
  let min = Infinity;
@@ -18614,7 +18976,7 @@ var DrawingSceneRenderer = class {
18614
18976
  };
18615
18977
  for (const ln of this.set.lines) {
18616
18978
  if (ln.invisible) continue;
18617
- if (!visible(this.logicalOf(ln.xloc, ln.x1), this.logicalOf(ln.xloc, ln.x2), ln.extend)) continue;
18979
+ if (!lineCoversWindow(this.logicalOf(ln.xloc, ln.x1), this.logicalOf(ln.xloc, ln.x2), ln.extend, lo, hi)) continue;
18618
18980
  fold(ln.y1);
18619
18981
  fold(ln.y2);
18620
18982
  }
@@ -21167,6 +21529,17 @@ var DrawingPainter = class {
21167
21529
  constructor() {
21168
21530
  /** The current `paintAll` call's interaction state, visible to the per-type painters. */
21169
21531
  this.targets = {};
21532
+ /** The chart's active series LOOK — style + resolved series colors — pushed by the
21533
+ * controller before each paint. The magnifier's inset mirrors both: candles/bars/line/
21534
+ * area restyle the paint (bar-transform styles like Heikin Ashi transform the fetched
21535
+ * bars; unknown/custom styles fall back to candles), and the colors default to the main
21536
+ * series' own so the inset reads as a finer copy of the chart. */
21537
+ this.seriesLook = {
21538
+ style: "candles",
21539
+ upColor: BULLISH,
21540
+ downColor: BEARISH,
21541
+ lineColor: BULLISH
21542
+ };
21170
21543
  }
21171
21544
  /** Paint every visible drawing, then selection handles for the targeted ones.
21172
21545
  * Each drawing is clipped to its own pane's rect (and skipped entirely while that pane
@@ -21211,6 +21584,8 @@ var DrawingPainter = class {
21211
21584
  ctx.globalAlpha = GHOST_ALPHA;
21212
21585
  if (ghost instanceof RegressionChannel || ghost instanceof FixedRangeVolumeProfile) {
21213
21586
  this.paintTimeSpanGhost(ctx, ghost, proj);
21587
+ } else if (ghost instanceof Magnifier) {
21588
+ this.paintMagnifierGhost(ctx, ghost, proj, theme);
21214
21589
  } else this.paintOne(ctx, ghost, proj, theme);
21215
21590
  ctx.globalAlpha = 1;
21216
21591
  }
@@ -21344,6 +21719,10 @@ var DrawingPainter = class {
21344
21719
  this.paintLabel(ctx, d, proj, theme);
21345
21720
  return;
21346
21721
  }
21722
+ if (d instanceof Magnifier) {
21723
+ this.paintMagnifier(ctx, d, proj, theme);
21724
+ return;
21725
+ }
21347
21726
  if (d instanceof PatternDrawing) {
21348
21727
  this.paintPattern(ctx, d, proj, theme);
21349
21728
  return;
@@ -21836,6 +22215,216 @@ var DrawingPainter = class {
21836
22215
  ctx.textBaseline = "alphabetic";
21837
22216
  }
21838
22217
  }
22218
+ /** Placement preview for the magnifier: a dashed rectangle outline only — no backdrop and
22219
+ * no series read, so dragging the area open never kicks a fetch per cursor move. */
22220
+ paintMagnifierGhost(ctx, d, proj, theme) {
22221
+ const r = d.rect(proj);
22222
+ if (!r) return;
22223
+ ctx.save();
22224
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
22225
+ ctx.lineWidth = 1;
22226
+ ctx.setLineDash([4, 4]);
22227
+ 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));
22228
+ ctx.restore();
22229
+ }
22230
+ /** Paint a magnifier: an opaque theme-background inset whose interior shows the chart's
22231
+ * market at a finer timeframe — candles at their true time/price positions, clipped to
22232
+ * the rectangle. Bars come through `Projector.seriesInRange` (cache read; `loading` and
22233
+ * `unavailable` states paint a short notice instead). The lower-timeframe candles shift
22234
+ * half a chart bar LEFT of their raw time pixel so each chart candle's visual cell —
22235
+ * centered on its open time — subdivides in place. */
22236
+ paintMagnifier(ctx, d, proj, theme) {
22237
+ const r = d.rect(proj);
22238
+ const a = d.anchors[0];
22239
+ const b = d.anchors[1];
22240
+ if (!r || !a || !b) return;
22241
+ const x0 = Math.min(r.x1, r.x2);
22242
+ const x1 = Math.max(r.x1, r.x2);
22243
+ const y0 = Math.min(r.y1, r.y2);
22244
+ const y1 = Math.max(r.y1, r.y2);
22245
+ const w = x1 - x0;
22246
+ const h = y1 - y0;
22247
+ ctx.save();
22248
+ ctx.globalAlpha = 1;
22249
+ ctx.fillStyle = theme.background;
22250
+ ctx.fillRect(x0, y0, w, h);
22251
+ ctx.restore();
22252
+ const from = Math.min(a.time, b.time);
22253
+ const to = Math.max(a.time, b.time);
22254
+ const chartBars = proj.barsBetween ? proj.barsBetween(from, to) : 0;
22255
+ const chartMs = chartBars > 0 ? (to - from) / chartBars : 0;
22256
+ const res = proj.seriesInRange && chartMs > 0 && w > 1 && h > 1 ? proj.seriesInRange(d.magnifier.timeframe, from, to + chartMs) : void 0;
22257
+ let seriesBars = res?.state === "ready" || res?.state === "loading" ? res.bars ?? [] : [];
22258
+ if (res && (res.state === "ready" || res.state === "loading") && seriesBars.length > 0) {
22259
+ const look = this.seriesLook;
22260
+ const transform = look.style !== "candles" ? barTransformFor(look.style) : null;
22261
+ if (transform) seriesBars = transform.full(seriesBars);
22262
+ const mode = look.style === "bars" ? "bars" : look.style === "line" || look.style === "baseline" ? "line" : look.style === "area" ? "area" : "candles";
22263
+ const halfPitch = (proj.xOf(from + chartMs) - proj.xOf(from)) / 2;
22264
+ ctx.save();
22265
+ ctx.beginPath();
22266
+ ctx.rect(x0, y0, w, h);
22267
+ ctx.clip();
22268
+ if (mode === "line" || mode === "area") {
22269
+ this.paintMagnifierLine(ctx, d, proj, seriesBars, res.barMs, halfPitch, y1, mode === "area", d.magnifier.upColor || look.lineColor);
22270
+ } else {
22271
+ this.paintMagnifierBars(ctx, d, proj, seriesBars, res.barMs, halfPitch, x0, x1, mode, d.magnifier.upColor || look.upColor, d.magnifier.downColor || look.downColor);
22272
+ }
22273
+ ctx.restore();
22274
+ } else if (res) {
22275
+ 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";
22276
+ this.paintMagnifierNotice(ctx, notice, x0, y0, w, h, theme);
22277
+ }
22278
+ ctx.save();
22279
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
22280
+ ctx.lineWidth = d.style.lineWidth || 1;
22281
+ ctx.setLineDash(dashPattern(d.style.lineStyle, d.style.lineWidth || 1));
22282
+ ctx.strokeRect(x0, y0, w, h);
22283
+ ctx.restore();
22284
+ if (w > 44) {
22285
+ const label = magnifierTimeframeLabel(res?.state === "ready" || res?.state === "loading" ? res.timeframe : d.magnifier.timeframe);
22286
+ const chipH = 17;
22287
+ const gap = 4;
22288
+ const pane = proj.paneRect?.(d.paneId);
22289
+ const paneBottom = pane ? pane.top + pane.height : proj.height;
22290
+ const below = y1 + gap + chipH <= paneBottom;
22291
+ const chipY = below ? y1 + gap : y1 - gap - chipH;
22292
+ ctx.save();
22293
+ ctx.font = `10px ${theme.fontFamily}`;
22294
+ const tw = ctx.measureText(label).width;
22295
+ const caretW = 11;
22296
+ const chipW = tw + 12 + caretW;
22297
+ roundRect(ctx, x0, chipY, chipW, chipH, 3);
22298
+ ctx.fillStyle = theme.background;
22299
+ ctx.fill();
22300
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
22301
+ ctx.lineWidth = 1;
22302
+ ctx.setLineDash([]);
22303
+ ctx.stroke();
22304
+ ctx.fillStyle = theme.textColor;
22305
+ ctx.textAlign = "left";
22306
+ ctx.textBaseline = "middle";
22307
+ ctx.fillText(label, x0 + 6, chipY + chipH / 2 + 0.5);
22308
+ const cxr = x0 + 6 + tw + 5;
22309
+ const cyr = chipY + chipH / 2;
22310
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.7);
22311
+ ctx.lineWidth = 1.2;
22312
+ ctx.beginPath();
22313
+ ctx.moveTo(cxr, cyr - 1.5);
22314
+ ctx.lineTo(cxr + 2.5, cyr + 1.5);
22315
+ ctx.lineTo(cxr + 5, cyr - 1.5);
22316
+ ctx.stroke();
22317
+ ctx.restore();
22318
+ d.chipRect = { x: x0, y: chipY, w: chipW, h: chipH };
22319
+ } else {
22320
+ d.chipRect = null;
22321
+ }
22322
+ }
22323
+ /** The magnifier's candle/bar loop: each bar's cell spans its open→close time (shifted left
22324
+ * by half a chart bar). Candles: wick always, body once the cell is wide enough to carry
22325
+ * one. OHLC bars: the high–low spine with open/close ticks once the cell has the room. */
22326
+ paintMagnifierBars(ctx, d, proj, bars, barMs, halfPitch, x0, x1, mode, upColor, downColor) {
22327
+ ctx.setLineDash([]);
22328
+ ctx.lineWidth = 1;
22329
+ for (const bar of bars) {
22330
+ const cx0 = proj.xOf(bar.time) - halfPitch;
22331
+ const cx1 = proj.xOf(bar.time + barMs) - halfPitch;
22332
+ if (cx1 < x0 || cx0 > x1) continue;
22333
+ const yHigh = proj.yOf(bar.high, d.paneId);
22334
+ const yLow = proj.yOf(bar.low, d.paneId);
22335
+ const yOpen = proj.yOf(bar.open, d.paneId);
22336
+ const yClose = proj.yOf(bar.close, d.paneId);
22337
+ if (yHigh == null || yLow == null || yOpen == null || yClose == null) continue;
22338
+ const color = bar.close >= bar.open ? upColor : downColor;
22339
+ const cellW = cx1 - cx0;
22340
+ const cx = (cx0 + cx1) / 2;
22341
+ ctx.strokeStyle = color;
22342
+ ctx.beginPath();
22343
+ ctx.moveTo(cx, yHigh);
22344
+ ctx.lineTo(cx, yLow);
22345
+ ctx.stroke();
22346
+ if (cellW < 3) continue;
22347
+ if (mode === "bars") {
22348
+ const tick = Math.max(1, cellW * 0.35);
22349
+ ctx.beginPath();
22350
+ ctx.moveTo(cx - tick, yOpen);
22351
+ ctx.lineTo(cx, yOpen);
22352
+ ctx.moveTo(cx, yClose);
22353
+ ctx.lineTo(cx + tick, yClose);
22354
+ ctx.stroke();
22355
+ } else {
22356
+ const bw = Math.max(1, cellW * 0.7);
22357
+ ctx.fillStyle = color;
22358
+ ctx.fillRect(cx - bw / 2, Math.min(yOpen, yClose), bw, Math.max(1, Math.abs(yClose - yOpen)));
22359
+ }
22360
+ }
22361
+ }
22362
+ /** The magnifier's line/area rendering: a close polyline through each cell's center (same
22363
+ * half-chart-bar shift as the candles), with an optional translucent fill down to the
22364
+ * rectangle's bottom edge for the area style. Colored like the chart's own line series. */
22365
+ paintMagnifierLine(ctx, d, proj, bars, barMs, halfPitch, yBottom, area, color) {
22366
+ const pts = [];
22367
+ for (const bar of bars) {
22368
+ const y = proj.yOf(bar.close, d.paneId);
22369
+ if (y == null) continue;
22370
+ pts.push([proj.xOf(bar.time + barMs / 2) - halfPitch, y]);
22371
+ }
22372
+ if (pts.length < 2) return;
22373
+ if (area) {
22374
+ ctx.beginPath();
22375
+ ctx.moveTo(pts[0][0], yBottom);
22376
+ for (const [px, py] of pts) ctx.lineTo(px, py);
22377
+ ctx.lineTo(pts[pts.length - 1][0], yBottom);
22378
+ ctx.closePath();
22379
+ ctx.fillStyle = withAlpha(color, 0.15);
22380
+ ctx.fill();
22381
+ }
22382
+ ctx.setLineDash([]);
22383
+ ctx.lineWidth = 1.5;
22384
+ ctx.strokeStyle = color;
22385
+ ctx.beginPath();
22386
+ ctx.moveTo(pts[0][0], pts[0][1]);
22387
+ for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
22388
+ ctx.stroke();
22389
+ }
22390
+ /** Centered muted notice inside the magnifier rect (loading / unavailable states). */
22391
+ paintMagnifierNotice(ctx, text, x0, y0, w, h, theme) {
22392
+ if (w < 60 || h < 20) return;
22393
+ ctx.save();
22394
+ ctx.beginPath();
22395
+ ctx.rect(x0, y0, w, h);
22396
+ ctx.clip();
22397
+ ctx.font = `11px ${theme.fontFamily}`;
22398
+ ctx.fillStyle = withAlpha(theme.textColor, 0.55);
22399
+ ctx.textAlign = "center";
22400
+ ctx.textBaseline = "middle";
22401
+ ctx.fillText(text, x0 + w / 2, y0 + h / 2);
22402
+ ctx.restore();
22403
+ }
22404
+ /** A bottom-center pill prompting the armed tool's placement gesture (e.g. the magnifier's
22405
+ * "drag an area"). Painted by the drawings layer while the tool is armed and no placement
22406
+ * is in progress; chart-background fill so it reads as chrome over any content. */
22407
+ paintPlacementHint(ctx, text, theme, width, height) {
22408
+ ctx.save();
22409
+ ctx.font = `11px ${theme.fontFamily}`;
22410
+ const tw = ctx.measureText(text).width;
22411
+ const pillW = tw + 24;
22412
+ const pillH = 24;
22413
+ const x = (width - pillW) / 2;
22414
+ const y = height - pillH - 14;
22415
+ roundRect(ctx, x, y, pillW, pillH, pillH / 2);
22416
+ ctx.fillStyle = theme.background;
22417
+ ctx.fill();
22418
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
22419
+ ctx.lineWidth = 1;
22420
+ ctx.setLineDash([]);
22421
+ ctx.stroke();
22422
+ ctx.fillStyle = theme.textColor;
22423
+ ctx.textAlign = "center";
22424
+ ctx.textBaseline = "middle";
22425
+ ctx.fillText(text, width / 2, y + pillH / 2 + 0.5);
22426
+ ctx.restore();
22427
+ }
21839
22428
  /** Paint a fixed-range volume profile: horizontal histogram rows (up/down split) anchored to
21840
22429
  * the left or right of the time span, optional VAH / VAL / POC levels across the range, and
21841
22430
  * optional developing POC / VA polylines. Recomputes from the two anchors on every paint. */
@@ -22554,6 +23143,22 @@ var DrawingInteraction = class {
22554
23143
  this.snapAt = changed ? { point: snapped, paneId } : null;
22555
23144
  return snapped;
22556
23145
  }
23146
+ /**
23147
+ * Resolve a cursor pixel through the magnet and return the snapped pixel — the same
23148
+ * conversion drawing placement uses. Updates the snap-ring marker. The measure
23149
+ * ruler goes through this so its endpoints follow weak/strong/Ctrl magnet too.
23150
+ */
23151
+ snapCursor(x, y, mode) {
23152
+ const proj = this.deps.projector();
23153
+ const paneId = proj.paneIdAtY(y) ?? "price";
23154
+ const point = this.resolve(x, y, paneId, mode);
23155
+ const sy = proj.yOf(point.price, paneId);
23156
+ return { x: proj.xOf(point.time), y: sy ?? y };
23157
+ }
23158
+ /** Drop the snap-ring marker (a transient mode ended without going through `up`). */
23159
+ clearSnapMarker() {
23160
+ this.snapAt = null;
23161
+ }
22557
23162
  /** Resolve a pixel to a data point with the segment angle locked to 45° steps around
22558
23163
  * `pivot` (Shift held on a line tool). Works in PIXEL space — the user reasons about
22559
23164
  * the angle they see, not about time/price units. The magnet is bypassed: snapping
@@ -23235,8 +23840,9 @@ function ensureStyles3() {
23235
23840
  if (!existing) document.head.appendChild(s);
23236
23841
  }
23237
23842
  var DrawingSettingsPopup = class {
23238
- constructor(host, theme) {
23843
+ constructor(host, theme, chartBarMs = () => 0) {
23239
23844
  this.host = host;
23845
+ this.chartBarMs = chartBarMs;
23240
23846
  this.el = null;
23241
23847
  this.tipEl = null;
23242
23848
  // floating hover-label (above/below the toolbar)
@@ -23261,6 +23867,14 @@ var DrawingSettingsPopup = class {
23261
23867
  this.theme = theme;
23262
23868
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
23263
23869
  }
23870
+ /** The magnifier timeframe choices strictly below the chart's own bar duration
23871
+ * (`auto` rides along while at least one concrete lower step exists). */
23872
+ lowerTimeframeOptions() {
23873
+ const chartMs = this.chartBarMs();
23874
+ if (!(chartMs > 0)) return [...MAGNIFIER_TIMEFRAME_OPTIONS];
23875
+ const lower = MAGNIFIER_TIMEFRAME_OPTIONS.filter((o) => o.ms > 0 && o.ms < chartMs);
23876
+ return lower.length > 0 ? [MAGNIFIER_TIMEFRAME_OPTIONS[0], ...lower] : [];
23877
+ }
23264
23878
  setTheme(theme) {
23265
23879
  this.theme = theme;
23266
23880
  this.settingsDialog.setTheme(theme);
@@ -23303,7 +23917,20 @@ var DrawingSettingsPopup = class {
23303
23917
  const sz = drawing.size ?? "normal";
23304
23918
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
23305
23919
  }
23306
- 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 })));
23920
+ if (paths.has("magnifier.timeframe") && drawing instanceof Magnifier) {
23921
+ const options = this.lowerTimeframeOptions();
23922
+ if (options.length > 0) {
23923
+ bar.appendChild(
23924
+ this.dropdown("Lower timeframe", options.map((o) => o.value), drawing.magnifier.timeframe, () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
23925
+ label: (v) => magnifierTimeframeLabel(String(v)),
23926
+ labelInTrigger: true
23927
+ })
23928
+ );
23929
+ }
23930
+ bar.appendChild(this.colorButton("Up candles", BUCKET_ICON, drawing.magnifier.upColor || t.upColor, (v) => actions.patch({ "magnifier.upColor": v })));
23931
+ bar.appendChild(this.colorButton("Down candles", BUCKET_ICON, drawing.magnifier.downColor || t.downColor, (v) => actions.patch({ "magnifier.downColor": v })));
23932
+ }
23933
+ 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 })));
23307
23934
  if (paths.has("style.lineWidth")) {
23308
23935
  const wf = schema.fields.find((f) => f.path === "style.lineWidth");
23309
23936
  if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
@@ -23625,6 +24252,7 @@ var DrawingSettingsPopup = class {
23625
24252
  this.colorPop = null;
23626
24253
  this.colorOwner = null;
23627
24254
  }
24255
+ opts.onClose?.();
23628
24256
  }
23629
24257
  });
23630
24258
  const el = pop.el;
@@ -23643,6 +24271,49 @@ var DrawingSettingsPopup = class {
23643
24271
  pop.show();
23644
24272
  return pop;
23645
24273
  }
24274
+ /**
24275
+ * A standalone timeframe menu for the magnifier's ON-CHART chip. The chip lives on
24276
+ * canvas, so a transient invisible anchor is dropped at its pixel rect for the popover
24277
+ * to position against, and removed again when the menu closes. Independent of the
24278
+ * quick toolbar — the chip works without selecting the drawing first.
24279
+ */
24280
+ openMagnifierTimeframeMenu(rect, current, onPick) {
24281
+ ensureStyles3();
24282
+ closeOpenPopovers();
24283
+ const options = this.lowerTimeframeOptions();
24284
+ const anchor = document.createElement("div");
24285
+ anchor.style.cssText = `position:absolute;left:${rect.x}px;top:${rect.y}px;width:${rect.w}px;height:${rect.h}px;pointer-events:none;`;
24286
+ this.host.appendChild(anchor);
24287
+ this.menuPop = this.hostFloat(anchor, {
24288
+ zIndex: 26,
24289
+ padding: "4px",
24290
+ onClose: () => anchor.remove(),
24291
+ fill: (menu2, pop) => {
24292
+ if (options.length === 0) {
24293
+ const note = document.createElement("div");
24294
+ note.style.cssText = "padding:6px 10px;opacity:0.65;white-space:nowrap;";
24295
+ note.textContent = "No lower timeframe available";
24296
+ menu2.appendChild(note);
24297
+ return;
24298
+ }
24299
+ for (const o of options) {
24300
+ const item = document.createElement("button");
24301
+ item.type = "button";
24302
+ item.className = "vela-dpop-item";
24303
+ item.dataset.active = o.value === current ? "1" : "0";
24304
+ 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;";
24305
+ item.textContent = o.label;
24306
+ item.addEventListener("click", (e) => {
24307
+ e.stopPropagation();
24308
+ pop.hide();
24309
+ onPick(o.value);
24310
+ });
24311
+ menu2.appendChild(item);
24312
+ }
24313
+ }
24314
+ });
24315
+ this.menuOwner = anchor;
24316
+ }
23646
24317
  /** A floating list of one-shot actions (icon + label rows) opened by the kebab. */
23647
24318
  openActionMenu(anchor, rows) {
23648
24319
  this.menuPop = this.hostFloat(anchor, {
@@ -23753,10 +24424,13 @@ var DrawingSettingsPopup = class {
23753
24424
  let cur = current;
23754
24425
  const paint = (v) => {
23755
24426
  b.replaceChildren();
23756
- const ic = document.createElement("span");
23757
- ic.style.cssText = "display:flex;";
23758
- ic.innerHTML = sized(render(v));
23759
- b.appendChild(ic);
24427
+ const glyph = render(v);
24428
+ if (glyph) {
24429
+ const ic = document.createElement("span");
24430
+ ic.style.cssText = "display:flex;";
24431
+ ic.innerHTML = sized(glyph);
24432
+ b.appendChild(ic);
24433
+ }
23760
24434
  if (opts.label && opts.labelInTrigger) {
23761
24435
  const tx = document.createElement("span");
23762
24436
  tx.textContent = opts.label(v);
@@ -23798,10 +24472,13 @@ var DrawingSettingsPopup = class {
23798
24472
  item.className = "vela-dpop-item";
23799
24473
  item.dataset.active = active ? "1" : "0";
23800
24474
  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;`;
23801
- const ic = document.createElement("span");
23802
- ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
23803
- ic.innerHTML = sized(render(v), 18);
23804
- item.appendChild(ic);
24475
+ const glyph = render(v);
24476
+ if (glyph) {
24477
+ const ic = document.createElement("span");
24478
+ ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
24479
+ ic.innerHTML = sized(glyph, 18);
24480
+ item.appendChild(ic);
24481
+ }
23805
24482
  if (label) {
23806
24483
  const tx = document.createElement("span");
23807
24484
  tx.textContent = label(v);
@@ -24591,30 +25268,39 @@ var MeasureOverlay = class {
24591
25268
  isFinished() {
24592
25269
  return this.state === "finished";
24593
25270
  }
24594
- /** A press: begin the measurement, or finish it on the second click. */
24595
- down(x, y) {
25271
+ /** A press: begin the measurement, or finish it on the second click.
25272
+ * `x,y` are the raw cursor (drag-slop vs click-move-click). `gx,gy` are the
25273
+ * graphic endpoints — magnet-snapped when the magnet is on, else the same as `x,y`. */
25274
+ down(x, y, gx = x, gy = y) {
24596
25275
  if (this.state === "measuring") {
24597
- this.end = { x, y };
25276
+ this.end = { x: gx, y: gy };
24598
25277
  this.state = "finished";
24599
25278
  return;
24600
25279
  }
24601
- this.start = { x, y };
24602
- this.end = { x, y };
25280
+ this.start = { x: gx, y: gy };
25281
+ this.end = { x: gx, y: gy };
24603
25282
  this.pressX = x;
24604
25283
  this.pressY = y;
24605
25284
  this.state = "measuring";
24606
25285
  }
25286
+ /** Size the in-progress ruler. `x,y` are the graphic (magnet-snapped) cursor. */
24607
25287
  move(x, y) {
24608
25288
  if (this.state === "measuring") this.end = { x, y };
24609
25289
  }
24610
25290
  /** A release: finish if the press was actually dragged (press-drag-release), else wait
24611
- * for the second click (click-move-click). */
24612
- up(x, y) {
25291
+ * for the second click (click-move-click). `x,y` are the raw cursor (slop); `gx,gy`
25292
+ * are the graphic end (magnet-snapped when the magnet is on). */
25293
+ up(x, y, gx = x, gy = y) {
24613
25294
  if (this.state === "measuring" && Math.hypot(x - this.pressX, y - this.pressY) > DRAG_SLOP3) {
24614
- this.end = { x, y };
25295
+ this.end = { x: gx, y: gy };
24615
25296
  this.state = "finished";
24616
25297
  }
24617
25298
  }
25299
+ /** Current graphic endpoints in media pixels, or null when idle. */
25300
+ points() {
25301
+ if (!this.start || !this.end) return null;
25302
+ return { start: this.start, end: this.end };
25303
+ }
24618
25304
  clear() {
24619
25305
  this.state = "idle";
24620
25306
  this.start = null;
@@ -24776,6 +25462,9 @@ var UserDrawingController = class {
24776
25462
  this.intentCb = null;
24777
25463
  /** Another chart's in-progress placement, mirrored here as a ghost (drawings sync). */
24778
25464
  this.externalGhost = null;
25465
+ /** Core-pushed series gateway (finer-timeframe bars for data-driven drawings). */
25466
+ this.seriesGw = null;
25467
+ this.seriesGwUnsub = null;
24779
25468
  /** Last draft fingerprint reported upstream — gates the per-render emission to actual changes. */
24780
25469
  this.lastDraftKey = null;
24781
25470
  this.measure = new MeasureOverlay();
@@ -24803,7 +25492,7 @@ var UserDrawingController = class {
24803
25492
  this.textEditor = null;
24804
25493
  this.painter = new DrawingPainter();
24805
25494
  this.ctx = canvas.getContext("2d");
24806
- this.popup = new DrawingSettingsPopup(overlayHost, deps.theme());
25495
+ this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
24807
25496
  this.toolbar = new DrawingToolbar(
24808
25497
  toolbarHost,
24809
25498
  deps.theme(),
@@ -24861,6 +25550,22 @@ var UserDrawingController = class {
24861
25550
  const shown = this.toolbarVisible && !this.mobileLayout;
24862
25551
  this.deps.setToolbarGutter(shown ? this.toolbarCollapsed ? TOOLBAR_COLLAPSED_WIDTH : TOOLBAR_WIDTH : 0);
24863
25552
  }
25553
+ /** Core push: the series gateway data-driven drawings read finer-timeframe bars
25554
+ * through (surfaced to them as `Projector.seriesInRange`). A landed background
25555
+ * fetch repaints both this layer and the interleave slices under the series. */
25556
+ setSeriesGateway(gateway) {
25557
+ this.seriesGwUnsub?.();
25558
+ this.seriesGw = gateway;
25559
+ this.seriesGwUnsub = gateway.onUpdate(() => {
25560
+ this.invalidateSlices();
25561
+ this.render();
25562
+ this.deps.requestDataPaint();
25563
+ });
25564
+ }
25565
+ /** The pushed series gateway, or null before the core provides one. */
25566
+ get seriesGateway() {
25567
+ return this.seriesGw;
25568
+ }
24864
25569
  /** Core push: mirror (or clear) another chart's in-progress placement as a ghost. */
24865
25570
  setExternalGhost(doc) {
24866
25571
  this.externalGhost = doc ? deserializeDrawing(doc) : null;
@@ -24978,8 +25683,34 @@ var UserDrawingController = class {
24978
25683
  /** Should the drawing layer win this press (vs pan)? */
24979
25684
  claim(x, y) {
24980
25685
  if (this.measureMode || this.eraserMode) return true;
25686
+ if (this.magnifierChipAt(x, y)) return true;
24981
25687
  return this.interaction.claim(x, y);
24982
25688
  }
25689
+ /** The topmost visible (unlocked) magnifier whose timeframe chip contains (x, y) —
25690
+ * the chip's rect is what the painter measured last frame. */
25691
+ magnifierChipAt(x, y) {
25692
+ for (let i = this.drawings.length - 1; i >= 0; i -= 1) {
25693
+ const d = this.drawings[i];
25694
+ if (!(d instanceof Magnifier) || !d.visible || d.locked) continue;
25695
+ const r = d.chipRect;
25696
+ if (r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return d;
25697
+ }
25698
+ return null;
25699
+ }
25700
+ /** Open the on-chart chip's timeframe menu; the pick patches the drawing like a
25701
+ * settings-popup edit (same intent, same undo step). */
25702
+ openMagnifierChipMenu(drawing) {
25703
+ const rect = drawing.chipRect;
25704
+ if (!rect) return;
25705
+ const id = drawing.id;
25706
+ this.popup.openMagnifierTimeframeMenu(rect, drawing.magnifier.timeframe, (value) => {
25707
+ const d = this.drawings.find((x) => x.id === id);
25708
+ if (!(d instanceof Magnifier)) return;
25709
+ d.applySettings({ "magnifier.timeframe": value });
25710
+ this.render();
25711
+ this.emit({ kind: "edit", doc: d.serialize() });
25712
+ });
25713
+ }
24983
25714
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
24984
25715
  * Shared by the eraser (click + drag) and the middle-click shortcut. */
24985
25716
  deleteAt(x, y) {
@@ -24991,11 +25722,13 @@ var UserDrawingController = class {
24991
25722
  }
24992
25723
  /** Shift+press on the empty plot: arm the measure ruler AND start it at (x, y) in one
24993
25724
  * gesture — the equivalent of clicking the toolbar's Measure button, then pressing.
24994
- * Returns false when a mode/tool is already active (the normal press path owns it). */
24995
- beginMeasureAt(x, y) {
25725
+ * `snap` is the effective magnet (sticky mode, or Ctrl/Cmd-forced strong). Returns
25726
+ * false when a mode/tool is already active (the normal press path owns it). */
25727
+ beginMeasureAt(x, y, snap = "off") {
24996
25728
  if (this.measureMode || this.eraserMode || this.activeTool != null) return false;
24997
25729
  this.withModeIntent(() => this.toggleMeasure());
24998
- this.measure.down(x, y);
25730
+ const g = this.interaction.snapCursor(x, y, snap);
25731
+ this.measure.down(x, y, g.x, g.y);
24999
25732
  this.render();
25000
25733
  return true;
25001
25734
  }
@@ -25017,11 +25750,19 @@ var UserDrawingController = class {
25017
25750
  return;
25018
25751
  }
25019
25752
  if (this.measureMode) {
25020
- this.measure.down(x, y);
25753
+ const g = this.interaction.snapCursor(x, y, snap);
25754
+ this.measure.down(x, y, g.x, g.y);
25021
25755
  if (this.measure.isFinished()) this.withModeIntent(() => this.exitMeasure(false));
25022
25756
  this.render();
25023
25757
  return;
25024
25758
  }
25759
+ if (this.activeTool == null) {
25760
+ const chipOwner = this.magnifierChipAt(x, y);
25761
+ if (chipOwner) {
25762
+ this.openMagnifierChipMenu(chipOwner);
25763
+ return;
25764
+ }
25765
+ }
25025
25766
  this.interaction.down(x, y, snap, shift);
25026
25767
  }
25027
25768
  pointerMove(x, y, snap = "off", shift = false) {
@@ -25030,7 +25771,8 @@ var UserDrawingController = class {
25030
25771
  return;
25031
25772
  }
25032
25773
  if (this.measureMode) {
25033
- this.measure.move(x, y);
25774
+ const g = this.interaction.snapCursor(x, y, snap);
25775
+ this.measure.move(g.x, g.y);
25034
25776
  this.render();
25035
25777
  return;
25036
25778
  }
@@ -25048,13 +25790,14 @@ var UserDrawingController = class {
25048
25790
  this.render();
25049
25791
  }
25050
25792
  }
25051
- pointerUp(x, y) {
25793
+ pointerUp(x, y, snap = "off") {
25052
25794
  if (this.eraserMode) {
25053
25795
  this.erasing = false;
25054
25796
  return;
25055
25797
  }
25056
25798
  if (this.measureMode) {
25057
- this.measure.up(x, y);
25799
+ const g = this.interaction.snapCursor(x, y, snap);
25800
+ this.measure.up(x, y, g.x, g.y);
25058
25801
  if (this.measure.isFinished()) this.withModeIntent(() => this.exitMeasure(false));
25059
25802
  this.render();
25060
25803
  return;
@@ -25099,6 +25842,7 @@ var UserDrawingController = class {
25099
25842
  /** Leave ruler mode. `clearGraphic` keeps a just-finished measurement on screen (false). */
25100
25843
  exitMeasure(clearGraphic = true) {
25101
25844
  this.measureMode = false;
25845
+ this.interaction.clearSnapMarker();
25102
25846
  if (clearGraphic) this.measure.clear();
25103
25847
  this.toolbar.setMeasureActive(false);
25104
25848
  this.render();
@@ -25237,17 +25981,31 @@ var UserDrawingController = class {
25237
25981
  /** Cursor hint while hovering — `'pointer'` over a drawing/handle, else null. */
25238
25982
  cursorAt(x, y) {
25239
25983
  if (this.eraserMode) return "pointer";
25984
+ if (this.activeTool == null && this.magnifierChipAt(x, y)) return "pointer";
25240
25985
  return this.interaction.cursorAt(x, y);
25241
25986
  }
25242
- /** Right-click while placing: cancel the in-progress drawing and revert to the
25243
- * pointer the gesture is an explicit escape, so it disarms even in
25244
- * stay-in-drawing-mode (where Escape would leave the tool armed). Returns whether
25245
- * the press was consumed; false lets the host's context menu open normally. */
25987
+ /** Right-click: an explicit escape back to the pointer. Cancels an in-progress
25988
+ * placement or measurement, and also plain-disarms an armed-but-idle drawing
25989
+ * tool or the eraser — so a right-click ALWAYS reverts to the pointer, even in
25990
+ * stay-in-drawing-mode (where Escape would leave a drawing tool armed).
25991
+ * Persistent toggles (magnet, stay-mode, favorites) are untouched. Returns
25992
+ * whether the press was consumed; false lets the host's context menu open
25993
+ * normally. */
25246
25994
  cancelPlacement() {
25247
- if (!this.interaction.isPlacing()) return false;
25248
- this.interaction.cancel();
25249
- if (this.activeTool != null) this.emit({ kind: "arm", type: null });
25250
- return true;
25995
+ if (this.measureMode || this.eraserMode) {
25996
+ this.withModeIntent(() => this.measureMode ? this.exitMeasure() : this.exitEraser());
25997
+ return true;
25998
+ }
25999
+ if (this.interaction.isPlacing()) {
26000
+ this.interaction.cancel();
26001
+ if (this.activeTool != null) this.emit({ kind: "arm", type: null });
26002
+ return true;
26003
+ }
26004
+ if (this.activeTool != null) {
26005
+ this.emit({ kind: "arm", type: null });
26006
+ return true;
26007
+ }
26008
+ return false;
25251
26009
  }
25252
26010
  /** Double-click over a drawing → suppress the chart's view reset (single-click already
25253
26011
  * opens settings). Returns true only when a drawing is under the cursor. */
@@ -25273,6 +26031,10 @@ var UserDrawingController = class {
25273
26031
  return true;
25274
26032
  }
25275
26033
  if (this.interaction.cancel()) return true;
26034
+ if (this.measureMode) {
26035
+ this.withModeIntent(() => this.exitMeasure());
26036
+ return true;
26037
+ }
25276
26038
  if (this.selectedIds.size) {
25277
26039
  this.clearSelection();
25278
26040
  return true;
@@ -25398,6 +26160,7 @@ var UserDrawingController = class {
25398
26160
  if (!sctx) continue;
25399
26161
  sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
25400
26162
  sctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
26163
+ this.painter.seriesLook = this.deps.seriesLook();
25401
26164
  this.painter.paintAll(sctx, drawings, proj, theme, EMPTY_TARGETS);
25402
26165
  const slices = out.get(paneId) ?? [];
25403
26166
  slices.push({ beforeZ, canvas });
@@ -25425,6 +26188,7 @@ var UserDrawingController = class {
25425
26188
  dragged: this.interaction.activeDragId(),
25426
26189
  mutedLabel: edited instanceof TextLabel ? edited.id : null
25427
26190
  };
26191
+ this.painter.seriesLook = this.deps.seriesLook();
25428
26192
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
25429
26193
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
25430
26194
  this.layoutTextEditor();
@@ -25432,6 +26196,10 @@ var UserDrawingController = class {
25432
26196
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
25433
26197
  if (this.externalGhost) this.painter.paintGhost(ctx, this.externalGhost, proj, this.deps.theme());
25434
26198
  this.emitDraft(ghost);
26199
+ if (this.activeTool && !ghost) {
26200
+ const hint = getDrawingType(this.activeTool)?.placementHint;
26201
+ if (hint) this.painter.paintPlacementHint(ctx, hint, this.deps.theme(), proj.width, proj.height);
26202
+ }
25435
26203
  const markers = this.interaction.placingMarkers(proj);
25436
26204
  if (markers) this.painter.paintHandles(ctx, markers);
25437
26205
  const m = this.interaction.snapMarker();
@@ -25443,6 +26211,9 @@ var UserDrawingController = class {
25443
26211
  this.closeTextEditor();
25444
26212
  this.popup.destroy();
25445
26213
  this.toolbar.destroy();
26214
+ this.seriesGwUnsub?.();
26215
+ this.seriesGwUnsub = null;
26216
+ this.seriesGw = null;
25446
26217
  this.intentCb = null;
25447
26218
  this.drawings = [];
25448
26219
  }
@@ -25840,7 +26611,7 @@ function mergeSlices(indicator, user) {
25840
26611
  }
25841
26612
 
25842
26613
  // src/renderers/native/drawings/Projector.ts
25843
- function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
26614
+ function createProjector(coords, paneOf, paneIdAtY, barsInRange, seriesInRange) {
25844
26615
  return {
25845
26616
  xOf: (time) => coords.timeToX(time),
25846
26617
  yOf: (price, paneId) => {
@@ -25861,6 +26632,7 @@ function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
25861
26632
  },
25862
26633
  barsBetween: (t1, t2) => Math.abs(coords.timeToLogical(t2) - coords.timeToLogical(t1)),
25863
26634
  barsInRange: barsInRange ? (from, to) => barsInRange(from, to) : void 0,
26635
+ seriesInRange,
25864
26636
  width: coords.width,
25865
26637
  height: coords.height
25866
26638
  };
@@ -27831,13 +28603,13 @@ var NativeRenderer = class {
27831
28603
  resetView: () => this.resetView(),
27832
28604
  // User drawings claim a gesture before pan when armed / over a drawing.
27833
28605
  drawingsClaim: (x, y) => this.userDrawings?.claim(x, y) ?? false,
27834
- drawingsMeasureStart: (x, y) => this.userDrawings?.beginMeasureAt(x, y) ?? false,
28606
+ drawingsMeasureStart: (x, y, snap) => this.userDrawings?.beginMeasureAt(x, y, snap) ?? false,
27835
28607
  drawingsDeleteAt: (x, y) => this.userDrawings?.deleteAt(x, y) ?? false,
27836
28608
  drawingsCancelPlacement: () => this.userDrawings?.cancelPlacement() ?? false,
27837
28609
  drawingsSnapMode: () => this.snapMode,
27838
28610
  drawingsPointerDown: (x, y, snap, shift) => this.userDrawings?.pointerDown(x, y, snap, shift),
27839
28611
  drawingsPointerMove: (x, y, snap, shift) => this.userDrawings?.pointerMove(x, y, snap, shift),
27840
- drawingsPointerUp: (x, y) => this.userDrawings?.pointerUp(x, y),
28612
+ drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
27841
28613
  drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
27842
28614
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
27843
28615
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
@@ -27864,6 +28636,23 @@ var NativeRenderer = class {
27864
28636
  seriesBoundaries: (paneId) => this.scene.seriesBoundaries(paneId),
27865
28637
  priceZ: (paneId) => paneId === PRICE_PANE_ID ? this.scene.candleZ : null,
27866
28638
  requestDataPaint: () => this.scheduler.invalidate(3 /* Light */),
28639
+ // The look the price series ACTUALLY paints with: candle colors resolved through
28640
+ // the per-style override, line/area colors through their configured styles — so
28641
+ // series-mirroring content (the magnifier inset) matches the chart exactly.
28642
+ seriesLook: () => {
28643
+ const st = this.scene.style;
28644
+ const paint = effectiveCandlePaint(st.candle, this.scene.candleOverride, this.theme.upColor, this.theme.downColor);
28645
+ const barsUp = st.bars.upColor ?? this.theme.upColor;
28646
+ const barsDown = st.bars.downColor ?? this.theme.downColor;
28647
+ const style = this.scene.priceStyle;
28648
+ return {
28649
+ style,
28650
+ upColor: style === "bars" ? barsUp : paint.up,
28651
+ downColor: style === "bars" ? barsDown : paint.down,
28652
+ lineColor: style === "area" ? st.area.lineColor ?? this.theme.upColor : st.line.color ?? this.theme.upColor
28653
+ };
28654
+ },
28655
+ chartBarMs: () => this.coords.barInterval,
27867
28656
  snap: (pt, paneId, mode, cursorPx) => this.snapToCandle(pt, paneId, mode, cursorPx),
27868
28657
  setSnapMode: (mode) => this.setSnapMode(mode),
27869
28658
  setToolbarGutter: (px) => this.setToolbarGutter(px)
@@ -28152,7 +28941,8 @@ var NativeRenderer = class {
28152
28941
  }
28153
28942
  const skipFit = opts?.preserveView === true && this.didInitialFit;
28154
28943
  if (this.coords.width > 0 && !skipFit) {
28155
- this.fitContent();
28944
+ if (this.didInitialFit) this.reframeKeepZoom();
28945
+ else this.fitContent();
28156
28946
  this.didInitialFit = true;
28157
28947
  }
28158
28948
  if (!this.introPlayed && this.bars.length > 0) {
@@ -28800,6 +29590,7 @@ var NativeRenderer = class {
28800
29590
  this.scaleDragHeight = res.height;
28801
29591
  this.scaleDragStart = { ...res.holder.scale };
28802
29592
  res.holder.manualScale = { ...res.holder.scale };
29593
+ this.axisScaleButtons?.reposition();
28803
29594
  this.scheduler.invalidate(4 /* Full */);
28804
29595
  }
28805
29596
  /** Rescale the grabbed scale around its center by the total drag (down ⇒ zoom out). */
@@ -28831,6 +29622,7 @@ var NativeRenderer = class {
28831
29622
  const res = this.resolveScaleHolder(x, y);
28832
29623
  if (!res) return;
28833
29624
  res.holder.manualScale = null;
29625
+ this.axisScaleButtons?.reposition();
28834
29626
  this.scheduler.invalidate(4 /* Full */);
28835
29627
  }
28836
29628
  /**
@@ -29254,7 +30046,8 @@ var NativeRenderer = class {
29254
30046
  return p ? { scale: p.scale, bounds: p.bounds, collapsed: p.collapsed } : null;
29255
30047
  },
29256
30048
  (y) => this.paneNodeAtY(y)?.id ?? null,
29257
- (from, to) => this.barsInTimeRange(from, to)
30049
+ (from, to) => this.barsInTimeRange(from, to),
30050
+ this.userDrawings?.seriesGateway ? (tf, from, to) => this.userDrawings.seriesGateway.seriesInRange(tf, from, to) : void 0
29258
30051
  );
29259
30052
  }
29260
30053
  /** OHLC bars whose open-time falls within `[from, to]` (inclusive) — the data a regression
@@ -29468,6 +30261,20 @@ var NativeRenderer = class {
29468
30261
  this.coords.setViewport(v);
29469
30262
  this.targetBarSpacing = v.barSpacing;
29470
30263
  }
30264
+ /** Re-frame after a series replacement (a symbol/timeframe switch): keep the user's
30265
+ * zoom (bar spacing), re-anchor the newest bars at the default right offset.
30266
+ * `clampViewport`'s fit-all-bars floor deliberately does NOT apply — a progressive
30267
+ * head may still be backfilling toward the previous depth, and raising the spacing
30268
+ * to its temporary bar count would lose the zoom this exists to keep. */
30269
+ reframeKeepZoom() {
30270
+ this.animator?.stop();
30271
+ this.panVelocity = 0;
30272
+ for (const pane of this.scene.panes.values()) pane.manualScale = null;
30273
+ for (const sl of this.scene.indicatorScales.values()) sl.manualScale = null;
30274
+ const v = { barSpacing: clampBarSpacing(this.coords.getViewport().barSpacing), rightOffset: defaultViewport().rightOffset };
30275
+ this.coords.setViewport(v);
30276
+ this.targetBarSpacing = v.barSpacing;
30277
+ }
29471
30278
  paneBoundsFor(paneId) {
29472
30279
  const p = this.scene.panes.get(paneId);
29473
30280
  return { top: p?.bounds.top ?? 0, height: p?.bounds.height ?? 0, rightAxis: this.rightAxisW };
@@ -30662,6 +31469,7 @@ exports.drawingTypes = drawingTypes;
30662
31469
  exports.getDrawingType = getDrawingType;
30663
31470
  exports.getNativeIndicator = getNativeIndicator;
30664
31471
  exports.iconMarkup = iconMarkup;
31472
+ exports.inputDeltas = inputDeltas;
30665
31473
  exports.inputVisible = inputVisible;
30666
31474
  exports.legendActions = legendActions;
30667
31475
  exports.legendCallouts = legendCallouts;