@luxalgo/vela 0.6.11 → 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 (41) hide show
  1. package/README.md +110 -46
  2. package/dist/{DataProvider-BBf-jc6W.d.ts → DataProvider-DhzstpQb.d.ts} +1 -1
  3. package/dist/{DataProvider-p0TEyhlX.d.cts → DataProvider-DlMtrwqM.d.cts} +1 -1
  4. package/dist/{chunk-MTLJKZDZ.js → chunk-F4M24ANM.js} +122 -1
  5. package/dist/{chunk-IO3NYSQV.js → chunk-IZV3CW5N.js} +184 -25
  6. package/dist/{chunk-73PEA4MU.js → chunk-KV7FDWSL.js} +618 -38
  7. package/dist/{contributions-Bbe2R-mQ.d.ts → contributions-37nni40G.d.ts} +6 -4
  8. package/dist/{contributions-CO01zWve.d.cts → contributions-lPojhTxI.d.cts} +6 -4
  9. package/dist/index.cjs +727 -26
  10. package/dist/index.d.cts +6 -6
  11. package/dist/index.d.ts +6 -6
  12. package/dist/index.js +2 -2
  13. package/dist/{options-yp7sA96q.d.cts → options-CYS5Wmlx.d.cts} +71 -2
  14. package/dist/{options-yp7sA96q.d.ts → options-CYS5Wmlx.d.ts} +71 -2
  15. package/dist/{plugin-CkkH8QnX.d.cts → plugin-Cwikpz1m.d.cts} +10 -3
  16. package/dist/{plugin-DwxjM3Ni.d.ts → plugin-DHyaoMjW.d.ts} +10 -3
  17. package/dist/plugin.cjs +110 -0
  18. package/dist/plugin.d.cts +4 -4
  19. package/dist/plugin.d.ts +4 -4
  20. package/dist/plugin.js +1 -1
  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-CHPDuKNp.d.ts → statusline-model-CiJm-riV.d.cts} +7 -85
  28. package/dist/{statusline-DTHZUFqK.d.cts → statusline-model-D-q_OOx9.d.ts} +7 -85
  29. package/dist/ui.d.cts +1 -1
  30. package/dist/ui.d.ts +1 -1
  31. package/dist/vela.global.js +727 -26
  32. package/dist/vela.global.min.js +52 -52
  33. package/dist/widget.cjs +910 -50
  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 +910 -50
  38. package/dist/workspace.d.cts +5 -5
  39. package/dist/workspace.d.ts +5 -5
  40. package/dist/workspace.js +3 -3
  41. package/package.json +1 -1
