@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
package/dist/index.cjs CHANGED
@@ -5424,6 +5424,111 @@ function roundFloat(n) {
5424
5424
  return Math.round(n * 1e8) / 1e8;
5425
5425
  }
5426
5426
 
5427
+ // src/core/drawings/types/Magnifier.ts
5428
+ var MAGNIFIER_TIMEFRAME_OPTIONS = [
5429
+ { value: "auto", label: "Auto", ms: 0 },
5430
+ { value: "1", label: "1m", ms: 6e4 },
5431
+ { value: "5", label: "5m", ms: 3e5 },
5432
+ { value: "15", label: "15m", ms: 9e5 },
5433
+ { value: "30", label: "30m", ms: 18e5 },
5434
+ { value: "60", label: "1h", ms: 36e5 },
5435
+ { value: "240", label: "4h", ms: 144e5 },
5436
+ { value: "D", label: "1D", ms: 864e5 }
5437
+ ];
5438
+ function magnifierTimeframeLabel(value) {
5439
+ const opt = MAGNIFIER_TIMEFRAME_OPTIONS.find((o) => o.value === value);
5440
+ if (opt) return opt.label;
5441
+ const n = Number(value);
5442
+ if (Number.isFinite(n) && n > 0) {
5443
+ if (n % 1440 === 0) return `${n / 1440}D`;
5444
+ if (n % 60 === 0) return `${n / 60}h`;
5445
+ return `${n}m`;
5446
+ }
5447
+ return value;
5448
+ }
5449
+ function defaultMagnifierStyle() {
5450
+ return { timeframe: "auto", upColor: "", downColor: "" };
5451
+ }
5452
+ var Magnifier = class extends Drawing {
5453
+ constructor(init) {
5454
+ super(init);
5455
+ this.type = "magnifier";
5456
+ /** Pixel rect of the timeframe chip as painted last frame, caret included — the chip is
5457
+ * an interactive dropdown trigger, so the interaction layer needs the exact rect the
5458
+ * painter measured. Renderer-transient: never serialized, null while unpainted. */
5459
+ this.chipRect = null;
5460
+ if (!this.magnifier) this.magnifier = defaultMagnifierStyle();
5461
+ }
5462
+ anchorSchema() {
5463
+ return { min: 2, max: 2, slots: [{ role: "c1", free: "both" }, { role: "c2", free: "both" }] };
5464
+ }
5465
+ placementMode() {
5466
+ return "drag";
5467
+ }
5468
+ /** The pixel rectangle between the two corner anchors (painter + hit-test share it). */
5469
+ rect(proj) {
5470
+ const a = this.anchors[0];
5471
+ const b = this.anchors[1];
5472
+ if (!a || !b) return null;
5473
+ const ya = proj.yOf(a.price, this.paneId);
5474
+ const yb = proj.yOf(b.price, this.paneId);
5475
+ if (ya == null || yb == null) return null;
5476
+ return { x1: proj.xOf(a.time), y1: ya, x2: proj.xOf(b.time), y2: yb };
5477
+ }
5478
+ hitTest(px, py, proj, tol) {
5479
+ const r = this.rect(proj);
5480
+ if (!r) return false;
5481
+ if (pointInBox(px, py, r.x1, r.y1, r.x2, r.y2)) return true;
5482
+ const edges = [
5483
+ [r.x1, r.y1, r.x2, r.y1],
5484
+ [r.x2, r.y1, r.x2, r.y2],
5485
+ [r.x2, r.y2, r.x1, r.y2],
5486
+ [r.x1, r.y2, r.x1, r.y1]
5487
+ ];
5488
+ return edges.some((e) => distToSegment(px, py, e[0], e[1], e[2], e[3]) <= tol);
5489
+ }
5490
+ handlePoints(proj) {
5491
+ const r = this.rect(proj);
5492
+ return r ? [[r.x1, r.y1], [r.x2, r.y2]] : [];
5493
+ }
5494
+ hitHandle(px, py, proj, tol) {
5495
+ return handleAt(px, py, this.handlePoints(proj), tol + 3);
5496
+ }
5497
+ bounds(proj) {
5498
+ const r = this.rect(proj);
5499
+ if (!r) return null;
5500
+ return { x: Math.min(r.x1, r.x2), y: Math.min(r.y1, r.y2), w: Math.abs(r.x2 - r.x1), h: Math.abs(r.y2 - r.y1) };
5501
+ }
5502
+ priceRange() {
5503
+ const a = this.anchors[0];
5504
+ const b = this.anchors[1];
5505
+ if (!a || !b) return null;
5506
+ return { min: Math.min(a.price, b.price), max: Math.max(a.price, b.price) };
5507
+ }
5508
+ schema() {
5509
+ return {
5510
+ fields: [
5511
+ {
5512
+ path: "magnifier.timeframe",
5513
+ label: "Timeframe",
5514
+ kind: "select",
5515
+ options: MAGNIFIER_TIMEFRAME_OPTIONS,
5516
+ group: "behavior"
5517
+ },
5518
+ ...LINE_FIELDS.map((f) => ({ ...f, label: f.label.replace("Line", "Border") })),
5519
+ { path: "magnifier.upColor", label: "Up candles", kind: "color", group: "fill" },
5520
+ { path: "magnifier.downColor", label: "Down candles", kind: "color", group: "fill" }
5521
+ ]
5522
+ };
5523
+ }
5524
+ writeProps() {
5525
+ return { ...this.magnifier };
5526
+ }
5527
+ readProps(props) {
5528
+ this.magnifier = { ...defaultMagnifierStyle(), ...props };
5529
+ }
5530
+ };
5531
+
5427
5532
  // src/core/drawings/registry.ts
5428
5533
  var REGISTRY2 = /* @__PURE__ */ new Map();
