@luxalgo/vela 0.6.11 → 0.6.13

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-G52XAKZY.js} +184 -25
  6. package/dist/{chunk-73PEA4MU.js → chunk-JKGA36ZM.js} +635 -49
  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 +744 -37
  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 +744 -37
  32. package/dist/vela.global.min.js +52 -52
  33. package/dist/widget.cjs +927 -61
  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 +927 -61
  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;
@@ -7278,7 +7578,6 @@ var Vela = (function (exports) {
7278
7578
  var PREVIEW_BARS = 300;
7279
7579
  var SINGLE_LOAD_BARS = 5e3;
7280
7580
  var CHUNK_BARS = 1e4;
7281
- var FIRST_PAINT_BARS = 100;
7282
7581
  var GAP_FACTOR = 1.5;
7283
7582
  var HEAL_COOLDOWN_MS = 5e3;
7284
7583
  var EngineOrchestrator = class _EngineOrchestrator {
@@ -7396,7 +7695,13 @@ var Vela = (function (exports) {
7396
7695
  const initialStyle = this.renderer.readFeature("priceStyle");
7397
7696
  if (typeof initialStyle === "string") this.priceStyle = initialStyle;
7398
7697
  this.barTransform = barTransformFor(initialStyle);
7399
- this.drawings = new DrawingController(this.renderer, this.events, config.drawings);
7698
+ const drawingSeries = new DrawingSeriesService({
7699
+ fetchBars: (tf, range) => this.fetchSeries(this.config.market.symbol ?? "", tf, range),
7700
+ canFetch: () => !!this.feed.loadRange && !this.config.market.data?.length && !!this.config.market.symbol,
7701
+ chartTimeframe: () => this.config.market.timeframe ?? "60",
7702
+ marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
7703
+ });
7704
+ this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
7400
7705
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7401
7706
  this.endLoad();
7402
7707
  this.events.emit("data:unresolved", info);
@@ -7548,7 +7853,6 @@ var Vela = (function (exports) {
7548
7853
  let painted = false;
7549
7854
  const paint = (bars, final) => {
7550
7855
  if (this.generation !== gen || !final && bars.length === 0) return;
7551
- if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
7552
7856
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
7553
7857
  if (!painted && bars.length > 0) {
7554
7858
  painted = true;
@@ -7567,10 +7871,14 @@ var Vela = (function (exports) {
7567
7871
  }
7568
7872
  };
7569
7873
  abort.signal.addEventListener("abort", () => signal(true), { once: true });
7570
- this.feed.loadProgressive(market, (bars) => {
7571
- paint(bars, false);
7572
- if (painted) signal(true);
7573
- }, { signal: abort.signal }).then((full) => {
7874
+ this.feed.loadProgressive(
7875
+ market,
7876
+ (bars) => {
7877
+ paint(bars, false);
7878
+ if (painted) signal(true);
7879
+ },
7880
+ { signal: abort.signal }
7881
+ ).then((full) => {
7574
7882
  if (this.progressiveAbort === abort) this.progressiveAbort = null;
7575
7883
  if (full == null) return signal(false);
7576
7884
  if (this.generation !== gen) return signal(true);
@@ -7657,7 +7965,14 @@ var Vela = (function (exports) {
7657
7965
  * an in-flight `setMarket` immediately (the config mutates before the load). */
7658
7966
  marketSnapshot() {
7659
7967
  const m = this.config.market;
7660
- return { symbol: m.symbol, provider: parseSymbol(m.symbol ?? "").provider ?? void 0, timeframe: m.timeframe, bars: m.bars, session: m.session, offline: m.data !== void 0 };
7968
+ return {
7969
+ symbol: m.symbol,
7970
+ provider: parseSymbol(m.symbol ?? "").provider ?? void 0,
7971
+ timeframe: m.timeframe,
7972
+ bars: m.bars,
7973
+ session: m.session,
7974
+ offline: m.data !== void 0
7975
+ };
7661
7976
  }
7662
7977
  /**
7663
7978
  * Switch the chart's market IN PLACE — no destroy/recreate. The renderer stays
@@ -7723,10 +8038,7 @@ var Vela = (function (exports) {
7723
8038
  if (depthOnly) {
7724
8039
  this.extendDepth(gen, m.bars ?? 500);
7725
8040
  } else {
7726
- await Promise.race([
7727
- this.loadMarket(gen, { firstLoad: false }),
7728
- new Promise((resolve) => this.supersedeWaiters.push(resolve))
7729
- ]);
8041
+ await Promise.race([this.loadMarket(gen, { firstLoad: false }), new Promise((resolve) => this.supersedeWaiters.push(resolve))]);
7730
8042
  }
7731
8043
  if (this.generation !== gen) return;
7732
8044
  } finally {
@@ -8389,8 +8701,8 @@ var Vela = (function (exports) {
8389
8701
  onModel: (model) => {
8390
8702
  const first2 = !record.announced;
8391
8703
  const cause = record.pendingCause ?? "history";
8704
+ if (!this.applyModel(id, model)) return;
8392
8705
  record.pendingCause = void 0;
8393
- this.applyModel(id, model);
8394
8706
  this.emitContextChanged(id);
8395
8707
  this.emitScriptRun(id, cause, first2);
8396
8708
  },
@@ -8683,10 +8995,16 @@ var Vela = (function (exports) {
8683
8995
  * Apply an emitted model. First emission mounts (and routes the pane); a pending
8684
8996
  * structural change (after an input edit) remounts idempotently; everything else
8685
8997
  * (live tick / viewport re-run) value-patches.
8998
+ *
8999
+ * Returns false when the model was DEFERRED — an output-free model arriving while
9000
+ * the record is still loading and the chart has no bars (see below); every other
9001
+ * outcome, including the hidden drop, returns true so the caller's event semantics
9002
+ * stay unchanged.
8686
9003
  */
8687
9004
  applyModel(id, model) {
8688
9005
  const record = this.registry.get(id);
8689
- if (!record || record.hidden) return;
9006
+ if (!record || record.hidden) return true;
9007
+ if (record.loading && this.bars.length === 0 && !_EngineOrchestrator.modelHasOutput(model)) return false;
8690
9008
  const handle = this.handles.get(id);
8691
9009
  if (!record.renderHandle) {
8692
9010
  const paneId2 = this.routePane(id, model, record.options ?? {});
@@ -8696,7 +9014,7 @@ var Vela = (function (exports) {
8696
9014
  record.renderHandle = this.renderer.mountIndicator(model);
8697
9015
  record.pendingStructural = false;
8698
9016
  this.announce(record, handle);
8699
- return;
9017
+ return true;
8700
9018
  }
8701
9019
  let paneId = record.model?.paneId ?? "price";
8702
9020
  const prevOwnScale = record.model?.ownScale === true;
@@ -8724,6 +9042,11 @@ var Vela = (function (exports) {
8724
9042
  }
8725
9043
  if (record.loading) this.setLoading(record, false);
8726
9044
  this.announce(record, handle);
9045
+ return true;
9046
+ }
9047
+ /** True when the model carries ANY executed output — series, drawings, bar colors, or trades. */
9048
+ static modelHasOutput(model) {
9049
+ 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
9050
  }
8728
9051
  routePane(id, model, options) {
8729
9052
  if (options.pane === "new") return `pane-${id}`;
@@ -9426,6 +9749,7 @@ var Vela = (function (exports) {
9426
9749
  "4h": 144e5,
9427
9750
  "1d": 864e5,
9428
9751
  "1w": 6048e5,
9752
+ "1M": 2592e6,
9429
9753
  "1": 6e4,
9430
9754
  "5": 3e5,
9431
9755
  "15": 9e5,
@@ -9433,7 +9757,8 @@ var Vela = (function (exports) {
9433
9757
  "60": 36e5,
9434
9758
  "240": 144e5,
9435
9759
  D: 864e5,
9436
- W: 6048e5
9760
+ W: 6048e5,
9761
+ M: 2592e6
9437
9762
  };
9438
9763
  return map[timeframe] ?? 36e5;
9439
9764
  }
@@ -26060,7 +26385,9 @@ void main() {
26060
26385
  const lo = Math.min(from, to);
26061
26386
  const hi = Math.max(from, to);
26062
26387
  const visible = (a, b, extend) => {
26063
- if (extend !== "none") return true;
26388
+ if (extend === "both") return true;
26389
+ if (extend === "left") return Math.max(a, b) >= lo;
26390
+ if (extend === "right") return Math.min(a, b) <= hi;
26064
26391
  return Math.max(a, b) >= lo && Math.min(a, b) <= hi;
26065
26392
  };
26066
26393
  let min2 = Infinity;
@@ -28624,6 +28951,17 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28624
28951
  constructor() {
28625
28952
  /** The current `paintAll` call's interaction state, visible to the per-type painters. */
28626
28953
  this.targets = {};
28954
+ /** The chart's active series LOOK — style + resolved series colors — pushed by the
28955
+ * controller before each paint. The magnifier's inset mirrors both: candles/bars/line/
28956
+ * area restyle the paint (bar-transform styles like Heikin Ashi transform the fetched
28957
+ * bars; unknown/custom styles fall back to candles), and the colors default to the main
28958
+ * series' own so the inset reads as a finer copy of the chart. */
28959
+ this.seriesLook = {
28960
+ style: "candles",
28961
+ upColor: BULLISH,
28962
+ downColor: BEARISH,
28963
+ lineColor: BULLISH
28964
+ };
28627
28965
  }
28628
28966
  /** Paint every visible drawing, then selection handles for the targeted ones.
28629
28967
  * Each drawing is clipped to its own pane's rect (and skipped entirely while that pane
@@ -28668,6 +29006,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28668
29006
  ctx.globalAlpha = GHOST_ALPHA;
28669
29007
  if (ghost instanceof RegressionChannel || ghost instanceof FixedRangeVolumeProfile) {
28670
29008
  this.paintTimeSpanGhost(ctx, ghost, proj);
29009
+ } else if (ghost instanceof Magnifier) {
29010
+ this.paintMagnifierGhost(ctx, ghost, proj, theme);
28671
29011
  } else this.paintOne(ctx, ghost, proj, theme);
28672
29012
  ctx.globalAlpha = 1;
28673
29013
  }
@@ -28801,6 +29141,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
28801
29141
  this.paintLabel(ctx, d, proj, theme);
28802
29142
  return;
28803
29143
  }
29144
+ if (d instanceof Magnifier) {
29145
+ this.paintMagnifier(ctx, d, proj, theme);
29146
+ return;
29147
+ }
28804
29148
  if (d instanceof PatternDrawing) {
28805
29149
  this.paintPattern(ctx, d, proj, theme);
28806
29150
  return;
@@ -29293,6 +29637,216 @@ ${overlayScrollbarCss(".vela-sd-pane")}
29293
29637
  ctx.textBaseline = "alphabetic";
29294
29638
  }
29295
29639
  }
29640
+ /** Placement preview for the magnifier: a dashed rectangle outline only — no backdrop and
29641
+ * no series read, so dragging the area open never kicks a fetch per cursor move. */
29642
+ paintMagnifierGhost(ctx, d, proj, theme) {
29643
+ const r = d.rect(proj);
29644
+ if (!r) return;
29645
+ ctx.save();
29646
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
29647
+ ctx.lineWidth = 1;
29648
+ ctx.setLineDash([4, 4]);
29649
+ 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));
29650
+ ctx.restore();
29651
+ }
29652
+ /** Paint a magnifier: an opaque theme-background inset whose interior shows the chart's
29653
+ * market at a finer timeframe — candles at their true time/price positions, clipped to
29654
+ * the rectangle. Bars come through `Projector.seriesInRange` (cache read; `loading` and
29655
+ * `unavailable` states paint a short notice instead). The lower-timeframe candles shift
29656
+ * half a chart bar LEFT of their raw time pixel so each chart candle's visual cell —
29657
+ * centered on its open time — subdivides in place. */
29658
+ paintMagnifier(ctx, d, proj, theme) {
29659
+ const r = d.rect(proj);
29660
+ const a = d.anchors[0];
29661
+ const b = d.anchors[1];
29662
+ if (!r || !a || !b) return;
29663
+ const x0 = Math.min(r.x1, r.x2);
29664
+ const x1 = Math.max(r.x1, r.x2);
29665
+ const y0 = Math.min(r.y1, r.y2);
29666
+ const y1 = Math.max(r.y1, r.y2);
29667
+ const w = x1 - x0;
29668
+ const h = y1 - y0;
29669
+ ctx.save();
29670
+ ctx.globalAlpha = 1;
29671
+ ctx.fillStyle = theme.background;
29672
+ ctx.fillRect(x0, y0, w, h);
29673
+ ctx.restore();
29674
+ const from = Math.min(a.time, b.time);
29675
+ const to = Math.max(a.time, b.time);
29676
+ const chartBars = proj.barsBetween ? proj.barsBetween(from, to) : 0;
29677
+ const chartMs = chartBars > 0 ? (to - from) / chartBars : 0;
29678
+ const res = proj.seriesInRange && chartMs > 0 && w > 1 && h > 1 ? proj.seriesInRange(d.magnifier.timeframe, from, to + chartMs) : void 0;
29679
+ let seriesBars = res?.state === "ready" || res?.state === "loading" ? res.bars ?? [] : [];
29680
+ if (res && (res.state === "ready" || res.state === "loading") && seriesBars.length > 0) {
29681
+ const look = this.seriesLook;
29682
+ const transform = look.style !== "candles" ? barTransformFor(look.style) : null;
29683
+ if (transform) seriesBars = transform.full(seriesBars);
29684
+ const mode = look.style === "bars" ? "bars" : look.style === "line" || look.style === "baseline" ? "line" : look.style === "area" ? "area" : "candles";
29685
+ const halfPitch = (proj.xOf(from + chartMs) - proj.xOf(from)) / 2;
29686
+ ctx.save();
29687
+ ctx.beginPath();
29688
+ ctx.rect(x0, y0, w, h);
29689
+ ctx.clip();
29690
+ if (mode === "line" || mode === "area") {
29691
+ this.paintMagnifierLine(ctx, d, proj, seriesBars, res.barMs, halfPitch, y1, mode === "area", d.magnifier.upColor || look.lineColor);
29692
+ } else {
29693
+ this.paintMagnifierBars(ctx, d, proj, seriesBars, res.barMs, halfPitch, x0, x1, mode, d.magnifier.upColor || look.upColor, d.magnifier.downColor || look.downColor);
29694
+ }
29695
+ ctx.restore();
29696
+ } else if (res) {
29697
+ 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";
29698
+ this.paintMagnifierNotice(ctx, notice, x0, y0, w, h, theme);
29699
+ }
29700
+ ctx.save();
29701
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
29702
+ ctx.lineWidth = d.style.lineWidth || 1;
29703
+ ctx.setLineDash(dashPattern(d.style.lineStyle, d.style.lineWidth || 1));
29704
+ ctx.strokeRect(x0, y0, w, h);
29705
+ ctx.restore();
29706
+ if (w > 44) {
29707
+ const label = magnifierTimeframeLabel(res?.state === "ready" || res?.state === "loading" ? res.timeframe : d.magnifier.timeframe);
29708
+ const chipH = 17;
29709
+ const gap = 4;
29710
+ const pane = proj.paneRect?.(d.paneId);
29711
+ const paneBottom = pane ? pane.top + pane.height : proj.height;
29712
+ const below = y1 + gap + chipH <= paneBottom;
29713
+ const chipY = below ? y1 + gap : y1 - gap - chipH;
29714
+ ctx.save();
29715
+ ctx.font = `10px ${theme.fontFamily}`;
29716
+ const tw = ctx.measureText(label).width;
29717
+ const caretW = 11;
29718
+ const chipW = tw + 12 + caretW;
29719
+ roundRect(ctx, x0, chipY, chipW, chipH, 3);
29720
+ ctx.fillStyle = theme.background;
29721
+ ctx.fill();
29722
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
29723
+ ctx.lineWidth = 1;
29724
+ ctx.setLineDash([]);
29725
+ ctx.stroke();
29726
+ ctx.fillStyle = theme.textColor;
29727
+ ctx.textAlign = "left";
29728
+ ctx.textBaseline = "middle";
29729
+ ctx.fillText(label, x0 + 6, chipY + chipH / 2 + 0.5);
29730
+ const cxr = x0 + 6 + tw + 5;
29731
+ const cyr = chipY + chipH / 2;
29732
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.7);
29733
+ ctx.lineWidth = 1.2;
29734
+ ctx.beginPath();
29735
+ ctx.moveTo(cxr, cyr - 1.5);
29736
+ ctx.lineTo(cxr + 2.5, cyr + 1.5);
29737
+ ctx.lineTo(cxr + 5, cyr - 1.5);
29738
+ ctx.stroke();
29739
+ ctx.restore();
29740
+ d.chipRect = { x: x0, y: chipY, w: chipW, h: chipH };
29741
+ } else {
29742
+ d.chipRect = null;
29743
+ }
29744
+ }
29745
+ /** The magnifier's candle/bar loop: each bar's cell spans its open→close time (shifted left
29746
+ * by half a chart bar). Candles: wick always, body once the cell is wide enough to carry
29747
+ * one. OHLC bars: the high–low spine with open/close ticks once the cell has the room. */
29748
+ paintMagnifierBars(ctx, d, proj, bars, barMs, halfPitch, x0, x1, mode, upColor, downColor) {
29749
+ ctx.setLineDash([]);
29750
+ ctx.lineWidth = 1;
29751
+ for (const bar of bars) {
29752
+ const cx0 = proj.xOf(bar.time) - halfPitch;
29753
+ const cx1 = proj.xOf(bar.time + barMs) - halfPitch;
29754
+ if (cx1 < x0 || cx0 > x1) continue;
29755
+ const yHigh = proj.yOf(bar.high, d.paneId);
29756
+ const yLow = proj.yOf(bar.low, d.paneId);
29757
+ const yOpen = proj.yOf(bar.open, d.paneId);
29758
+ const yClose = proj.yOf(bar.close, d.paneId);
29759
+ if (yHigh == null || yLow == null || yOpen == null || yClose == null) continue;
29760
+ const color = bar.close >= bar.open ? upColor : downColor;
29761
+ const cellW = cx1 - cx0;
29762
+ const cx = (cx0 + cx1) / 2;
29763
+ ctx.strokeStyle = color;
29764
+ ctx.beginPath();
29765
+ ctx.moveTo(cx, yHigh);
29766
+ ctx.lineTo(cx, yLow);
29767
+ ctx.stroke();
29768
+ if (cellW < 3) continue;
29769
+ if (mode === "bars") {
29770
+ const tick = Math.max(1, cellW * 0.35);
29771
+ ctx.beginPath();
29772
+ ctx.moveTo(cx - tick, yOpen);
29773
+ ctx.lineTo(cx, yOpen);
29774
+ ctx.moveTo(cx, yClose);
29775
+ ctx.lineTo(cx + tick, yClose);
29776
+ ctx.stroke();
29777
+ } else {
29778
+ const bw = Math.max(1, cellW * 0.7);
29779
+ ctx.fillStyle = color;
29780
+ ctx.fillRect(cx - bw / 2, Math.min(yOpen, yClose), bw, Math.max(1, Math.abs(yClose - yOpen)));
29781
+ }
29782
+ }
29783
+ }
29784
+ /** The magnifier's line/area rendering: a close polyline through each cell's center (same
29785
+ * half-chart-bar shift as the candles), with an optional translucent fill down to the
29786
+ * rectangle's bottom edge for the area style. Colored like the chart's own line series. */
29787
+ paintMagnifierLine(ctx, d, proj, bars, barMs, halfPitch, yBottom, area, color) {
29788
+ const pts = [];
29789
+ for (const bar of bars) {
29790
+ const y = proj.yOf(bar.close, d.paneId);
29791
+ if (y == null) continue;
29792
+ pts.push([proj.xOf(bar.time + barMs / 2) - halfPitch, y]);
29793
+ }
29794
+ if (pts.length < 2) return;
29795
+ if (area) {
29796
+ ctx.beginPath();
29797
+ ctx.moveTo(pts[0][0], yBottom);
29798
+ for (const [px, py] of pts) ctx.lineTo(px, py);
29799
+ ctx.lineTo(pts[pts.length - 1][0], yBottom);
29800
+ ctx.closePath();
29801
+ ctx.fillStyle = withAlpha(color, 0.15);
29802
+ ctx.fill();
29803
+ }
29804
+ ctx.setLineDash([]);
29805
+ ctx.lineWidth = 1.5;
29806
+ ctx.strokeStyle = color;
29807
+ ctx.beginPath();
29808
+ ctx.moveTo(pts[0][0], pts[0][1]);
29809
+ for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
29810
+ ctx.stroke();
29811
+ }
29812
+ /** Centered muted notice inside the magnifier rect (loading / unavailable states). */
29813
+ paintMagnifierNotice(ctx, text, x0, y0, w, h, theme) {
29814
+ if (w < 60 || h < 20) return;
29815
+ ctx.save();
29816
+ ctx.beginPath();
29817
+ ctx.rect(x0, y0, w, h);
29818
+ ctx.clip();
29819
+ ctx.font = `11px ${theme.fontFamily}`;
29820
+ ctx.fillStyle = withAlpha(theme.textColor, 0.55);
29821
+ ctx.textAlign = "center";
29822
+ ctx.textBaseline = "middle";
29823
+ ctx.fillText(text, x0 + w / 2, y0 + h / 2);
29824
+ ctx.restore();
29825
+ }
29826
+ /** A bottom-center pill prompting the armed tool's placement gesture (e.g. the magnifier's
29827
+ * "drag an area"). Painted by the drawings layer while the tool is armed and no placement
29828
+ * is in progress; chart-background fill so it reads as chrome over any content. */
29829
+ paintPlacementHint(ctx, text, theme, width, height) {
29830
+ ctx.save();
29831
+ ctx.font = `11px ${theme.fontFamily}`;
29832
+ const tw = ctx.measureText(text).width;
29833
+ const pillW = tw + 24;
29834
+ const pillH = 24;
29835
+ const x = (width - pillW) / 2;
29836
+ const y = height - pillH - 14;
29837
+ roundRect(ctx, x, y, pillW, pillH, pillH / 2);
29838
+ ctx.fillStyle = theme.background;
29839
+ ctx.fill();
29840
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
29841
+ ctx.lineWidth = 1;
29842
+ ctx.setLineDash([]);
29843
+ ctx.stroke();
29844
+ ctx.fillStyle = theme.textColor;
29845
+ ctx.textAlign = "center";
29846
+ ctx.textBaseline = "middle";
29847
+ ctx.fillText(text, width / 2, y + pillH / 2 + 0.5);
29848
+ ctx.restore();
29849
+ }
29296
29850
  /** Paint a fixed-range volume profile: horizontal histogram rows (up/down split) anchored to
29297
29851
  * the left or right of the time span, optional VAH / VAL / POC levels across the range, and
29298
29852
  * optional developing POC / VA polylines. Recomputes from the two anchors on every paint. */
@@ -30708,8 +31262,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30708
31262
  if (!existing) document.head.appendChild(s);
30709
31263
  }
30710
31264
  var DrawingSettingsPopup = class {
30711
- constructor(host, theme) {
31265
+ constructor(host, theme, chartBarMs = () => 0) {
30712
31266
  this.host = host;
31267
+ this.chartBarMs = chartBarMs;
30713
31268
  this.el = null;
30714
31269
  this.tipEl = null;
30715
31270
  // floating hover-label (above/below the toolbar)
@@ -30734,6 +31289,14 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30734
31289
  this.theme = theme;
30735
31290
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
30736
31291
  }
31292
+ /** The magnifier timeframe choices strictly below the chart's own bar duration
31293
+ * (`auto` rides along while at least one concrete lower step exists). */
31294
+ lowerTimeframeOptions() {
31295
+ const chartMs = this.chartBarMs();
31296
+ if (!(chartMs > 0)) return [...MAGNIFIER_TIMEFRAME_OPTIONS];
31297
+ const lower = MAGNIFIER_TIMEFRAME_OPTIONS.filter((o) => o.ms > 0 && o.ms < chartMs);
31298
+ return lower.length > 0 ? [MAGNIFIER_TIMEFRAME_OPTIONS[0], ...lower] : [];
31299
+ }
30737
31300
  setTheme(theme) {
30738
31301
  this.theme = theme;
30739
31302
  this.settingsDialog.setTheme(theme);
@@ -30776,7 +31339,20 @@ ${overlayScrollbarCss(".vela-sd-pane")}
30776
31339
  const sz = drawing.size ?? "normal";
30777
31340
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
30778
31341
  }
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 })));
31342
+ if (paths.has("magnifier.timeframe") && drawing instanceof Magnifier) {
31343
+ const options = this.lowerTimeframeOptions();
31344
+ if (options.length > 0) {
31345
+ bar.appendChild(
31346
+ this.dropdown("Lower timeframe", options.map((o) => o.value), drawing.magnifier.timeframe, () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
31347
+ label: (v) => magnifierTimeframeLabel(String(v)),
31348
+ labelInTrigger: true
31349
+ })
31350
+ );
31351
+ }
31352
+ bar.appendChild(this.colorButton("Up candles", BUCKET_ICON, drawing.magnifier.upColor || t.upColor, (v) => actions.patch({ "magnifier.upColor": v })));
31353
+ bar.appendChild(this.colorButton("Down candles", BUCKET_ICON, drawing.magnifier.downColor || t.downColor, (v) => actions.patch({ "magnifier.downColor": v })));
31354
+ }
31355
+ 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
31356
  if (paths.has("style.lineWidth")) {
30781
31357
  const wf = schema.fields.find((f) => f.path === "style.lineWidth");
30782
31358
  if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
@@ -31098,6 +31674,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31098
31674
  this.colorPop = null;
31099
31675
  this.colorOwner = null;
31100
31676
  }
31677
+ opts.onClose?.();
31101
31678
  }
31102
31679
  });
31103
31680
  const el = pop.el;
@@ -31116,6 +31693,49 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31116
31693
  pop.show();
31117
31694
  return pop;
31118
31695
  }
31696
+ /**
31697
+ * A standalone timeframe menu for the magnifier's ON-CHART chip. The chip lives on
31698
+ * canvas, so a transient invisible anchor is dropped at its pixel rect for the popover
31699
+ * to position against, and removed again when the menu closes. Independent of the
31700
+ * quick toolbar — the chip works without selecting the drawing first.
31701
+ */
31702
+ openMagnifierTimeframeMenu(rect, current, onPick) {
31703
+ ensureStyles3();
31704
+ closeOpenPopovers();
31705
+ const options = this.lowerTimeframeOptions();
31706
+ const anchor = document.createElement("div");
31707
+ anchor.style.cssText = `position:absolute;left:${rect.x}px;top:${rect.y}px;width:${rect.w}px;height:${rect.h}px;pointer-events:none;`;
31708
+ this.host.appendChild(anchor);
31709
+ this.menuPop = this.hostFloat(anchor, {
31710
+ zIndex: 26,
31711
+ padding: "4px",
31712
+ onClose: () => anchor.remove(),
31713
+ fill: (menu, pop) => {
31714
+ if (options.length === 0) {
31715
+ const note = document.createElement("div");
31716
+ note.style.cssText = "padding:6px 10px;opacity:0.65;white-space:nowrap;";
31717
+ note.textContent = "No lower timeframe available";
31718
+ menu.appendChild(note);
31719
+ return;
31720
+ }
31721
+ for (const o of options) {
31722
+ const item = document.createElement("button");
31723
+ item.type = "button";
31724
+ item.className = "vela-dpop-item";
31725
+ item.dataset.active = o.value === current ? "1" : "0";
31726
+ 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;";
31727
+ item.textContent = o.label;
31728
+ item.addEventListener("click", (e) => {
31729
+ e.stopPropagation();
31730
+ pop.hide();
31731
+ onPick(o.value);
31732
+ });
31733
+ menu.appendChild(item);
31734
+ }
31735
+ }
31736
+ });
31737
+ this.menuOwner = anchor;
31738
+ }
31119
31739
  /** A floating list of one-shot actions (icon + label rows) opened by the kebab. */
31120
31740
  openActionMenu(anchor, rows) {
31121
31741
  this.menuPop = this.hostFloat(anchor, {
@@ -31226,10 +31846,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31226
31846
  let cur = current;
31227
31847
  const paint = (v) => {
31228
31848
  b.replaceChildren();
31229
- const ic = document.createElement("span");
31230
- ic.style.cssText = "display:flex;";
31231
- ic.innerHTML = sized(render(v));
31232
- b.appendChild(ic);
31849
+ const glyph = render(v);
31850
+ if (glyph) {
31851
+ const ic = document.createElement("span");
31852
+ ic.style.cssText = "display:flex;";
31853
+ ic.innerHTML = sized(glyph);
31854
+ b.appendChild(ic);
31855
+ }
31233
31856
  if (opts.label && opts.labelInTrigger) {
31234
31857
  const tx = document.createElement("span");
31235
31858
  tx.textContent = opts.label(v);
@@ -31271,10 +31894,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
31271
31894
  item.className = "vela-dpop-item";
31272
31895
  item.dataset.active = active ? "1" : "0";
31273
31896
  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);
31897
+ const glyph = render(v);
31898
+ if (glyph) {
31899
+ const ic = document.createElement("span");
31900
+ ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
31901
+ ic.innerHTML = sized(glyph, 18);
31902
+ item.appendChild(ic);
31903
+ }
31278
31904
  if (label) {
31279
31905
  const tx = document.createElement("span");
31280
31906
  tx.textContent = label(v);
@@ -32258,6 +32884,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32258
32884
  this.intentCb = null;
32259
32885
  /** Another chart's in-progress placement, mirrored here as a ghost (drawings sync). */
32260
32886
  this.externalGhost = null;
32887
+ /** Core-pushed series gateway (finer-timeframe bars for data-driven drawings). */
32888
+ this.seriesGw = null;
32889
+ this.seriesGwUnsub = null;
32261
32890
  /** Last draft fingerprint reported upstream — gates the per-render emission to actual changes. */
32262
32891
  this.lastDraftKey = null;
32263
32892
  this.measure = new MeasureOverlay();
@@ -32285,7 +32914,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32285
32914
  this.textEditor = null;
32286
32915
  this.painter = new DrawingPainter();
32287
32916
  this.ctx = canvas.getContext("2d");
32288
- this.popup = new DrawingSettingsPopup(overlayHost, deps.theme());
32917
+ this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
32289
32918
  this.toolbar = new DrawingToolbar(
32290
32919
  toolbarHost,
32291
32920
  deps.theme(),
@@ -32343,6 +32972,22 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32343
32972
  const shown = this.toolbarVisible && !this.mobileLayout;
32344
32973
  this.deps.setToolbarGutter(shown ? this.toolbarCollapsed ? TOOLBAR_COLLAPSED_WIDTH : TOOLBAR_WIDTH : 0);
32345
32974
  }
32975
+ /** Core push: the series gateway data-driven drawings read finer-timeframe bars
32976
+ * through (surfaced to them as `Projector.seriesInRange`). A landed background
32977
+ * fetch repaints both this layer and the interleave slices under the series. */
32978
+ setSeriesGateway(gateway) {
32979
+ this.seriesGwUnsub?.();
32980
+ this.seriesGw = gateway;
32981
+ this.seriesGwUnsub = gateway.onUpdate(() => {
32982
+ this.invalidateSlices();
32983
+ this.render();
32984
+ this.deps.requestDataPaint();
32985
+ });
32986
+ }
32987
+ /** The pushed series gateway, or null before the core provides one. */
32988
+ get seriesGateway() {
32989
+ return this.seriesGw;
32990
+ }
32346
32991
  /** Core push: mirror (or clear) another chart's in-progress placement as a ghost. */
32347
32992
  setExternalGhost(doc) {
32348
32993
  this.externalGhost = doc ? deserializeDrawing(doc) : null;
@@ -32460,8 +33105,34 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32460
33105
  /** Should the drawing layer win this press (vs pan)? */
32461
33106
  claim(x, y) {
32462
33107
  if (this.measureMode || this.eraserMode) return true;
33108
+ if (this.magnifierChipAt(x, y)) return true;
32463
33109
  return this.interaction.claim(x, y);
32464
33110
  }
33111
+ /** The topmost visible (unlocked) magnifier whose timeframe chip contains (x, y) —
33112
+ * the chip's rect is what the painter measured last frame. */
33113
+ magnifierChipAt(x, y) {
33114
+ for (let i = this.drawings.length - 1; i >= 0; i -= 1) {
33115
+ const d = this.drawings[i];
33116
+ if (!(d instanceof Magnifier) || !d.visible || d.locked) continue;
33117
+ const r = d.chipRect;
33118
+ if (r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return d;
33119
+ }
33120
+ return null;
33121
+ }
33122
+ /** Open the on-chart chip's timeframe menu; the pick patches the drawing like a
33123
+ * settings-popup edit (same intent, same undo step). */
33124
+ openMagnifierChipMenu(drawing) {
33125
+ const rect = drawing.chipRect;
33126
+ if (!rect) return;
33127
+ const id = drawing.id;
33128
+ this.popup.openMagnifierTimeframeMenu(rect, drawing.magnifier.timeframe, (value) => {
33129
+ const d = this.drawings.find((x) => x.id === id);
33130
+ if (!(d instanceof Magnifier)) return;
33131
+ d.applySettings({ "magnifier.timeframe": value });
33132
+ this.render();
33133
+ this.emit({ kind: "edit", doc: d.serialize() });
33134
+ });
33135
+ }
32465
33136
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
32466
33137
  * Shared by the eraser (click + drag) and the middle-click shortcut. */
32467
33138
  deleteAt(x, y) {
@@ -32507,6 +33178,13 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32507
33178
  this.render();
32508
33179
  return;
32509
33180
  }
33181
+ if (this.activeTool == null) {
33182
+ const chipOwner = this.magnifierChipAt(x, y);
33183
+ if (chipOwner) {
33184
+ this.openMagnifierChipMenu(chipOwner);
33185
+ return;
33186
+ }
33187
+ }
32510
33188
  this.interaction.down(x, y, snap, shift3);
32511
33189
  }
32512
33190
  pointerMove(x, y, snap = "off", shift3 = false) {
@@ -32725,6 +33403,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32725
33403
  /** Cursor hint while hovering — `'pointer'` over a drawing/handle, else null. */
32726
33404
  cursorAt(x, y) {
32727
33405
  if (this.eraserMode) return "pointer";
33406
+ if (this.activeTool == null && this.magnifierChipAt(x, y)) return "pointer";
32728
33407
  return this.interaction.cursorAt(x, y);
32729
33408
  }
32730
33409
  /** Right-click: an explicit escape back to the pointer. Cancels an in-progress
@@ -32903,6 +33582,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32903
33582
  if (!sctx) continue;
32904
33583
  sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
32905
33584
  sctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
33585
+ this.painter.seriesLook = this.deps.seriesLook();
32906
33586
  this.painter.paintAll(sctx, drawings, proj, theme, EMPTY_TARGETS);
32907
33587
  const slices = out.get(paneId) ?? [];
32908
33588
  slices.push({ beforeZ, canvas });
@@ -32930,6 +33610,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32930
33610
  dragged: this.interaction.activeDragId(),
32931
33611
  mutedLabel: edited instanceof TextLabel ? edited.id : null
32932
33612
  };
33613
+ this.painter.seriesLook = this.deps.seriesLook();
32933
33614
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
32934
33615
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
32935
33616
  this.layoutTextEditor();
@@ -32937,6 +33618,10 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32937
33618
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
32938
33619
  if (this.externalGhost) this.painter.paintGhost(ctx, this.externalGhost, proj, this.deps.theme());
32939
33620
  this.emitDraft(ghost);
33621
+ if (this.activeTool && !ghost) {
33622
+ const hint = getDrawingType(this.activeTool)?.placementHint;
33623
+ if (hint) this.painter.paintPlacementHint(ctx, hint, this.deps.theme(), proj.width, proj.height);
33624
+ }
32940
33625
  const markers = this.interaction.placingMarkers(proj);
32941
33626
  if (markers) this.painter.paintHandles(ctx, markers);
32942
33627
  const m = this.interaction.snapMarker();
@@ -32948,6 +33633,9 @@ ${overlayScrollbarCss(".vela-sd-pane")}
32948
33633
  this.closeTextEditor();
32949
33634
  this.popup.destroy();
32950
33635
  this.toolbar.destroy();
33636
+ this.seriesGwUnsub?.();
33637
+ this.seriesGwUnsub = null;
33638
+ this.seriesGw = null;
32951
33639
  this.intentCb = null;
32952
33640
  this.drawings = [];
32953
33641
  }
@@ -33345,7 +34033,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
33345
34033
  }
33346
34034
 
33347
34035
  // src/renderers/native/drawings/Projector.ts
33348
- function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
34036
+ function createProjector(coords, paneOf, paneIdAtY, barsInRange, seriesInRange) {
33349
34037
  return {
33350
34038
  xOf: (time) => coords.timeToX(time),
33351
34039
  yOf: (price, paneId) => {
@@ -33366,6 +34054,7 @@ ${overlayScrollbarCss(".vela-sd-pane")}
33366
34054
  },
33367
34055
  barsBetween: (t1, t2) => Math.abs(coords.timeToLogical(t2) - coords.timeToLogical(t1)),
33368
34056
  barsInRange: barsInRange ? (from, to) => barsInRange(from, to) : void 0,
34057
+ seriesInRange,
33369
34058
  width: coords.width,
33370
34059
  height: coords.height
33371
34060
  };
@@ -35369,6 +36058,23 @@ ${overlayScrollbarCss(".vela-sd-pane")}
35369
36058
  seriesBoundaries: (paneId) => this.scene.seriesBoundaries(paneId),
35370
36059
  priceZ: (paneId) => paneId === PRICE_PANE_ID ? this.scene.candleZ : null,
35371
36060
  requestDataPaint: () => this.scheduler.invalidate(3 /* Light */),
36061
+ // The look the price series ACTUALLY paints with: candle colors resolved through
36062
+ // the per-style override, line/area colors through their configured styles — so
36063
+ // series-mirroring content (the magnifier inset) matches the chart exactly.
36064
+ seriesLook: () => {
36065
+ const st = this.scene.style;
36066
+ const paint = effectiveCandlePaint(st.candle, this.scene.candleOverride, this.theme.upColor, this.theme.downColor);
36067
+ const barsUp = st.bars.upColor ?? this.theme.upColor;
36068
+ const barsDown = st.bars.downColor ?? this.theme.downColor;
36069
+ const style = this.scene.priceStyle;
36070
+ return {
36071
+ style,
36072
+ upColor: style === "bars" ? barsUp : paint.up,
36073
+ downColor: style === "bars" ? barsDown : paint.down,
36074
+ lineColor: style === "area" ? st.area.lineColor ?? this.theme.upColor : st.line.color ?? this.theme.upColor
36075
+ };
36076
+ },
36077
+ chartBarMs: () => this.coords.barInterval,
35372
36078
  snap: (pt2, paneId, mode, cursorPx) => this.snapToCandle(pt2, paneId, mode, cursorPx),
35373
36079
  setSnapMode: (mode) => this.setSnapMode(mode),
35374
36080
  setToolbarGutter: (px) => this.setToolbarGutter(px)
@@ -36762,7 +37468,8 @@ ${overlayScrollbarCss(".vela-sd-pane")}
36762
37468
  return p ? { scale: p.scale, bounds: p.bounds, collapsed: p.collapsed } : null;
36763
37469
  },
36764
37470
  (y) => this.paneNodeAtY(y)?.id ?? null,
36765
- (from, to) => this.barsInTimeRange(from, to)
37471
+ (from, to) => this.barsInTimeRange(from, to),
37472
+ this.userDrawings?.seriesGateway ? (tf, from, to) => this.userDrawings.seriesGateway.seriesInRange(tf, from, to) : void 0
36766
37473
  );
36767
37474
  }
36768
37475
  /** OHLC bars whose open-time falls within `[from, to]` (inclusive) — the data a regression