@@ -5400,6 +5400,111 @@ var Vela = (function (exports) {
5400
5400
  return Math.round(n * 1e8) / 1e8;
5401
5401
  }
5402
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
+
5403
5508
  // src/core/drawings/registry.ts
5404
5509
  var REGISTRY2 = /* @__PURE__ */ new Map();
5405
5510
  function registerDrawingType(meta) {
@@ -6059,6 +6164,22 @@ var Vela = (function (exports) {
6059
6164
  defaultStyle: { lineColor: DEFAULT_DRAWING_COLOR, lineWidth: 1, lineStyle: "solid" },
6060
6165
  create: (init) => new PositionTool(init)
6061
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
+ });
6062
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"/>');
6063
6184
  registerDrawingType({
6064
6185
  type: "anchoredvwap",
@@ -6098,7 +6219,7 @@ var Vela = (function (exports) {
6098
6219
  var PATTERN_TYPES = ["xabcd", "abcd", "headshoulders"];
6099
6220
  var ELLIOTT_TYPES = ["elliottimpulse", "elliottcorrection"];
6100
6221
  var HARMONIC_TYPES = ["gartley", "bat", "butterfly", "crab", "shark", "cypher"];
6101
- var MEASUREMENT_TYPES = ["position", "datepricerange"];
6222
+ var MEASUREMENT_TYPES = ["position", "datepricerange", "magnifier"];
6102
6223
  var VOLUME_TYPES = ["anchoredvwap", "fixedrangevp"];
6103
6224
  var BRUSH_TYPES = ["freehand", "highlighter"];
6104
6225
  var ARROW_TYPES = ["arrow", "arrowmarkup", "arrowmarkdown"];
@@ -6462,7 +6583,7 @@ var Vela = (function (exports) {
6462
6583
 
6463
6584
  // src/core/drawings/DrawingController.ts
6464
6585
  var DrawingController = class {
6465
- constructor(renderer, events, option) {
6586
+ constructor(renderer, events, option, seriesGateway) {
6466
6587
  this.events = events;
6467
6588
  this.store = new DrawingStore();
6468
6589
  this.history = new DrawingHistory();
@@ -6488,6 +6609,7 @@ var Vela = (function (exports) {
6488
6609
  const { definition, visible } = buildToolbar(option);
6489
6610
  this.port.setToolbar(definition);
6490
6611
  this.port.showToolbar(visible);
6612
+ if (seriesGateway) this.port.setSeriesGateway?.(seriesGateway);
6491
6613
  this.subs.push(this.port.onDrawingIntent((i) => this.onIntent(i)));
6492
6614
  this.subs.push(this.store.onChange(() => this.sync()));
6493
6615
  }
@@ -6593,7 +6715,7 @@ var Vela = (function (exports) {
6593
6715
  style,
6594
6716
  text: init.text,
6595
6717
  props: init.props,
6596
- zIndex: init.zIndex ?? this.startZ(init.paneId ?? "price")
6718
+ zIndex: init.zIndex ?? this.startZ(type, init.paneId ?? "price")
6597
6719
  });
6598
6720
  if (!d) return null;
6599
6721
  this.history.record(this.store.serialize());
@@ -6668,10 +6790,14 @@ var Vela = (function (exports) {
6668
6790
  * (falling back to just under the pane's top series where there is no price — a study
6669
6791
  * pane). Half a key down never ties a series; drawings tying each other paint in insertion
6670
6792
  * order, so consecutive new drawings still stack newest-in-front. Undefined without a
6671
- * shared z space — the store then places it over the other drawings, its own layer's top. */
6672
- 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) {
6673
6797
  const range = this.port?.stackRange?.(paneId);
6674
- 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;
6675
6801
  }
6676
6802
  /** Programmatically select drawings (host UI → chart): shows the on-chart handles + toolbar.
6677
6803
  * `additive` toggles membership (matching shift-click) instead of replacing. */
@@ -6816,7 +6942,7 @@ var Vela = (function (exports) {
6816
6942
  const style = last2 ? { ...i.doc.style, ...last2 } : i.doc.style;
6817
6943
  const d = deserializeDrawing({ ...i.doc, id: this.store.nextId(), style });
6818
6944
  if (!d) return;
6819
- if (!d.zIndex) d.zIndex = this.startZ(d.paneId) ?? 0;
6945
+ if (!d.zIndex) d.zIndex = this.startZ(d.type, d.paneId) ?? 0;
6820
6946
  this.history.record(before);
6821
6947
  this.store.add(d);
6822
6948
  this.captureStyle(d.id);
@@ -6914,6 +7040,180 @@ var Vela = (function (exports) {
6914
7040
  return 36e5;
6915
7041
  }
6916
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
+
6917
7217
  // src/data/symbol-groups.ts
6918
7218
  function isGroupRow(d) {
6919
7219
  return d.group != null && d.ticker === d.group;
@@ -7396,7 +7696,13 @@ var Vela = (function (exports) {
7396
7696
  const initialStyle = this.renderer.readFeature("priceStyle");
7397
7697
  if (typeof initialStyle === "string") this.priceStyle = initialStyle;
7398
7698
  this.barTransform = barTransformFor(initialStyle);
7399
- 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);
7400
7706
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7401
7707
  this.endLoad();
7402
7708
  this.events.emit("data:unresolved", info);
@@ -8389,8 +8695,8 @@ var Vela = (function (exports) {
8389
8695
  onModel: (model) => {
8390
8696
  const first2 = !record.announced;
8391
8697
  const cause = record.pendingCause ?? "history";
8698
+ if (!this.applyModel(id, model)) return;
8392
8699
  record.pendingCause = void 0;
8393
- this.applyModel(id, model);
8394
8700
  this.emitContextChanged(id);
8395
8701
  this.emitScriptRun(id, cause, first2);
8396
8702
  },
@@ -8683,10 +8989,16 @@ var Vela = (function (exports) {
8683
8989
  * Apply an emitted model. First emission mounts (and routes the pane); a pending
8684
8990
  * structural change (after an input edit) remounts idempotently; everything else
8685
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.
8686
8997
  */
8687
8998
  applyModel(id, model) {
8688
8999
  const record = this.registry.get(id);
8689
- 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;
8690
9002
  const handle = this.handles.get(id);
8691
9003
  if (!record.renderHandle) {
8692
9004
  const paneId2 = this.routePane(id, model, record.options ?? {});
@@ -8696,7 +9008,7 @@ var Vela = (function (exports) {
8696
9008
  record.renderHandle = this.renderer.mountIndicator(model);
8697
9009
  record.pendingStructural = false;
8698
9010
  this.announce(record, handle);
8699
- return;
9011
+ return true;
8700
9012
  }
8701
9013
  let paneId = record.model?.paneId ?? "price";
8702
9014
  const prevOwnScale = record.model?.ownScale === true;
@@ -8724,6 +9036,11 @@ var Vela = (function (exports) {
8724
9036
  }
8725
9037
  if (record.loading) this.setLoading(record, false);
8726
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;
8727
9044
  }
8728
9045
  routePane(id, model, options) {
8729
9046
  if (options.pane === "new") return `pane-${id}`;
@@ -9426,6 +9743,7 @@ var Vela = (function (exports) {
9426
9743
  "4h": 144e5,
9427
9744
  "1d": 864e5,
9428
9745
  "1w": 6048e5,
9746
+ "1M": 2592e6,
9429
9747
  "1": 6e4,
9430
9748
  "5": 3e5,
9431
9749
  "15": 9e5,
@@ -9433,7 +9751,8 @@ var Vela = (function (exports) {
9433
9751
  "60": 36e5,
9434
9752
  "240": 144e5,
9435
9753
  D: 864e5,
9436
- W: 6048e5
9754
+ W: 6048e5,
9755
+ M: 2592e6
9437
9756
  };
9438
9757
  return map[timeframe] ?? 36e5;
9439
9758
  }
@@ -26060,7 +26379,9 @@ void main() {
26060
26379
  const lo = Math.min(from, to);
26061
26380
  const hi = Math.max(from, to);
26062
26381
  const visible = (a, b, extend) => {
26063
- 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;
26064
26385
  return Math.max(a, b) >= lo && Math.min(a, b) <= hi;
26065
26386
  };
26066
26387
  let min2 = Infinity;
@@ -28624,6 +28945,17 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28624
28945
  constructor() {
28625
28946
  /** The current `paintAll` call's interaction state, visible to the per-type painters. */
28626
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
+ };
28627
28959
  }
28628
28960
  /** Paint every visible drawing, then selection handles for the targeted ones.
28629
28961
  * Each drawing is clipped to its own pane's rect (and skipped entirely while that pane
@@ -28668,6 +29000,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28668
29000
  ctx.globalAlpha = GHOST_ALPHA;
28669
29001
  if (ghost instanceof RegressionChannel || ghost instanceof FixedRangeVolumeProfile) {
28670
29002
  this.paintTimeSpanGhost(ctx, ghost, proj);
29003
+ } else if (ghost instanceof Magnifier) {
29004
+ this.paintMagnifierGhost(ctx, ghost, proj, theme);
28671
29005
  } else this.paintOne(ctx, ghost, proj, theme);
28672
29006
  ctx.globalAlpha = 1;
28673
29007
  }
@@ -28801,6 +29135,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28801
29135
  this.paintLabel(ctx, d, proj, theme);
28802
29136
  return;
28803
29137
  }
29138
+ if (d instanceof Magnifier) {
29139
+ this.paintMagnifier(ctx, d, proj, theme);
29140
+ return;
29141
+ }
28804
29142
  if (d instanceof PatternDrawing) {
28805
29143
  this.paintPattern(ctx, d, proj, theme);
28806
29144
  return;
@@ -29293,6 +29631,216 @@ ${overlayScrollbarCss(".vela-sd-pane")}
29293
29631
  ctx.textBaseline = "alphabetic";
29294
29632
  }
29295
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
+ }
29296
29844
  /** Paint a fixed-range volume profile: horizontal histogram rows (up/down split) anchored to
29297
29845
  * the left or right of the time span, optional VAH / VAL / POC levels across the range, and
29298
29846
  * optional developing POC / VA polylines. Recomputes from the two anchors on every paint. */
@@ -30708,8 +31256,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30708
31256
  if (!existing) document.head.appendChild(s);
30709
31257
  }
30710
31258
  var DrawingSettingsPopup = class {
30711
- constructor(host, theme) {
31259
+ constructor(host, theme, chartBarMs = () => 0) {
30712
31260
  this.host = host;
31261
+ this.chartBarMs = chartBarMs;
30713
31262
  this.el = null;
30714
31263
  this.tipEl = null;
30715
31264
  // floating hover-label (above/below the toolbar)
@@ -30734,6 +31283,14 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30734
31283
  this.theme = theme;
30735
31284
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
30736
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
+ }
30737
31294
  setTheme(theme) {
30738
31295
  this.theme = theme;
30739
31296
  this.settingsDialog.setTheme(theme);
@@ -30776,7 +31333,20 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30776
31333
  const sz = drawing.size ?? "normal";
30777
31334
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
30778
31335
  }
30779
- 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 })));
30780
31350
  if (paths.has("style.lineWidth")) {
30781
31351
  const wf = schema.fields.find((f) => f.path === "style.lineWidth");
30782
31352
  if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
@@ -31098,6 +31668,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31098
31668
  this.colorPop = null;
31099
31669
  this.colorOwner = null;
31100
31670
  }
31671
+ opts.onClose?.();
31101
31672
  }
31102
31673
  });
31103
31674
  const el = pop.el;
@@ -31116,6 +31687,49 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31116
31687
  pop.show();
31117
31688
  return pop;
31118
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
+ }
31119
31733
  /** A floating list of one-shot actions (icon + label rows) opened by the kebab. */
31120
31734
  openActionMenu(anchor, rows) {
31121
31735
  this.menuPop = this.hostFloat(anchor, {
@@ -31226,10 +31840,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31226
31840
  let cur = current;
31227
31841
  const paint = (v) => {
31228
31842
  b.replaceChildren();
31229
- const ic = document.createElement("span");
31230
- ic.style.cssText = "display:flex;";
31231
- ic.innerHTML = sized(render(v));
31232
- 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
+ }
31233
31850
  if (opts.label && opts.labelInTrigger) {
31234
31851
  const tx = document.createElement("span");
31235
31852
  tx.textContent = opts.label(v);
@@ -31271,10 +31888,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31271
31888
  item.className = "vela-dpop-item";
31272
31889
  item.dataset.active = active ? "1" : "0";
31273
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;`;
31274
- const ic = document.createElement("span");
31275
- ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
31276
- ic.innerHTML = sized(render(v), 18);
31277
- 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
+ }
31278
31898
  if (label) {
31279
31899
  const tx = document.createElement("span");
31280
31900
  tx.textContent = label(v);
@@ -32258,6 +32878,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32258
32878
  this.intentCb = null;
32259
32879
  /** Another chart's in-progress placement, mirrored here as a ghost (drawings sync). */
32260
32880
  this.externalGhost = null;
32881
+ /** Core-pushed series gateway (finer-timeframe bars for data-driven drawings). */
32882
+ this.seriesGw = null;
32883
+ this.seriesGwUnsub = null;
32261
32884
  /** Last draft fingerprint reported upstream — gates the per-render emission to actual changes. */
32262
32885
  this.lastDraftKey = null;
32263
32886
  this.measure = new MeasureOverlay();
@@ -32285,7 +32908,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32285
32908
  this.textEditor = null;
32286
32909
  this.painter = new DrawingPainter();
32287
32910
  this.ctx = canvas.getContext("2d");
32288
- this.popup = new DrawingSettingsPopup(overlayHost, deps.theme());
32911
+ this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
32289
32912
  this.toolbar = new DrawingToolbar(
32290
32913
  toolbarHost,
32291
32914
  deps.theme(),
@@ -32343,6 +32966,22 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32343
32966
  const shown = this.toolbarVisible && !this.mobileLayout;
32344
32967
  this.deps.setToolbarGutter(shown ? this.toolbarCollapsed ? TOOLBAR_COLLAPSED_WIDTH : TOOLBAR_WIDTH : 0);
32345
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
+ }
32346
32985
  /** Core push: mirror (or clear) another chart's in-progress placement as a ghost. */
32347
32986
  setExternalGhost(doc) {
32348
32987
  this.externalGhost = doc ? deserializeDrawing(doc) : null;
@@ -32460,8 +33099,34 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32460
33099
  /** Should the drawing layer win this press (vs pan)? */
32461
33100
  claim(x, y) {
32462
33101
  if (this.measureMode || this.eraserMode) return true;
33102
+ if (this.magnifierChipAt(x, y)) return true;
32463
33103
  return this.interaction.claim(x, y);
32464
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
+ }
32465
33130
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
32466
33131
  * Shared by the eraser (click + drag) and the middle-click shortcut. */
32467
33132
  deleteAt(x, y) {
@@ -32507,6 +33172,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32507
33172
  this.render();
32508
33173
  return;
32509
33174
  }
33175
+ if (this.activeTool == null) {
33176
+ const chipOwner = this.magnifierChipAt(x, y);
33177
+ if (chipOwner) {
33178
+ this.openMagnifierChipMenu(chipOwner);
33179
+ return;
33180
+ }
33181
+ }
32510
33182
  this.interaction.down(x, y, snap, shift3);
32511
33183
  }
32512
33184
  pointerMove(x, y, snap = "off", shift3 = false) {
@@ -32725,6 +33397,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32725
33397
  /** Cursor hint while hovering — `'pointer'` over a drawing/handle, else null. */
32726
33398
  cursorAt(x, y) {
32727
33399
  if (this.eraserMode) return "pointer";
33400
+ if (this.activeTool == null && this.magnifierChipAt(x, y)) return "pointer";
32728
33401
  return this.interaction.cursorAt(x, y);
32729
33402
  }
32730
33403
  /** Right-click: an explicit escape back to the pointer. Cancels an in-progress
@@ -32903,6 +33576,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32903
33576
  if (!sctx) continue;
32904
33577
  sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32905
33578
  sctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
33579
+ this.painter.seriesLook = this.deps.seriesLook();
32906
33580
  this.painter.paintAll(sctx, drawings, proj, theme, EMPTY_TARGETS);
32907
33581
  const slices = out.get(paneId) ?? [];
32908
33582
  slices.push({ beforeZ, canvas });
@@ -32930,6 +33604,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32930
33604
  dragged: this.interaction.activeDragId(),
32931
33605
  mutedLabel: edited instanceof TextLabel ? edited.id : null
32932
33606
  };
33607
+ this.painter.seriesLook = this.deps.seriesLook();
32933
33608
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
32934
33609
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
32935
33610
  this.layoutTextEditor();
@@ -32937,6 +33612,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32937
33612
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
32938
33613
  if (this.externalGhost) this.painter.paintGhost(ctx, this.externalGhost, proj, this.deps.theme());
32939
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
+ }
32940
33619
  const markers = this.interaction.placingMarkers(proj);
32941
33620
  if (markers) this.painter.paintHandles(ctx, markers);
32942
33621
  const m = this.interaction.snapMarker();
@@ -32948,6 +33627,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32948
33627
  this.closeTextEditor();
32949
33628
  this.popup.destroy();
32950
33629
  this.toolbar.destroy();
33630
+ this.seriesGwUnsub?.();
33631
+ this.seriesGwUnsub = null;
33632
+ this.seriesGw = null;
32951
33633
  this.intentCb = null;
32952
33634
  this.drawings = [];
32953
33635
  }
@@ -33345,7 +34027,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
33345
34027
  }
33346
34028
 
33347
34029
  // src/renderers/native/drawings/Projector.ts
33348
- function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
34030
+ function createProjector(coords, paneOf, paneIdAtY, barsInRange, seriesInRange) {
33349
34031
  return {
33350
34032
  xOf: (time) => coords.timeToX(time),
33351
34033
  yOf: (price, paneId) => {
@@ -33366,6 +34048,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
33366
34048
  },
33367
34049
  barsBetween: (t1, t2) => Math.abs(coords.timeToLogical(t2) - coords.timeToLogical(t1)),
33368
34050
  barsInRange: barsInRange ? (from, to) => barsInRange(from, to) : void 0,
34051
+ seriesInRange,
33369
34052
  width: coords.width,
33370
34053
  height: coords.height
33371
34054
  };
@@ -35369,6 +36052,23 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35369
36052
  seriesBoundaries: (paneId) => this.scene.seriesBoundaries(paneId),
35370
36053
  priceZ: (paneId) => paneId === PRICE_PANE_ID ? this.scene.candleZ : null,
35371
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,
35372
36072
  snap: (pt2, paneId, mode, cursorPx) => this.snapToCandle(pt2, paneId, mode, cursorPx),
35373
36073
  setSnapMode: (mode) => this.setSnapMode(mode),
35374
36074
  setToolbarGutter: (px) => this.setToolbarGutter(px)
@@ -36762,7 +37462,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36762
37462
  return p ? { scale: p.scale, bounds: p.bounds, collapsed: p.collapsed } : null;
36763
37463
  },
36764
37464
  (y) => this.paneNodeAtY(y)?.id ?? null,
36765
- (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
36766
37467
  );
36767
37468
  }
36768
37469
  /** OHLC bars whose open-time falls within `[from, to]` (inclusive) — the data a regression