5429
5534
  function registerDrawingType(meta) {
@@ -6083,6 +6188,22 @@ registerDrawingType({
6083
6188
  defaultStyle: { lineColor: DEFAULT_DRAWING_COLOR, lineWidth: 1, lineStyle: "solid" },
6084
6189
  create: (init) => new PositionTool(init)
6085
6190
  });
6191
+ var MAGNIFIER_ICON = svg24(
6192
+ '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.3 15.3 5.2 5.2"/><path d="M8 12.5v-3M10.5 13.5v-5.5M13 12v-2"/>'
6193
+ );
6194
+ registerDrawingType({
6195
+ type: "magnifier",
6196
+ group: "measure",
6197
+ label: "Magnifier",
6198
+ icon: MAGNIFIER_ICON,
6199
+ // An empty border color means the THEME's contrast ink (white on dark, black on
6200
+ // light), resolved at paint time so it follows theme switches; a user pick wins.
6201
+ defaultStyle: { lineColor: "", lineWidth: 1, lineStyle: "solid" },
6202
+ coversSeries: true,
6203
+ // the inset's backdrop must sit over the base candles it replaces
6204
+ placementHint: "Drag an area on the chart to view it at a lower timeframe",
6205
+ create: (init) => new Magnifier(init)
6206
+ });
6086
6207
  var VWAP_ICON = svg24('<line x1="5" y1="3" x2="5" y2="21"/><path d="M5 16c4 0 5-9 8-9s3 5 8 3"/>');
6087
6208
  registerDrawingType({
6088
6209
  type: "anchoredvwap",
@@ -6122,7 +6243,7 @@ var GEOMETRY_TYPES = ["dedekind", "sonic", "supersonic", "goldensonic", "goldens
6122
6243
  var PATTERN_TYPES = ["xabcd", "abcd", "headshoulders"];
6123
6244
  var ELLIOTT_TYPES = ["elliottimpulse", "elliottcorrection"];
6124
6245
  var HARMONIC_TYPES = ["gartley", "bat", "butterfly", "crab", "shark", "cypher"];
6125
- var MEASUREMENT_TYPES = ["position", "datepricerange"];
6246
+ var MEASUREMENT_TYPES = ["position", "datepricerange", "magnifier"];
6126
6247
  var VOLUME_TYPES = ["anchoredvwap", "fixedrangevp"];
6127
6248
  var BRUSH_TYPES = ["freehand", "highlighter"];
6128
6249
  var ARROW_TYPES = ["arrow", "arrowmarkup", "arrowmarkdown"];
@@ -6486,7 +6607,7 @@ var DrawingHistory = class {
6486
6607
 
6487
6608
  // src/core/drawings/DrawingController.ts
6488
6609
  var DrawingController = class {
6489
- constructor(renderer, events, option) {
6610
+ constructor(renderer, events, option, seriesGateway) {
6490
6611
  this.events = events;
6491
6612
  this.store = new DrawingStore();
6492
6613
  this.history = new DrawingHistory();
@@ -6512,6 +6633,7 @@ var DrawingController = class {
6512
6633
  const { definition, visible } = buildToolbar(option);
6513
6634
  this.port.setToolbar(definition);
6514
6635
  this.port.showToolbar(visible);
6636
+ if (seriesGateway) this.port.setSeriesGateway?.(seriesGateway);
6515
6637
  this.subs.push(this.port.onDrawingIntent((i) => this.onIntent(i)));
6516
6638
  this.subs.push(this.store.onChange(() => this.sync()));
6517
6639
  }
@@ -6617,7 +6739,7 @@ var DrawingController = class {
6617
6739
  style,
6618
6740
  text: init.text,
6619
6741
  props: init.props,
6620
- zIndex: init.zIndex ?? this.startZ(init.paneId ?? "price")
6742
+ zIndex: init.zIndex ?? this.startZ(type, init.paneId ?? "price")
6621
6743
  });
6622
6744
  if (!d) return null;
6623
6745
  this.history.record(this.store.serialize());
@@ -6692,10 +6814,14 @@ var DrawingController = class {
6692
6814
  * (falling back to just under the pane's top series where there is no price — a study
6693
6815
  * pane). Half a key down never ties a series; drawings tying each other paint in insertion
6694
6816
  * order, so consecutive new drawings still stack newest-in-front. Undefined without a
6695
- * shared z space — the store then places it over the other drawings, its own layer's top. */
6696
- startZ(paneId) {
6817
+ * shared z space — the store then places it over the other drawings, its own layer's top.
6818
+ * A type that COVERS the series (an opaque inset, `coversSeries`) instead starts just
6819
+ * above the whole stack — under the candles its content would be buried. */
6820
+ startZ(type, paneId) {
6697
6821
  const range = this.port?.stackRange?.(paneId);
6698
- return range ? (range.price ?? range.front) - 0.5 : void 0;
6822
+ if (!range) return void 0;
6823
+ if (getDrawingType(type)?.coversSeries) return range.front + 0.5;
6824
+ return (range.price ?? range.front) - 0.5;
6699
6825
  }
6700
6826
  /** Programmatically select drawings (host UI → chart): shows the on-chart handles + toolbar.
6701
6827
  * `additive` toggles membership (matching shift-click) instead of replacing. */
@@ -6840,7 +6966,7 @@ var DrawingController = class {
6840
6966
  const style = last ? { ...i.doc.style, ...last } : i.doc.style;
6841
6967
  const d = deserializeDrawing({ ...i.doc, id: this.store.nextId(), style });
6842
6968
  if (!d) return;
6843
- if (!d.zIndex) d.zIndex = this.startZ(d.paneId) ?? 0;
6969
+ if (!d.zIndex) d.zIndex = this.startZ(d.type, d.paneId) ?? 0;
6844
6970
  this.history.record(before);
6845
6971
  this.store.add(d);
6846
6972
  this.captureStyle(d.id);
@@ -6938,6 +7064,180 @@ function timeframeToMs(timeframe) {
6938
7064
  return 36e5;
6939
7065
  }
6940
7066
 
7067
+ // src/core/engine/DrawingSeriesService.ts
7068
+ var MAX_BARS = 5e3;
7069
+ var PAD_FRAC = 0.25;
7070
+ var MAX_ENTRIES = 16;
7071
+ var RETRY_MS = 15e3;
7072
+ var AUTO_STEPS = ["240", "60", "30", "15", "5", "1"];
7073
+ var DrawingSeriesService = class {
7074
+ constructor(deps) {
7075
+ this.deps = deps;
7076
+ /** Cached windows per `market|timeframe` key, newest-used last (LRU across keys). */
7077
+ this.cache = /* @__PURE__ */ new Map();
7078
+ this.listeners = /* @__PURE__ */ new Set();
7079
+ }
7080
+ seriesInRange(timeframe, from, to) {
7081
+ if (!this.deps.canFetch()) return { state: "unavailable", reason: "no-source" };
7082
+ const resolved = this.resolveTimeframe(timeframe);
7083
+ if (typeof resolved !== "string") return { state: "unavailable", reason: resolved.reason };
7084
+ const barMs = timeframeToMs(resolved);
7085
+ const lo = Math.min(from, to);
7086
+ const hi = Math.max(from, to);
7087
+ if (!(hi > lo) || !(barMs > 0)) return { state: "unavailable", reason: "not-lower" };
7088
+ if ((hi - lo) / barMs > MAX_BARS) return { state: "unavailable", reason: "too-wide" };
7089
+ const key = `${this.deps.marketKey()}|${resolved}`;
7090
+ const entries = this.cache.get(key) ?? [];
7091
+ const covering = entries.find((e) => e.from <= lo && e.to >= hi);
7092
+ if (covering) {
7093
+ if (covering.pending) return this.loading(entries, resolved, barMs, lo, hi);
7094
+ if (covering.failedAt > 0) {
7095
+ if (Date.now() - covering.failedAt < RETRY_MS) return this.loading(entries, resolved, barMs, lo, hi);
7096
+ entries.splice(entries.indexOf(covering), 1);
7097
+ } else {
7098
+ this.maybeRefresh(key, covering, resolved, barMs, hi);
7099
+ return { state: "ready", bars: this.slice(covering.bars, lo, hi), timeframe: resolved, barMs };
7100
+ }
7101
+ }
7102
+ this.fetchWindow(key, entries, resolved, lo, hi);
7103
+ return this.loading(entries, resolved, barMs, lo, hi);
7104
+ }
7105
+ onUpdate(listener) {
7106
+ this.listeners.add(listener);
7107
+ return () => this.listeners.delete(listener);
7108
+ }
7109
+ // ── internals ──
7110
+ /** `'auto'` → the largest standard step at least 4× finer than the chart (else the finest
7111
+ * step still below it); an explicit timeframe passes only when strictly finer. Failures
7112
+ * distinguish "this pick isn't lower" from "NOTHING lower exists" (the chart is already
7113
+ * at the finest offered step) so the consumer can word its notice honestly. */
7114
+ resolveTimeframe(timeframe) {
7115
+ const chartMs = timeframeToMs(this.deps.chartTimeframe());
7116
+ const finest = AUTO_STEPS[AUTO_STEPS.length - 1];
7117
+ if (timeframeToMs(finest) >= chartMs) return { reason: "none-lower" };
7118
+ const tf = timeframe.trim() || "auto";
7119
+ if (tf === "auto") {
7120
+ for (const step of AUTO_STEPS) {
7121
+ if (timeframeToMs(step) <= chartMs / 4) return step;
7122
+ }
7123
+ return finest;
7124
+ }
7125
+ return timeframeToMs(tf) < chartMs ? tf : { reason: "not-lower" };
7126
+ }
7127
+ /** The `loading` answer, carrying best-effort PARTIAL bars from settled overlapping
7128
+ * windows — a widened window keeps painting what it already has while it fetches. */
7129
+ loading(entries, timeframe, barMs, lo, hi) {
7130
+ const partial = /* @__PURE__ */ new Map();
7131
+ for (const e of entries) {
7132
+ if (e.pending || e.failedAt > 0) continue;
7133
+ if (e.to < lo || e.from > hi) continue;
7134
+ for (const b of this.slice(e.bars, lo, hi)) partial.set(b.time, b);
7135
+ }
7136
+ if (partial.size === 0) return { state: "loading", timeframe, barMs };
7137
+ const bars = [...partial.values()].sort((a, b) => a.time - b.time);
7138
+ return { state: "loading", timeframe, barMs, bars };
7139
+ }
7140
+ /** Kick ONE background fetch for the padded window. Any OVERLAPPING in-flight fetch
7141
+ * defers this one (a corner drag repaints per pointer move — kicking a window per
7142
+ * frame would spam the provider); when it lands, the next paint re-evaluates. */
7143
+ fetchWindow(key, entries, timeframe, lo, hi) {
7144
+ if (entries.some((e) => e.pending && e.to >= lo && e.from <= hi)) return;
7145
+ const pad = (hi - lo) * PAD_FRAC;
7146
+ const entry = { from: lo - pad, to: hi + pad, bars: [], fetchedAt: 0, pending: true, failedAt: 0 };
7147
+ entries.push(entry);
7148
+ this.cache.set(key, entries);
7149
+ this.evict();
7150
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
7151
+ entry.bars = bars;
7152
+ entry.fetchedAt = Date.now();
7153
+ entry.pending = false;
7154
+ this.absorbOverlaps(key, entry);
7155
+ this.fire();
7156
+ }).catch(() => {
7157
+ entry.pending = false;
7158
+ entry.failedAt = Date.now();
7159
+ this.fire();
7160
+ });
7161
+ }
7162
+ /** A window whose right edge reaches the newest fetched bar refreshes at most once per
7163
+ * bar interval — new closed bars ride the feed's cache, only the live tail re-fetches. */
7164
+ maybeRefresh(key, entry, timeframe, barMs, hi) {
7165
+ if (entry.pending) return;
7166
+ const lastBar = entry.bars.length > 0 ? entry.bars[entry.bars.length - 1].time : entry.from;
7167
+ if (hi < lastBar) return;
7168
+ if (Date.now() - entry.fetchedAt < barMs) return;
7169
+ entry.pending = true;
7170
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
7171
+ entry.bars = bars;
7172
+ entry.fetchedAt = Date.now();
7173
+ entry.pending = false;
7174
+ this.fire();
7175
+ }).catch(() => {
7176
+ entry.pending = false;
7177
+ entry.fetchedAt = Date.now();
7178
+ });
7179
+ }
7180
+ /** Merge windows that overlap `entry` into it (dedupe by bar time) so a key's list
7181
+ * converges instead of accumulating slivers. */
7182
+ absorbOverlaps(key, entry) {
7183
+ const entries = this.cache.get(key);
7184
+ if (!entries) return;
7185
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
7186
+ const other = entries[i];
7187
+ if (other === entry || other.pending || other.failedAt > 0) continue;
7188
+ if (other.to < entry.from || other.from > entry.to) continue;
7189
+ const byTime = /* @__PURE__ */ new Map();
7190
+ for (const b of other.bars) byTime.set(b.time, b);
7191
+ for (const b of entry.bars) byTime.set(b.time, b);
7192
+ entry.bars = [...byTime.values()].sort((a, b) => a.time - b.time);
7193
+ entry.from = Math.min(entry.from, other.from);
7194
+ entry.to = Math.max(entry.to, other.to);
7195
+ entry.fetchedAt = Math.min(entry.fetchedAt, other.fetchedAt || entry.fetchedAt);
7196
+ entries.splice(i, 1);
7197
+ }
7198
+ }
7199
+ /** Drop the oldest settled windows once the global count passes {@link MAX_ENTRIES}. */
7200
+ evict() {
7201
+ let total = 0;
7202
+ for (const entries of this.cache.values()) total += entries.length;
7203
+ while (total > MAX_ENTRIES) {
7204
+ let oldestKey = null;
7205
+ let oldestIdx = -1;
7206
+ let oldestAt = Infinity;
7207
+ for (const [key, entries2] of this.cache) {
7208
+ for (let i = 0; i < entries2.length; i += 1) {
7209
+ const e = entries2[i];
7210
+ if (e.pending) continue;
7211
+ const at = e.fetchedAt || e.failedAt;
7212
+ if (at < oldestAt) {
7213
+ oldestKey = key;
7214
+ oldestIdx = i;
7215
+ oldestAt = at;
7216
+ }
7217
+ }
7218
+ }
7219
+ if (oldestKey == null) return;
7220
+ const entries = this.cache.get(oldestKey);
7221
+ entries.splice(oldestIdx, 1);
7222
+ if (entries.length === 0) this.cache.delete(oldestKey);
7223
+ total -= 1;
7224
+ }
7225
+ }
7226
+ /** Bars whose open time falls within `[lo, hi]` (ascending input → linear scan is fine). */
7227
+ slice(bars, lo, hi) {
7228
+ const out = [];
7229
+ for (const b of bars) {
7230
+ if (b.time < lo) continue;
7231
+ if (b.time > hi) break;
7232
+ out.push(b);
7233
+ }
7234
+ return out;
7235
+ }
7236
+ fire() {
7237
+ for (const l of [...this.listeners]) l();
7238
+ }
7239
+ };
7240
+
6941
7241
  // src/data/symbol-groups.ts
6942
7242
  function isGroupRow(d) {
6943
7243
  return d.group != null && d.ticker === d.group;
@@ -7420,7 +7720,13 @@ var EngineOrchestrator = class _EngineOrchestrator {
7420
7720
  const initialStyle = this.renderer.readFeature("priceStyle");
7421
7721
  if (typeof initialStyle === "string") this.priceStyle = initialStyle;
7422
7722
  this.barTransform = barTransformFor(initialStyle);
7423
- this.drawings = new DrawingController(this.renderer, this.events, config.drawings);
7723
+ const drawingSeries = new DrawingSeriesService({
7724
+ fetchBars: (tf, range) => this.fetchSeries(this.config.market.symbol ?? "", tf, range),
7725
+ canFetch: () => !!this.feed.loadRange && !this.config.market.data?.length && !!this.config.market.symbol,
7726
+ chartTimeframe: () => this.config.market.timeframe ?? "60",
7727
+ marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
7728
+ });
7729
+ this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
7424
7730
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
7425
7731
  this.endLoad();
7426
7732
  this.events.emit("data:unresolved", info);
@@ -8413,8 +8719,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
8413
8719
  onModel: (model) => {
8414
8720
  const first = !record.announced;
8415
8721
  const cause = record.pendingCause ?? "history";
8722
+ if (!this.applyModel(id, model)) return;
8416
8723
  record.pendingCause = void 0;
8417
- this.applyModel(id, model);
8418
8724
  this.emitContextChanged(id);
8419
8725
  this.emitScriptRun(id, cause, first);
8420
8726
  },
@@ -8707,10 +9013,16 @@ var EngineOrchestrator = class _EngineOrchestrator {
8707
9013
  * Apply an emitted model. First emission mounts (and routes the pane); a pending
8708
9014
  * structural change (after an input edit) remounts idempotently; everything else
8709
9015
  * (live tick / viewport re-run) value-patches.
9016
+ *
9017
+ * Returns false when the model was DEFERRED — an output-free model arriving while
9018
+ * the record is still loading and the chart has no bars (see below); every other
9019
+ * outcome, including the hidden drop, returns true so the caller's event semantics
9020
+ * stay unchanged.
8710
9021
  */
8711
9022
  applyModel(id, model) {
8712
9023
  const record = this.registry.get(id);
8713
- if (!record || record.hidden) return;
9024
+ if (!record || record.hidden) return true;
9025
+ if (record.loading && this.bars.length === 0 && !_EngineOrchestrator.modelHasOutput(model)) return false;
8714
9026
  const handle = this.handles.get(id);
8715
9027
  if (!record.renderHandle) {
8716
9028
  const paneId2 = this.routePane(id, model, record.options ?? {});
@@ -8720,7 +9032,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
8720
9032
  record.renderHandle = this.renderer.mountIndicator(model);
8721
9033
  record.pendingStructural = false;
8722
9034
  this.announce(record, handle);
8723
- return;
9035
+ return true;
8724
9036
  }
8725
9037
  let paneId = record.model?.paneId ?? "price";
8726
9038
  const prevOwnScale = record.model?.ownScale === true;
@@ -8748,6 +9060,11 @@ var EngineOrchestrator = class _EngineOrchestrator {
8748
9060
  }
8749
9061
  if (record.loading) this.setLoading(record, false);
8750
9062
  this.announce(record, handle);
9063
+ return true;
9064
+ }
9065
+ /** True when the model carries ANY executed output — series, drawings, bar colors, or trades. */
9066
+ static modelHasOutput(model) {
9067
+ return model.series.length > 0 || model.fills.length > 0 || model.backgrounds.length > 0 || model.priceLines.length > 0 || (model.lines?.length ?? 0) > 0 || (model.boxes?.length ?? 0) > 0 || (model.labels?.length ?? 0) > 0 || (model.polylines?.length ?? 0) > 0 || (model.linefills?.length ?? 0) > 0 || (model.tables?.length ?? 0) > 0 || (model.barColors?.length ?? 0) > 0 || (model.trades?.length ?? 0) > 0;
8751
9068
  }
8752
9069
  routePane(id, model, options) {
8753
9070
  if (options.pane === "new") return `pane-${id}`;
@@ -9450,6 +9767,7 @@ function intervalMs(timeframe) {
9450
9767
  "4h": 144e5,
9451
9768
  "1d": 864e5,
9452
9769
  "1w": 6048e5,
9770
+ "1M": 2592e6,
9453
9771
  "1": 6e4,
9454
9772
  "5": 3e5,
9455
9773
  "15": 9e5,
@@ -9457,7 +9775,8 @@ function intervalMs(timeframe) {
9457
9775
  "60": 36e5,
9458
9776
  "240": 144e5,
9459
9777
  D: 864e5,
9460
- W: 6048e5
9778
+ W: 6048e5,
9779
+ M: 2592e6
9461
9780
  };
9462
9781
  return map[timeframe] ?? 36e5;
9463
9782
  }
@@ -18644,7 +18963,9 @@ var DrawingSceneRenderer = class {
18644
18963
  const lo = Math.min(from, to);
18645
18964
  const hi = Math.max(from, to);
18646
18965
  const visible = (a, b, extend) => {
18647
- if (extend !== "none") return true;
18966
+ if (extend === "both") return true;
18967
+ if (extend === "left") return Math.max(a, b) >= lo;
18968
+ if (extend === "right") return Math.min(a, b) <= hi;
18648
18969
  return Math.max(a, b) >= lo && Math.min(a, b) <= hi;
18649
18970
  };
18650
18971
  let min = Infinity;
@@ -21208,6 +21529,17 @@ var DrawingPainter = class {
21208
21529
  constructor() {
21209
21530
  /** The current `paintAll` call's interaction state, visible to the per-type painters. */
21210
21531
  this.targets = {};
21532
+ /** The chart's active series LOOK — style + resolved series colors — pushed by the
21533
+ * controller before each paint. The magnifier's inset mirrors both: candles/bars/line/
21534
+ * area restyle the paint (bar-transform styles like Heikin Ashi transform the fetched
21535
+ * bars; unknown/custom styles fall back to candles), and the colors default to the main
21536
+ * series' own so the inset reads as a finer copy of the chart. */
21537
+ this.seriesLook = {
21538
+ style: "candles",
21539
+ upColor: BULLISH,
21540
+ downColor: BEARISH,
21541
+ lineColor: BULLISH
21542
+ };
21211
21543
  }
21212
21544
  /** Paint every visible drawing, then selection handles for the targeted ones.
21213
21545
  * Each drawing is clipped to its own pane's rect (and skipped entirely while that pane
@@ -21252,6 +21584,8 @@ var DrawingPainter = class {
21252
21584
  ctx.globalAlpha = GHOST_ALPHA;
21253
21585
  if (ghost instanceof RegressionChannel || ghost instanceof FixedRangeVolumeProfile) {
21254
21586
  this.paintTimeSpanGhost(ctx, ghost, proj);
21587
+ } else if (ghost instanceof Magnifier) {
21588
+ this.paintMagnifierGhost(ctx, ghost, proj, theme);
21255
21589
  } else this.paintOne(ctx, ghost, proj, theme);
21256
21590
  ctx.globalAlpha = 1;
21257
21591
  }
@@ -21385,6 +21719,10 @@ var DrawingPainter = class {
21385
21719
  this.paintLabel(ctx, d, proj, theme);
21386
21720
  return;
21387
21721
  }
21722
+ if (d instanceof Magnifier) {
21723
+ this.paintMagnifier(ctx, d, proj, theme);
21724
+ return;
21725
+ }
21388
21726
  if (d instanceof PatternDrawing) {
21389
21727
  this.paintPattern(ctx, d, proj, theme);
21390
21728
  return;
@@ -21877,6 +22215,216 @@ var DrawingPainter = class {
21877
22215
  ctx.textBaseline = "alphabetic";
21878
22216
  }
21879
22217
  }
22218
+ /** Placement preview for the magnifier: a dashed rectangle outline only — no backdrop and
22219
+ * no series read, so dragging the area open never kicks a fetch per cursor move. */
22220
+ paintMagnifierGhost(ctx, d, proj, theme) {
22221
+ const r = d.rect(proj);
22222
+ if (!r) return;
22223
+ ctx.save();
22224
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
22225
+ ctx.lineWidth = 1;
22226
+ ctx.setLineDash([4, 4]);
22227
+ ctx.strokeRect(Math.min(r.x1, r.x2), Math.min(r.y1, r.y2), Math.abs(r.x2 - r.x1), Math.abs(r.y2 - r.y1));
22228
+ ctx.restore();
22229
+ }
22230
+ /** Paint a magnifier: an opaque theme-background inset whose interior shows the chart's
22231
+ * market at a finer timeframe — candles at their true time/price positions, clipped to
22232
+ * the rectangle. Bars come through `Projector.seriesInRange` (cache read; `loading` and
22233
+ * `unavailable` states paint a short notice instead). The lower-timeframe candles shift
22234
+ * half a chart bar LEFT of their raw time pixel so each chart candle's visual cell —
22235
+ * centered on its open time — subdivides in place. */
22236
+ paintMagnifier(ctx, d, proj, theme) {
22237
+ const r = d.rect(proj);
22238
+ const a = d.anchors[0];
22239
+ const b = d.anchors[1];
22240
+ if (!r || !a || !b) return;
22241
+ const x0 = Math.min(r.x1, r.x2);
22242
+ const x1 = Math.max(r.x1, r.x2);
22243
+ const y0 = Math.min(r.y1, r.y2);
22244
+ const y1 = Math.max(r.y1, r.y2);
22245
+ const w = x1 - x0;
22246
+ const h = y1 - y0;
22247
+ ctx.save();
22248
+ ctx.globalAlpha = 1;
22249
+ ctx.fillStyle = theme.background;
22250
+ ctx.fillRect(x0, y0, w, h);
22251
+ ctx.restore();
22252
+ const from = Math.min(a.time, b.time);
22253
+ const to = Math.max(a.time, b.time);
22254
+ const chartBars = proj.barsBetween ? proj.barsBetween(from, to) : 0;
22255
+ const chartMs = chartBars > 0 ? (to - from) / chartBars : 0;
22256
+ const res = proj.seriesInRange && chartMs > 0 && w > 1 && h > 1 ? proj.seriesInRange(d.magnifier.timeframe, from, to + chartMs) : void 0;
22257
+ let seriesBars = res?.state === "ready" || res?.state === "loading" ? res.bars ?? [] : [];
22258
+ if (res && (res.state === "ready" || res.state === "loading") && seriesBars.length > 0) {
22259
+ const look = this.seriesLook;
22260
+ const transform = look.style !== "candles" ? barTransformFor(look.style) : null;
22261
+ if (transform) seriesBars = transform.full(seriesBars);
22262
+ const mode = look.style === "bars" ? "bars" : look.style === "line" || look.style === "baseline" ? "line" : look.style === "area" ? "area" : "candles";
22263
+ const halfPitch = (proj.xOf(from + chartMs) - proj.xOf(from)) / 2;
22264
+ ctx.save();
22265
+ ctx.beginPath();
22266
+ ctx.rect(x0, y0, w, h);
22267
+ ctx.clip();
22268
+ if (mode === "line" || mode === "area") {
22269
+ this.paintMagnifierLine(ctx, d, proj, seriesBars, res.barMs, halfPitch, y1, mode === "area", d.magnifier.upColor || look.lineColor);
22270
+ } else {
22271
+ this.paintMagnifierBars(ctx, d, proj, seriesBars, res.barMs, halfPitch, x0, x1, mode, d.magnifier.upColor || look.upColor, d.magnifier.downColor || look.downColor);
22272
+ }
22273
+ ctx.restore();
22274
+ } else if (res) {
22275
+ const notice = res.state === "loading" ? "Loading\u2026" : res.state === "ready" ? "No lower-timeframe data" : res.reason === "too-wide" ? "Area too wide for this timeframe" : res.reason === "none-lower" ? "No lower timeframe available" : res.reason === "not-lower" ? "Pick a timeframe below the chart" : "No data source";
22276
+ this.paintMagnifierNotice(ctx, notice, x0, y0, w, h, theme);
22277
+ }
22278
+ ctx.save();
22279
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
22280
+ ctx.lineWidth = d.style.lineWidth || 1;
22281
+ ctx.setLineDash(dashPattern(d.style.lineStyle, d.style.lineWidth || 1));
22282
+ ctx.strokeRect(x0, y0, w, h);
22283
+ ctx.restore();
22284
+ if (w > 44) {
22285
+ const label = magnifierTimeframeLabel(res?.state === "ready" || res?.state === "loading" ? res.timeframe : d.magnifier.timeframe);
22286
+ const chipH = 17;
22287
+ const gap = 4;
22288
+ const pane = proj.paneRect?.(d.paneId);
22289
+ const paneBottom = pane ? pane.top + pane.height : proj.height;
22290
+ const below = y1 + gap + chipH <= paneBottom;
22291
+ const chipY = below ? y1 + gap : y1 - gap - chipH;
22292
+ ctx.save();
22293
+ ctx.font = `10px ${theme.fontFamily}`;
22294
+ const tw = ctx.measureText(label).width;
22295
+ const caretW = 11;
22296
+ const chipW = tw + 12 + caretW;
22297
+ roundRect(ctx, x0, chipY, chipW, chipH, 3);
22298
+ ctx.fillStyle = theme.background;
22299
+ ctx.fill();
22300
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
22301
+ ctx.lineWidth = 1;
22302
+ ctx.setLineDash([]);
22303
+ ctx.stroke();
22304
+ ctx.fillStyle = theme.textColor;
22305
+ ctx.textAlign = "left";
22306
+ ctx.textBaseline = "middle";
22307
+ ctx.fillText(label, x0 + 6, chipY + chipH / 2 + 0.5);
22308
+ const cxr = x0 + 6 + tw + 5;
22309
+ const cyr = chipY + chipH / 2;
22310
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.7);
22311
+ ctx.lineWidth = 1.2;
22312
+ ctx.beginPath();
22313
+ ctx.moveTo(cxr, cyr - 1.5);
22314
+ ctx.lineTo(cxr + 2.5, cyr + 1.5);
22315
+ ctx.lineTo(cxr + 5, cyr - 1.5);
22316
+ ctx.stroke();
22317
+ ctx.restore();
22318
+ d.chipRect = { x: x0, y: chipY, w: chipW, h: chipH };
22319
+ } else {
22320
+ d.chipRect = null;
22321
+ }
22322
+ }
22323
+ /** The magnifier's candle/bar loop: each bar's cell spans its open→close time (shifted left
22324
+ * by half a chart bar). Candles: wick always, body once the cell is wide enough to carry
22325
+ * one. OHLC bars: the high–low spine with open/close ticks once the cell has the room. */
22326
+ paintMagnifierBars(ctx, d, proj, bars, barMs, halfPitch, x0, x1, mode, upColor, downColor) {
22327
+ ctx.setLineDash([]);
22328
+ ctx.lineWidth = 1;
22329
+ for (const bar of bars) {
22330
+ const cx0 = proj.xOf(bar.time) - halfPitch;
22331
+ const cx1 = proj.xOf(bar.time + barMs) - halfPitch;
22332
+ if (cx1 < x0 || cx0 > x1) continue;
22333
+ const yHigh = proj.yOf(bar.high, d.paneId);
22334
+ const yLow = proj.yOf(bar.low, d.paneId);
22335
+ const yOpen = proj.yOf(bar.open, d.paneId);
22336
+ const yClose = proj.yOf(bar.close, d.paneId);
22337
+ if (yHigh == null || yLow == null || yOpen == null || yClose == null) continue;
22338
+ const color = bar.close >= bar.open ? upColor : downColor;
22339
+ const cellW = cx1 - cx0;
22340
+ const cx = (cx0 + cx1) / 2;
22341
+ ctx.strokeStyle = color;
22342
+ ctx.beginPath();
22343
+ ctx.moveTo(cx, yHigh);
22344
+ ctx.lineTo(cx, yLow);
22345
+ ctx.stroke();
22346
+ if (cellW < 3) continue;
22347
+ if (mode === "bars") {
22348
+ const tick = Math.max(1, cellW * 0.35);
22349
+ ctx.beginPath();
22350
+ ctx.moveTo(cx - tick, yOpen);
22351
+ ctx.lineTo(cx, yOpen);
22352
+ ctx.moveTo(cx, yClose);
22353
+ ctx.lineTo(cx + tick, yClose);
22354
+ ctx.stroke();
22355
+ } else {
22356
+ const bw = Math.max(1, cellW * 0.7);
22357
+ ctx.fillStyle = color;
22358
+ ctx.fillRect(cx - bw / 2, Math.min(yOpen, yClose), bw, Math.max(1, Math.abs(yClose - yOpen)));
22359
+ }
22360
+ }
22361
+ }
22362
+ /** The magnifier's line/area rendering: a close polyline through each cell's center (same
22363
+ * half-chart-bar shift as the candles), with an optional translucent fill down to the
22364
+ * rectangle's bottom edge for the area style. Colored like the chart's own line series. */
22365
+ paintMagnifierLine(ctx, d, proj, bars, barMs, halfPitch, yBottom, area, color) {
22366
+ const pts = [];
22367
+ for (const bar of bars) {
22368
+ const y = proj.yOf(bar.close, d.paneId);
22369
+ if (y == null) continue;
22370
+ pts.push([proj.xOf(bar.time + barMs / 2) - halfPitch, y]);
22371
+ }
22372
+ if (pts.length < 2) return;
22373
+ if (area) {
22374
+ ctx.beginPath();
22375
+ ctx.moveTo(pts[0][0], yBottom);
22376
+ for (const [px, py] of pts) ctx.lineTo(px, py);
22377
+ ctx.lineTo(pts[pts.length - 1][0], yBottom);
22378
+ ctx.closePath();
22379
+ ctx.fillStyle = withAlpha(color, 0.15);
22380
+ ctx.fill();
22381
+ }
22382
+ ctx.setLineDash([]);
22383
+ ctx.lineWidth = 1.5;
22384
+ ctx.strokeStyle = color;
22385
+ ctx.beginPath();
22386
+ ctx.moveTo(pts[0][0], pts[0][1]);
22387
+ for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
22388
+ ctx.stroke();
22389
+ }
22390
+ /** Centered muted notice inside the magnifier rect (loading / unavailable states). */
22391
+ paintMagnifierNotice(ctx, text, x0, y0, w, h, theme) {
22392
+ if (w < 60 || h < 20) return;
22393
+ ctx.save();
22394
+ ctx.beginPath();
22395
+ ctx.rect(x0, y0, w, h);
22396
+ ctx.clip();
22397
+ ctx.font = `11px ${theme.fontFamily}`;
22398
+ ctx.fillStyle = withAlpha(theme.textColor, 0.55);
22399
+ ctx.textAlign = "center";
22400
+ ctx.textBaseline = "middle";
22401
+ ctx.fillText(text, x0 + w / 2, y0 + h / 2);
22402
+ ctx.restore();
22403
+ }
22404
+ /** A bottom-center pill prompting the armed tool's placement gesture (e.g. the magnifier's
22405
+ * "drag an area"). Painted by the drawings layer while the tool is armed and no placement
22406
+ * is in progress; chart-background fill so it reads as chrome over any content. */
22407
+ paintPlacementHint(ctx, text, theme, width, height) {
22408
+ ctx.save();
22409
+ ctx.font = `11px ${theme.fontFamily}`;
22410
+ const tw = ctx.measureText(text).width;
22411
+ const pillW = tw + 24;
22412
+ const pillH = 24;
22413
+ const x = (width - pillW) / 2;
22414
+ const y = height - pillH - 14;
22415
+ roundRect(ctx, x, y, pillW, pillH, pillH / 2);
22416
+ ctx.fillStyle = theme.background;
22417
+ ctx.fill();
22418
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
22419
+ ctx.lineWidth = 1;
22420
+ ctx.setLineDash([]);
22421
+ ctx.stroke();
22422
+ ctx.fillStyle = theme.textColor;
22423
+ ctx.textAlign = "center";
22424
+ ctx.textBaseline = "middle";
22425
+ ctx.fillText(text, width / 2, y + pillH / 2 + 0.5);
22426
+ ctx.restore();
22427
+ }
21880
22428
  /** Paint a fixed-range volume profile: horizontal histogram rows (up/down split) anchored to
21881
22429
  * the left or right of the time span, optional VAH / VAL / POC levels across the range, and
21882
22430
  * optional developing POC / VA polylines. Recomputes from the two anchors on every paint. */
@@ -23292,8 +23840,9 @@ function ensureStyles3() {
23292
23840
  if (!existing) document.head.appendChild(s);
23293
23841
  }
23294
23842
  var DrawingSettingsPopup = class {
23295
- constructor(host, theme) {
23843
+ constructor(host, theme, chartBarMs = () => 0) {
23296
23844
  this.host = host;
23845
+ this.chartBarMs = chartBarMs;
23297
23846
  this.el = null;
23298
23847
  this.tipEl = null;
23299
23848
  // floating hover-label (above/below the toolbar)
@@ -23318,6 +23867,14 @@ var DrawingSettingsPopup = class {
23318
23867
  this.theme = theme;
23319
23868
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
23320
23869
  }
23870
+ /** The magnifier timeframe choices strictly below the chart's own bar duration
23871
+ * (`auto` rides along while at least one concrete lower step exists). */
23872
+ lowerTimeframeOptions() {
23873
+ const chartMs = this.chartBarMs();
23874
+ if (!(chartMs > 0)) return [...MAGNIFIER_TIMEFRAME_OPTIONS];
23875
+ const lower = MAGNIFIER_TIMEFRAME_OPTIONS.filter((o) => o.ms > 0 && o.ms < chartMs);
23876
+ return lower.length > 0 ? [MAGNIFIER_TIMEFRAME_OPTIONS[0], ...lower] : [];
23877
+ }
23321
23878
  setTheme(theme) {
23322
23879
  this.theme = theme;
23323
23880
  this.settingsDialog.setTheme(theme);
@@ -23360,7 +23917,20 @@ var DrawingSettingsPopup = class {
23360
23917
  const sz = drawing.size ?? "normal";
23361
23918
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
23362
23919
  }
23363
- if (paths.has("style.lineColor")) bar.appendChild(this.colorButton("Line color", BRUSH_ICON, drawing.style.lineColor || DEFAULT_DRAWING_COLOR, (v) => actions.patch({ "style.lineColor": v })));
23920
+ if (paths.has("magnifier.timeframe") && drawing instanceof Magnifier) {
23921
+ const options = this.lowerTimeframeOptions();
23922
+ if (options.length > 0) {
23923
+ bar.appendChild(
23924
+ this.dropdown("Lower timeframe", options.map((o) => o.value), drawing.magnifier.timeframe, () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
23925
+ label: (v) => magnifierTimeframeLabel(String(v)),
23926
+ labelInTrigger: true
23927
+ })
23928
+ );
23929
+ }
23930
+ bar.appendChild(this.colorButton("Up candles", BUCKET_ICON, drawing.magnifier.upColor || t.upColor, (v) => actions.patch({ "magnifier.upColor": v })));
23931
+ bar.appendChild(this.colorButton("Down candles", BUCKET_ICON, drawing.magnifier.downColor || t.downColor, (v) => actions.patch({ "magnifier.downColor": v })));
23932
+ }
23933
+ if (paths.has("style.lineColor")) bar.appendChild(this.colorButton("Line color", BRUSH_ICON, drawing.style.lineColor || (drawing instanceof Magnifier ? contrastColor(this.theme.background) : DEFAULT_DRAWING_COLOR), (v) => actions.patch({ "style.lineColor": v })));
23364
23934
  if (paths.has("style.lineWidth")) {
23365
23935
  const wf = schema.fields.find((f) => f.path === "style.lineWidth");
23366
23936
  if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
@@ -23682,6 +24252,7 @@ var DrawingSettingsPopup = class {
23682
24252
  this.colorPop = null;
23683
24253
  this.colorOwner = null;
23684
24254
  }
24255
+ opts.onClose?.();
23685
24256
  }
23686
24257
  });
23687
24258
  const el = pop.el;
@@ -23700,6 +24271,49 @@ var DrawingSettingsPopup = class {
23700
24271
  pop.show();
23701
24272
  return pop;
23702
24273
  }
24274
+ /**
24275
+ * A standalone timeframe menu for the magnifier's ON-CHART chip. The chip lives on
24276
+ * canvas, so a transient invisible anchor is dropped at its pixel rect for the popover
24277
+ * to position against, and removed again when the menu closes. Independent of the
24278
+ * quick toolbar — the chip works without selecting the drawing first.
24279
+ */
24280
+ openMagnifierTimeframeMenu(rect, current, onPick) {
24281
+ ensureStyles3();
24282
+ closeOpenPopovers();
24283
+ const options = this.lowerTimeframeOptions();
24284
+ const anchor = document.createElement("div");
24285
+ anchor.style.cssText = `position:absolute;left:${rect.x}px;top:${rect.y}px;width:${rect.w}px;height:${rect.h}px;pointer-events:none;`;
24286
+ this.host.appendChild(anchor);
24287
+ this.menuPop = this.hostFloat(anchor, {
24288
+ zIndex: 26,
24289
+ padding: "4px",
24290
+ onClose: () => anchor.remove(),
24291
+ fill: (menu2, pop) => {
24292
+ if (options.length === 0) {
24293
+ const note = document.createElement("div");
24294
+ note.style.cssText = "padding:6px 10px;opacity:0.65;white-space:nowrap;";
24295
+ note.textContent = "No lower timeframe available";
24296
+ menu2.appendChild(note);
24297
+ return;
24298
+ }
24299
+ for (const o of options) {
24300
+ const item = document.createElement("button");
24301
+ item.type = "button";
24302
+ item.className = "vela-dpop-item";
24303
+ item.dataset.active = o.value === current ? "1" : "0";
24304
+ item.style.cssText = "display:flex;align-items:center;min-width:88px;padding:5px 10px;border:none;border-radius:5px;color:inherit;cursor:pointer;text-align:left;font:inherit;font-variant-numeric:tabular-nums;";
24305
+ item.textContent = o.label;
24306
+ item.addEventListener("click", (e) => {
24307
+ e.stopPropagation();
24308
+ pop.hide();
24309
+ onPick(o.value);
24310
+ });
24311
+ menu2.appendChild(item);
24312
+ }
24313
+ }
24314
+ });
24315
+ this.menuOwner = anchor;
24316
+ }
23703
24317
  /** A floating list of one-shot actions (icon + label rows) opened by the kebab. */
23704
24318
  openActionMenu(anchor, rows) {
23705
24319
  this.menuPop = this.hostFloat(anchor, {
@@ -23810,10 +24424,13 @@ var DrawingSettingsPopup = class {
23810
24424
  let cur = current;
23811
24425
  const paint = (v) => {
23812
24426
  b.replaceChildren();
23813
- const ic = document.createElement("span");
23814
- ic.style.cssText = "display:flex;";
23815
- ic.innerHTML = sized(render(v));
23816
- b.appendChild(ic);
24427
+ const glyph = render(v);
24428
+ if (glyph) {
24429
+ const ic = document.createElement("span");
24430
+ ic.style.cssText = "display:flex;";
24431
+ ic.innerHTML = sized(glyph);
24432
+ b.appendChild(ic);
24433
+ }
23817
24434
  if (opts.label && opts.labelInTrigger) {
23818
24435
  const tx = document.createElement("span");
23819
24436
  tx.textContent = opts.label(v);
@@ -23855,10 +24472,13 @@ var DrawingSettingsPopup = class {
23855
24472
  item.className = "vela-dpop-item";
23856
24473
  item.dataset.active = active ? "1" : "0";
23857
24474
  item.style.cssText = `display:flex;align-items:center;gap:8px;${label ? "min-width:118px;" : ""}padding:5px 8px;border:none;border-radius:5px;color:inherit;cursor:pointer;text-align:left;font:inherit;`;
23858
- const ic = document.createElement("span");
23859
- ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
23860
- ic.innerHTML = sized(render(v), 18);
23861
- item.appendChild(ic);
24475
+ const glyph = render(v);
24476
+ if (glyph) {
24477
+ const ic = document.createElement("span");
24478
+ ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
24479
+ ic.innerHTML = sized(glyph, 18);
24480
+ item.appendChild(ic);
24481
+ }
23862
24482
  if (label) {
23863
24483
  const tx = document.createElement("span");
23864
24484
  tx.textContent = label(v);
@@ -24842,6 +25462,9 @@ var UserDrawingController = class {
24842
25462
  this.intentCb = null;
24843
25463
  /** Another chart's in-progress placement, mirrored here as a ghost (drawings sync). */
24844
25464
  this.externalGhost = null;
25465
+ /** Core-pushed series gateway (finer-timeframe bars for data-driven drawings). */
25466
+ this.seriesGw = null;
25467
+ this.seriesGwUnsub = null;
24845
25468
  /** Last draft fingerprint reported upstream — gates the per-render emission to actual changes. */
24846
25469
  this.lastDraftKey = null;
24847
25470
  this.measure = new MeasureOverlay();
@@ -24869,7 +25492,7 @@ var UserDrawingController = class {
24869
25492
  this.textEditor = null;
24870
25493
  this.painter = new DrawingPainter();
24871
25494
  this.ctx = canvas.getContext("2d");
24872
- this.popup = new DrawingSettingsPopup(overlayHost, deps.theme());
25495
+ this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
24873
25496
  this.toolbar = new DrawingToolbar(
24874
25497
  toolbarHost,
24875
25498
  deps.theme(),
@@ -24927,6 +25550,22 @@ var UserDrawingController = class {
24927
25550
  const shown = this.toolbarVisible && !this.mobileLayout;
24928
25551
  this.deps.setToolbarGutter(shown ? this.toolbarCollapsed ? TOOLBAR_COLLAPSED_WIDTH : TOOLBAR_WIDTH : 0);
24929
25552
  }
25553
+ /** Core push: the series gateway data-driven drawings read finer-timeframe bars
25554
+ * through (surfaced to them as `Projector.seriesInRange`). A landed background
25555
+ * fetch repaints both this layer and the interleave slices under the series. */
25556
+ setSeriesGateway(gateway) {
25557
+ this.seriesGwUnsub?.();
25558
+ this.seriesGw = gateway;
25559
+ this.seriesGwUnsub = gateway.onUpdate(() => {
25560
+ this.invalidateSlices();
25561
+ this.render();
25562
+ this.deps.requestDataPaint();
25563
+ });
25564
+ }
25565
+ /** The pushed series gateway, or null before the core provides one. */
25566
+ get seriesGateway() {
25567
+ return this.seriesGw;
25568
+ }
24930
25569
  /** Core push: mirror (or clear) another chart's in-progress placement as a ghost. */
24931
25570
  setExternalGhost(doc) {
24932
25571
  this.externalGhost = doc ? deserializeDrawing(doc) : null;
@@ -25044,8 +25683,34 @@ var UserDrawingController = class {
25044
25683
  /** Should the drawing layer win this press (vs pan)? */
25045
25684
  claim(x, y) {
25046
25685
  if (this.measureMode || this.eraserMode) return true;
25686
+ if (this.magnifierChipAt(x, y)) return true;
25047
25687
  return this.interaction.claim(x, y);
25048
25688
  }
25689
+ /** The topmost visible (unlocked) magnifier whose timeframe chip contains (x, y) —
25690
+ * the chip's rect is what the painter measured last frame. */
25691
+ magnifierChipAt(x, y) {
25692
+ for (let i = this.drawings.length - 1; i >= 0; i -= 1) {
25693
+ const d = this.drawings[i];
25694
+ if (!(d instanceof Magnifier) || !d.visible || d.locked) continue;
25695
+ const r = d.chipRect;
25696
+ if (r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return d;
25697
+ }
25698
+ return null;
25699
+ }
25700
+ /** Open the on-chart chip's timeframe menu; the pick patches the drawing like a
25701
+ * settings-popup edit (same intent, same undo step). */
25702
+ openMagnifierChipMenu(drawing) {
25703
+ const rect = drawing.chipRect;
25704
+ if (!rect) return;
25705
+ const id = drawing.id;
25706
+ this.popup.openMagnifierTimeframeMenu(rect, drawing.magnifier.timeframe, (value) => {
25707
+ const d = this.drawings.find((x) => x.id === id);
25708
+ if (!(d instanceof Magnifier)) return;
25709
+ d.applySettings({ "magnifier.timeframe": value });
25710
+ this.render();
25711
+ this.emit({ kind: "edit", doc: d.serialize() });
25712
+ });
25713
+ }
25049
25714
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
25050
25715
  * Shared by the eraser (click + drag) and the middle-click shortcut. */
25051
25716
  deleteAt(x, y) {
@@ -25091,6 +25756,13 @@ var UserDrawingController = class {
25091
25756
  this.render();
25092
25757
  return;
25093
25758
  }
25759
+ if (this.activeTool == null) {
25760
+ const chipOwner = this.magnifierChipAt(x, y);
25761
+ if (chipOwner) {
25762
+ this.openMagnifierChipMenu(chipOwner);
25763
+ return;
25764
+ }
25765
+ }
25094
25766
  this.interaction.down(x, y, snap, shift);
25095
25767
  }
25096
25768
  pointerMove(x, y, snap = "off", shift = false) {
@@ -25309,6 +25981,7 @@ var UserDrawingController = class {
25309
25981
  /** Cursor hint while hovering — `'pointer'` over a drawing/handle, else null. */
25310
25982
  cursorAt(x, y) {
25311
25983
  if (this.eraserMode) return "pointer";
25984
+ if (this.activeTool == null && this.magnifierChipAt(x, y)) return "pointer";
25312
25985
  return this.interaction.cursorAt(x, y);
25313
25986
  }
25314
25987
  /** Right-click: an explicit escape back to the pointer. Cancels an in-progress
@@ -25487,6 +26160,7 @@ var UserDrawingController = class {
25487
26160
  if (!sctx) continue;
25488
26161
  sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
25489
26162
  sctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
26163
+ this.painter.seriesLook = this.deps.seriesLook();
25490
26164
  this.painter.paintAll(sctx, drawings, proj, theme, EMPTY_TARGETS);
25491
26165
  const slices = out.get(paneId) ?? [];
25492
26166
  slices.push({ beforeZ, canvas });
@@ -25514,6 +26188,7 @@ var UserDrawingController = class {
25514
26188
  dragged: this.interaction.activeDragId(),
25515
26189
  mutedLabel: edited instanceof TextLabel ? edited.id : null
25516
26190
  };
26191
+ this.painter.seriesLook = this.deps.seriesLook();
25517
26192
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
25518
26193
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
25519
26194
  this.layoutTextEditor();
@@ -25521,6 +26196,10 @@ var UserDrawingController = class {
25521
26196
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
25522
26197
  if (this.externalGhost) this.painter.paintGhost(ctx, this.externalGhost, proj, this.deps.theme());
25523
26198
  this.emitDraft(ghost);
26199
+ if (this.activeTool && !ghost) {
26200
+ const hint = getDrawingType(this.activeTool)?.placementHint;
26201
+ if (hint) this.painter.paintPlacementHint(ctx, hint, this.deps.theme(), proj.width, proj.height);
26202
+ }
25524
26203
  const markers = this.interaction.placingMarkers(proj);
25525
26204
  if (markers) this.painter.paintHandles(ctx, markers);
25526
26205
  const m = this.interaction.snapMarker();
@@ -25532,6 +26211,9 @@ var UserDrawingController = class {
25532
26211
  this.closeTextEditor();
25533
26212
  this.popup.destroy();
25534
26213
  this.toolbar.destroy();
26214
+ this.seriesGwUnsub?.();
26215
+ this.seriesGwUnsub = null;
26216
+ this.seriesGw = null;
25535
26217
  this.intentCb = null;
25536
26218
  this.drawings = [];
25537
26219
  }
@@ -25929,7 +26611,7 @@ function mergeSlices(indicator, user) {
25929
26611
  }
25930
26612
 
25931
26613
  // src/renderers/native/drawings/Projector.ts
25932
- function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
26614
+ function createProjector(coords, paneOf, paneIdAtY, barsInRange, seriesInRange) {
25933
26615
  return {
25934
26616
  xOf: (time) => coords.timeToX(time),
25935
26617
  yOf: (price, paneId) => {
@@ -25950,6 +26632,7 @@ function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
25950
26632
  },
25951
26633
  barsBetween: (t1, t2) => Math.abs(coords.timeToLogical(t2) - coords.timeToLogical(t1)),
25952
26634
  barsInRange: barsInRange ? (from, to) => barsInRange(from, to) : void 0,
26635
+ seriesInRange,
25953
26636
  width: coords.width,
25954
26637
  height: coords.height
25955
26638
  };
@@ -27953,6 +28636,23 @@ var NativeRenderer = class {
27953
28636
  seriesBoundaries: (paneId) => this.scene.seriesBoundaries(paneId),
27954
28637
  priceZ: (paneId) => paneId === PRICE_PANE_ID ? this.scene.candleZ : null,
27955
28638
  requestDataPaint: () => this.scheduler.invalidate(3 /* Light */),
28639
+ // The look the price series ACTUALLY paints with: candle colors resolved through
28640
+ // the per-style override, line/area colors through their configured styles — so
28641
+ // series-mirroring content (the magnifier inset) matches the chart exactly.
28642
+ seriesLook: () => {
28643
+ const st = this.scene.style;
28644
+ const paint = effectiveCandlePaint(st.candle, this.scene.candleOverride, this.theme.upColor, this.theme.downColor);
28645
+ const barsUp = st.bars.upColor ?? this.theme.upColor;
28646
+ const barsDown = st.bars.downColor ?? this.theme.downColor;
28647
+ const style = this.scene.priceStyle;
28648
+ return {
28649
+ style,
28650
+ upColor: style === "bars" ? barsUp : paint.up,
28651
+ downColor: style === "bars" ? barsDown : paint.down,
28652
+ lineColor: style === "area" ? st.area.lineColor ?? this.theme.upColor : st.line.color ?? this.theme.upColor
28653
+ };
28654
+ },
28655
+ chartBarMs: () => this.coords.barInterval,
27956
28656
  snap: (pt, paneId, mode, cursorPx) => this.snapToCandle(pt, paneId, mode, cursorPx),
27957
28657
  setSnapMode: (mode) => this.setSnapMode(mode),
27958
28658
  setToolbarGutter: (px) => this.setToolbarGutter(px)
@@ -29346,7 +30046,8 @@ var NativeRenderer = class {
29346
30046
  return p ? { scale: p.scale, bounds: p.bounds, collapsed: p.collapsed } : null;
29347
30047
  },
29348
30048
  (y) => this.paneNodeAtY(y)?.id ?? null,
29349
- (from, to) => this.barsInTimeRange(from, to)
30049
+ (from, to) => this.barsInTimeRange(from, to),
30050
+ this.userDrawings?.seriesGateway ? (tf, from, to) => this.userDrawings.seriesGateway.seriesInRange(tf, from, to) : void 0
29350
30051
  );
29351
30052
  }
29352
30053
  /** OHLC bars whose open-time falls within `[from, to]` (inclusive) — the data a regression