@luxalgo/vela 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +110 -46
  2. package/dist/{DataProvider-CWmp31dA.d.ts → DataProvider-DhzstpQb.d.ts} +1 -1
  3. package/dist/{DataProvider-l_eLMly_.d.cts → DataProvider-DlMtrwqM.d.cts} +1 -1
  4. package/dist/{chunk-4KZVZ7QQ.js → chunk-F4M24ANM.js} +130 -1
  5. package/dist/{chunk-NMTQXNT4.js → chunk-IZV3CW5N.js} +245 -43
  6. package/dist/{chunk-FFOP37FQ.js → chunk-KV7FDWSL.js} +748 -70
  7. package/dist/{contributions-BCz6Dr6a.d.ts → contributions-37nni40G.d.ts} +20 -4
  8. package/dist/{contributions-D9vTzm5p.d.cts → contributions-lPojhTxI.d.cts} +20 -4
  9. package/dist/index.cjs +866 -58
  10. package/dist/index.d.cts +12 -6
  11. package/dist/index.d.ts +12 -6
  12. package/dist/index.js +3 -3
  13. package/dist/{options-DSqHsQyN.d.cts → options-CYS5Wmlx.d.cts} +78 -2
  14. package/dist/{options-DSqHsQyN.d.ts → options-CYS5Wmlx.d.ts} +78 -2
  15. package/dist/{plugin-Bt8hLR8Z.d.cts → plugin-Cwikpz1m.d.cts} +10 -3
  16. package/dist/{plugin-CH7pfMrE.d.ts → plugin-DHyaoMjW.d.ts} +10 -3
  17. package/dist/plugin.cjs +119 -0
  18. package/dist/plugin.d.cts +4 -4
  19. package/dist/plugin.d.ts +4 -4
  20. package/dist/plugin.js +2 -2
  21. package/dist/providers/binance.d.cts +2 -2
  22. package/dist/providers/binance.d.ts +2 -2
  23. package/dist/providers/coinbase.d.cts +2 -2
  24. package/dist/providers/coinbase.d.ts +2 -2
  25. package/dist/providers/hyperliquid.d.cts +2 -2
  26. package/dist/providers/hyperliquid.d.ts +2 -2
  27. package/dist/{statusline-5K7y4Ya2.d.ts → statusline-model-CiJm-riV.d.cts} +17 -87
  28. package/dist/{statusline-TYLQmoeh.d.cts → statusline-model-D-q_OOx9.d.ts} +17 -87
  29. package/dist/ui.d.cts +1 -1
  30. package/dist/ui.d.ts +1 -1
  31. package/dist/vela.global.js +866 -58
  32. package/dist/vela.global.min.js +52 -52
  33. package/dist/widget.cjs +1109 -100
  34. package/dist/widget.d.cts +118 -7
  35. package/dist/widget.d.ts +118 -7
  36. package/dist/widget.js +4 -4
  37. package/dist/workspace.cjs +1109 -100
  38. package/dist/workspace.d.cts +20 -11
  39. package/dist/workspace.d.ts +20 -11
  40. package/dist/workspace.js +3 -3
  41. package/package.json +1 -1
  42. /package/dist/{chunk-AOGZBKUE.js → chunk-H26BEHF4.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { deserializeDrawing, chartTypes, rendererLayers, foldBaseModulation, registerChartType, rendererDefaults, getDrawingType, settingsRowVisible, normalizeSettingsRow, TextLabel, Callout, resetDrawingSettings, registerNativeIndicator, chartType, getNativeIndicator, nativeIndicatorDescriptors, drawingTypes, settingsRowValueKeys, formatDuration, RegressionChannel, FixedRangeVolumeProfile, SegmentDrawing, ArrowMark, GlyphStamp, RadialFib, FibSpiral, DedekindTessellation, MachFigure, GannSquare, FibRatios, MeasureBox, PositionTool, AnchoredVwap, PatternDrawing, Comment, PriceNote, Signpost, Note, PriceLabel, GANN_SQUARE_ARCS, lineSegmentIntersection, DEFAULT_DRAWING_COLOR, GLYPH_OPTIONS, STAMP_SIZE_OPTIONS, LINE_STYLE_OPTIONS, DEDEKIND_CURVATURE_OPTIONS, MACH_NUMBER_OPTIONS, MACH_WAVE_COUNT_OPTIONS, TEXT_SIZE_OPTIONS, createDrawing, inputVisible, tickerModifierIds, CalloutBase, DIRECTION_OPTIONS } from './chunk-4KZVZ7QQ.js';
1
+ import { deserializeDrawing, chartTypes, rendererLayers, foldBaseModulation, registerChartType, rendererDefaults, getDrawingType, settingsRowVisible, normalizeSettingsRow, Magnifier, TextLabel, Callout, resetDrawingSettings, registerNativeIndicator, chartType, getNativeIndicator, nativeIndicatorDescriptors, drawingTypes, settingsRowValueKeys, formatDuration, RegressionChannel, FixedRangeVolumeProfile, SegmentDrawing, ArrowMark, GlyphStamp, RadialFib, FibSpiral, DedekindTessellation, MachFigure, GannSquare, FibRatios, MeasureBox, PositionTool, AnchoredVwap, PatternDrawing, Comment, PriceNote, Signpost, Note, PriceLabel, GANN_SQUARE_ARCS, magnifierTimeframeLabel, lineSegmentIntersection, DEFAULT_DRAWING_COLOR, MAGNIFIER_TIMEFRAME_OPTIONS, GLYPH_OPTIONS, STAMP_SIZE_OPTIONS, LINE_STYLE_OPTIONS, DEDEKIND_CURVATURE_OPTIONS, MACH_NUMBER_OPTIONS, MACH_WAVE_COUNT_OPTIONS, TEXT_SIZE_OPTIONS, createDrawing, inputVisible, tickerModifierIds, CalloutBase, DIRECTION_OPTIONS } from './chunk-F4M24ANM.js';
2
2
  import { themeTokens, Dialog, closeOpenPopovers, closeWidthPopover, fieldSection, buildFieldControl, fieldSeparator, fieldGrid, fieldRow, blendOver, splitColor, CalloutBubble, Menu, TextArea, Popover, NumberInput, buildColorPicker, isPopoverOpen, eventDismissedPopover, toggleSelectList, openPopoverTrigger, fieldGridColumns, FIELD_GAP_PX, STATIC_TOKENS } from './chunk-MHY7MVXH.js';
3
3
  import { icon, iconAt, withAlpha, WARNING, ACCENT, NEUTRAL, BEARISH, BULLISH, isDarkColor, SERIES_LINE, SLATE, CROSSHAIR, TRADE_EXIT, TRADE_SHORT, TRADE_LONG, overlayScrollbarCss, SLATE_DEEP, VALID, INVALID, FIELD_FOCUS_CSS, FIELD_FOCUS_RING, svg24, svg24Solid } from './chunk-KG4YT3TI.js';
4
4
 
@@ -62,7 +62,7 @@ var GEOMETRY_TYPES = ["dedekind", "sonic", "supersonic", "goldensonic", "goldens
62
62
  var PATTERN_TYPES = ["xabcd", "abcd", "headshoulders"];
63
63
  var ELLIOTT_TYPES = ["elliottimpulse", "elliottcorrection"];
64
64
  var HARMONIC_TYPES = ["gartley", "bat", "butterfly", "crab", "shark", "cypher"];
65
- var MEASUREMENT_TYPES = ["position", "datepricerange"];
65
+ var MEASUREMENT_TYPES = ["position", "datepricerange", "magnifier"];
66
66
  var VOLUME_TYPES = ["anchoredvwap", "fixedrangevp"];
67
67
  var BRUSH_TYPES = ["freehand", "highlighter"];
68
68
  var ARROW_TYPES = ["arrow", "arrowmarkup", "arrowmarkdown"];
@@ -1138,6 +1138,7 @@ function intervalMs(timeframe) {
1138
1138
  "4h": 144e5,
1139
1139
  "1d": 864e5,
1140
1140
  "1w": 6048e5,
1141
+ "1M": 2592e6,
1141
1142
  "1": 6e4,
1142
1143
  "5": 3e5,
1143
1144
  "15": 9e5,
@@ -1145,7 +1146,8 @@ function intervalMs(timeframe) {
1145
1146
  "60": 36e5,
1146
1147
  "240": 144e5,
1147
1148
  D: 864e5,
1148
- W: 6048e5
1149
+ W: 6048e5,
1150
+ M: 2592e6
1149
1151
  };
1150
1152
  return map[timeframe] ?? 36e5;
1151
1153
  }
@@ -1859,7 +1861,7 @@ var DrawingHistory = class {
1859
1861
 
1860
1862
  // src/core/drawings/DrawingController.ts
1861
1863
  var DrawingController = class {
1862
- constructor(renderer, events, option) {
1864
+ constructor(renderer, events, option, seriesGateway) {
1863
1865
  this.events = events;
1864
1866
  this.store = new DrawingStore();
1865
1867
  this.history = new DrawingHistory();
@@ -1885,6 +1887,7 @@ var DrawingController = class {
1885
1887
  const { definition, visible } = buildToolbar(option);
1886
1888
  this.port.setToolbar(definition);
1887
1889
  this.port.showToolbar(visible);
1890
+ if (seriesGateway) this.port.setSeriesGateway?.(seriesGateway);
1888
1891
  this.subs.push(this.port.onDrawingIntent((i) => this.onIntent(i)));
1889
1892
  this.subs.push(this.store.onChange(() => this.sync()));
1890
1893
  }
@@ -1990,7 +1993,7 @@ var DrawingController = class {
1990
1993
  style,
1991
1994
  text: init.text,
1992
1995
  props: init.props,
1993
- zIndex: init.zIndex ?? this.startZ(init.paneId ?? "price")
1996
+ zIndex: init.zIndex ?? this.startZ(type, init.paneId ?? "price")
1994
1997
  });
1995
1998
  if (!d) return null;
1996
1999
  this.history.record(this.store.serialize());
@@ -2065,10 +2068,14 @@ var DrawingController = class {
2065
2068
  * (falling back to just under the pane's top series where there is no price — a study
2066
2069
  * pane). Half a key down never ties a series; drawings tying each other paint in insertion
2067
2070
  * order, so consecutive new drawings still stack newest-in-front. Undefined without a
2068
- * shared z space — the store then places it over the other drawings, its own layer's top. */
2069
- startZ(paneId) {
2071
+ * shared z space — the store then places it over the other drawings, its own layer's top.
2072
+ * A type that COVERS the series (an opaque inset, `coversSeries`) instead starts just
2073
+ * above the whole stack — under the candles its content would be buried. */
2074
+ startZ(type, paneId) {
2070
2075
  const range = this.port?.stackRange?.(paneId);
2071
- return range ? (range.price ?? range.front) - 0.5 : void 0;
2076
+ if (!range) return void 0;
2077
+ if (getDrawingType(type)?.coversSeries) return range.front + 0.5;
2078
+ return (range.price ?? range.front) - 0.5;
2072
2079
  }
2073
2080
  /** Programmatically select drawings (host UI → chart): shows the on-chart handles + toolbar.
2074
2081
  * `additive` toggles membership (matching shift-click) instead of replacing. */
@@ -2213,7 +2220,7 @@ var DrawingController = class {
2213
2220
  const style = last ? { ...i.doc.style, ...last } : i.doc.style;
2214
2221
  const d = deserializeDrawing({ ...i.doc, id: this.store.nextId(), style });
2215
2222
  if (!d) return;
2216
- if (!d.zIndex) d.zIndex = this.startZ(d.paneId) ?? 0;
2223
+ if (!d.zIndex) d.zIndex = this.startZ(d.type, d.paneId) ?? 0;
2217
2224
  this.history.record(before);
2218
2225
  this.store.add(d);
2219
2226
  this.captureStyle(d.id);
@@ -6859,6 +6866,7 @@ var DOUBLE_TAP_MS = 350;
6859
6866
  var DOUBLE_TAP_SLOP = 30;
6860
6867
  var TIME_SCALE_K = 4e-3;
6861
6868
  var WHEEL_ZOOM_K = 4e-3;
6869
+ var WHEEL_PRICE_DRAG_PX = 0.25;
6862
6870
  function wheelZoomAnchor(coords, cursorX, rightEdge) {
6863
6871
  if (rightEdge) return { logical: coords.rightEdgeLogical, x: coords.width };
6864
6872
  return { logical: coords.xToLogical(cursorX), x: cursorX };
@@ -6989,7 +6997,7 @@ var InputController = class {
6989
6997
  this.capture(e.pointerId);
6990
6998
  return;
6991
6999
  }
6992
- if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y)) {
7000
+ if (e.shiftKey && this.regionAt(x, y) === "data" && this.deps.drawingsMeasureStart?.(x, y, this.snapMode(e))) {
6993
7001
  this.region = "drawing";
6994
7002
  this.capture(e.pointerId);
6995
7003
  return;
@@ -7103,7 +7111,7 @@ var InputController = class {
7103
7111
  const wasTouch = e.pointerType === "touch";
7104
7112
  const tapRelease = this.dragging && !this.moved && (!wasTouch || Math.hypot(x - this.startX, y - this.startY) <= TOUCH_TAP_SLOP);
7105
7113
  if (this.dragging && this.region === "drawing") {
7106
- this.deps.drawingsPointerUp?.(x, y);
7114
+ this.deps.drawingsPointerUp?.(x, y, this.snapMode(e));
7107
7115
  } else if (tapRelease && this.region === "data") {
7108
7116
  this.deps.onClick(x, y);
7109
7117
  } else if (this.dragging && this.region === "data") {
@@ -7124,7 +7132,7 @@ var InputController = class {
7124
7132
  if (e.pointerType === "touch") this.touches.delete(e.pointerId);
7125
7133
  this.cancelLongPress();
7126
7134
  if (!this.dragging) return;
7127
- if (this.region === "drawing" && !Number.isNaN(this.cursorX)) this.deps.drawingsPointerUp?.(this.cursorX, this.cursorY);
7135
+ if (this.region === "drawing" && !Number.isNaN(this.cursorX)) this.deps.drawingsPointerUp?.(this.cursorX, this.cursorY, this.snapMode(e));
7128
7136
  if (this.region === "crosshair" || e.pointerType === "touch") this.deps.onPointerMove(null, null);
7129
7137
  this.endGesture(e);
7130
7138
  };
@@ -7140,6 +7148,12 @@ var InputController = class {
7140
7148
  this.onWheel = (e) => {
7141
7149
  e.preventDefault();
7142
7150
  this.deps.drawingsClearTransient?.();
7151
+ const { x, y } = this.local(e);
7152
+ if (this.regionAt(x, y) === "price" && e.deltaY !== 0) {
7153
+ this.deps.beginPriceScale(x, y);
7154
+ this.deps.priceScaleBy(e.deltaY * WHEEL_PRICE_DRAG_PX);
7155
+ return;
7156
+ }
7143
7157
  const coords = this.deps.getCoords();
7144
7158
  const vp = coords.getViewport();
7145
7159
  const pan = wheelPanDelta(e.deltaX, e.deltaY, e.shiftKey);
@@ -7147,9 +7161,8 @@ var InputController = class {
7147
7161
  this.deps.apply({ barSpacing: vp.barSpacing, rightOffset: wheelPanRightOffset(vp.rightOffset, pan, coords.pxPerBar()) });
7148
7162
  return;
7149
7163
  }
7150
- const cursorX = this.local(e).x;
7151
7164
  const rightEdge = this.rightEdgeZoom && !(e.ctrlKey || e.metaKey);
7152
- const anchor = wheelZoomAnchor(coords, cursorX, rightEdge);
7165
+ const anchor = wheelZoomAnchor(coords, x, rightEdge);
7153
7166
  const target = clampBarSpacing(vp.barSpacing * Math.exp(-e.deltaY * WHEEL_ZOOM_K));
7154
7167
  this.deps.zoomTo(target, anchor.logical, anchor.x);
7155
7168
  };
@@ -8631,6 +8644,15 @@ function drawingSetEmpty(s) {
8631
8644
  function fontSizePx(size) {
8632
8645
  return size === "auto" ? 12 : namedFontSize(size);
8633
8646
  }
8647
+ function lineCoversWindow(a, b, extend, lo, hi) {
8648
+ const minX = Math.min(a, b);
8649
+ const maxX = Math.max(a, b);
8650
+ if (a === b) return a >= lo && a <= hi;
8651
+ if (extend === "both") return true;
8652
+ if (extend === "left") return maxX >= lo;
8653
+ if (extend === "right") return minX <= hi;
8654
+ return maxX >= lo && minX <= hi;
8655
+ }
8634
8656
  var DrawingSceneRenderer = class {
8635
8657
  constructor(deps, set = EMPTY_DRAWING_SET) {
8636
8658
  this.deps = deps;
@@ -8684,7 +8706,9 @@ var DrawingSceneRenderer = class {
8684
8706
  const lo = Math.min(from, to);
8685
8707
  const hi = Math.max(from, to);
8686
8708
  const visible = (a, b, extend) => {
8687
- if (extend !== "none") return true;
8709
+ if (extend === "both") return true;
8710
+ if (extend === "left") return Math.max(a, b) >= lo;
8711
+ if (extend === "right") return Math.min(a, b) <= hi;
8688
8712
  return Math.max(a, b) >= lo && Math.min(a, b) <= hi;
8689
8713
  };
8690
8714
  let min = Infinity;
@@ -8695,7 +8719,7 @@ var DrawingSceneRenderer = class {
8695
8719
  };
8696
8720
  for (const ln of this.set.lines) {
8697
8721
  if (ln.invisible) continue;
8698
- if (!visible(this.logicalOf(ln.xloc, ln.x1), this.logicalOf(ln.xloc, ln.x2), ln.extend)) continue;
8722
+ if (!lineCoversWindow(this.logicalOf(ln.xloc, ln.x1), this.logicalOf(ln.xloc, ln.x2), ln.extend, lo, hi)) continue;
8699
8723
  fold(ln.y1);
8700
8724
  fold(ln.y2);
8701
8725
  }
@@ -11139,6 +11163,18 @@ function timezoneOptions(current) {
11139
11163
  return [[normalized, tzMenuLabel(normalized, normalized)], ...options];
11140
11164
  }
11141
11165
 
11166
+ // src/core/price-styles/BarTransform.ts
11167
+ function barTransformFor(style) {
11168
+ return chartType(style)?.barTransform ?? null;
11169
+ }
11170
+ function parseExtendedTicker(symbol) {
11171
+ const at = symbol.lastIndexOf(";");
11172
+ if (at <= 0 || at === symbol.length - 1) return { symbol, modifier: null, transform: null };
11173
+ const modifier = symbol.slice(at + 1).toLowerCase();
11174
+ if (modifier !== "standard" && !tickerModifierIds().includes(modifier)) return { symbol, modifier: null, transform: null };
11175
+ return { symbol: symbol.slice(0, at), modifier, transform: barTransformFor(modifier) };
11176
+ }
11177
+
11142
11178
  // src/renderers/native/drawings/DrawingPainter.ts
11143
11179
  var HANDLE_RADIUS = 4.5;
11144
11180
  var HANDLE_BORDER = DEFAULT_DRAWING_COLOR;
@@ -11158,6 +11194,17 @@ var DrawingPainter = class {
11158
11194
  constructor() {
11159
11195
  /** The current `paintAll` call's interaction state, visible to the per-type painters. */
11160
11196
  this.targets = {};
11197
+ /** The chart's active series LOOK — style + resolved series colors — pushed by the
11198
+ * controller before each paint. The magnifier's inset mirrors both: candles/bars/line/
11199
+ * area restyle the paint (bar-transform styles like Heikin Ashi transform the fetched
11200
+ * bars; unknown/custom styles fall back to candles), and the colors default to the main
11201
+ * series' own so the inset reads as a finer copy of the chart. */
11202
+ this.seriesLook = {
11203
+ style: "candles",
11204
+ upColor: BULLISH,
11205
+ downColor: BEARISH,
11206
+ lineColor: BULLISH
11207
+ };
11161
11208
  }
11162
11209
  /** Paint every visible drawing, then selection handles for the targeted ones.
11163
11210
  * Each drawing is clipped to its own pane's rect (and skipped entirely while that pane
@@ -11202,6 +11249,8 @@ var DrawingPainter = class {
11202
11249
  ctx.globalAlpha = GHOST_ALPHA;
11203
11250
  if (ghost instanceof RegressionChannel || ghost instanceof FixedRangeVolumeProfile) {
11204
11251
  this.paintTimeSpanGhost(ctx, ghost, proj);
11252
+ } else if (ghost instanceof Magnifier) {
11253
+ this.paintMagnifierGhost(ctx, ghost, proj, theme);
11205
11254
  } else this.paintOne(ctx, ghost, proj, theme);
11206
11255
  ctx.globalAlpha = 1;
11207
11256
  }
@@ -11335,6 +11384,10 @@ var DrawingPainter = class {
11335
11384
  this.paintLabel(ctx, d, proj, theme);
11336
11385
  return;
11337
11386
  }
11387
+ if (d instanceof Magnifier) {
11388
+ this.paintMagnifier(ctx, d, proj, theme);
11389
+ return;
11390
+ }
11338
11391
  if (d instanceof PatternDrawing) {
11339
11392
  this.paintPattern(ctx, d, proj, theme);
11340
11393
  return;
@@ -11827,6 +11880,216 @@ var DrawingPainter = class {
11827
11880
  ctx.textBaseline = "alphabetic";
11828
11881
  }
11829
11882
  }
11883
+ /** Placement preview for the magnifier: a dashed rectangle outline only — no backdrop and
11884
+ * no series read, so dragging the area open never kicks a fetch per cursor move. */
11885
+ paintMagnifierGhost(ctx, d, proj, theme) {
11886
+ const r = d.rect(proj);
11887
+ if (!r) return;
11888
+ ctx.save();
11889
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
11890
+ ctx.lineWidth = 1;
11891
+ ctx.setLineDash([4, 4]);
11892
+ 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));
11893
+ ctx.restore();
11894
+ }
11895
+ /** Paint a magnifier: an opaque theme-background inset whose interior shows the chart's
11896
+ * market at a finer timeframe — candles at their true time/price positions, clipped to
11897
+ * the rectangle. Bars come through `Projector.seriesInRange` (cache read; `loading` and
11898
+ * `unavailable` states paint a short notice instead). The lower-timeframe candles shift
11899
+ * half a chart bar LEFT of their raw time pixel so each chart candle's visual cell —
11900
+ * centered on its open time — subdivides in place. */
11901
+ paintMagnifier(ctx, d, proj, theme) {
11902
+ const r = d.rect(proj);
11903
+ const a = d.anchors[0];
11904
+ const b = d.anchors[1];
11905
+ if (!r || !a || !b) return;
11906
+ const x0 = Math.min(r.x1, r.x2);
11907
+ const x1 = Math.max(r.x1, r.x2);
11908
+ const y0 = Math.min(r.y1, r.y2);
11909
+ const y1 = Math.max(r.y1, r.y2);
11910
+ const w = x1 - x0;
11911
+ const h = y1 - y0;
11912
+ ctx.save();
11913
+ ctx.globalAlpha = 1;
11914
+ ctx.fillStyle = theme.background;
11915
+ ctx.fillRect(x0, y0, w, h);
11916
+ ctx.restore();
11917
+ const from = Math.min(a.time, b.time);
11918
+ const to = Math.max(a.time, b.time);
11919
+ const chartBars = proj.barsBetween ? proj.barsBetween(from, to) : 0;
11920
+ const chartMs = chartBars > 0 ? (to - from) / chartBars : 0;
11921
+ const res = proj.seriesInRange && chartMs > 0 && w > 1 && h > 1 ? proj.seriesInRange(d.magnifier.timeframe, from, to + chartMs) : void 0;
11922
+ let seriesBars = res?.state === "ready" || res?.state === "loading" ? res.bars ?? [] : [];
11923
+ if (res && (res.state === "ready" || res.state === "loading") && seriesBars.length > 0) {
11924
+ const look = this.seriesLook;
11925
+ const transform = look.style !== "candles" ? barTransformFor(look.style) : null;
11926
+ if (transform) seriesBars = transform.full(seriesBars);
11927
+ const mode = look.style === "bars" ? "bars" : look.style === "line" || look.style === "baseline" ? "line" : look.style === "area" ? "area" : "candles";
11928
+ const halfPitch = (proj.xOf(from + chartMs) - proj.xOf(from)) / 2;
11929
+ ctx.save();
11930
+ ctx.beginPath();
11931
+ ctx.rect(x0, y0, w, h);
11932
+ ctx.clip();
11933
+ if (mode === "line" || mode === "area") {
11934
+ this.paintMagnifierLine(ctx, d, proj, seriesBars, res.barMs, halfPitch, y1, mode === "area", d.magnifier.upColor || look.lineColor);
11935
+ } else {
11936
+ this.paintMagnifierBars(ctx, d, proj, seriesBars, res.barMs, halfPitch, x0, x1, mode, d.magnifier.upColor || look.upColor, d.magnifier.downColor || look.downColor);
11937
+ }
11938
+ ctx.restore();
11939
+ } else if (res) {
11940
+ 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";
11941
+ this.paintMagnifierNotice(ctx, notice, x0, y0, w, h, theme);
11942
+ }
11943
+ ctx.save();
11944
+ ctx.strokeStyle = d.style.lineColor || contrastColor(theme.background);
11945
+ ctx.lineWidth = d.style.lineWidth || 1;
11946
+ ctx.setLineDash(dashPattern(d.style.lineStyle, d.style.lineWidth || 1));
11947
+ ctx.strokeRect(x0, y0, w, h);
11948
+ ctx.restore();
11949
+ if (w > 44) {
11950
+ const label = magnifierTimeframeLabel(res?.state === "ready" || res?.state === "loading" ? res.timeframe : d.magnifier.timeframe);
11951
+ const chipH = 17;
11952
+ const gap = 4;
11953
+ const pane = proj.paneRect?.(d.paneId);
11954
+ const paneBottom = pane ? pane.top + pane.height : proj.height;
11955
+ const below = y1 + gap + chipH <= paneBottom;
11956
+ const chipY = below ? y1 + gap : y1 - gap - chipH;
11957
+ ctx.save();
11958
+ ctx.font = `10px ${theme.fontFamily}`;
11959
+ const tw = ctx.measureText(label).width;
11960
+ const caretW = 11;
11961
+ const chipW = tw + 12 + caretW;
11962
+ roundRect(ctx, x0, chipY, chipW, chipH, 3);
11963
+ ctx.fillStyle = theme.background;
11964
+ ctx.fill();
11965
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
11966
+ ctx.lineWidth = 1;
11967
+ ctx.setLineDash([]);
11968
+ ctx.stroke();
11969
+ ctx.fillStyle = theme.textColor;
11970
+ ctx.textAlign = "left";
11971
+ ctx.textBaseline = "middle";
11972
+ ctx.fillText(label, x0 + 6, chipY + chipH / 2 + 0.5);
11973
+ const cxr = x0 + 6 + tw + 5;
11974
+ const cyr = chipY + chipH / 2;
11975
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.7);
11976
+ ctx.lineWidth = 1.2;
11977
+ ctx.beginPath();
11978
+ ctx.moveTo(cxr, cyr - 1.5);
11979
+ ctx.lineTo(cxr + 2.5, cyr + 1.5);
11980
+ ctx.lineTo(cxr + 5, cyr - 1.5);
11981
+ ctx.stroke();
11982
+ ctx.restore();
11983
+ d.chipRect = { x: x0, y: chipY, w: chipW, h: chipH };
11984
+ } else {
11985
+ d.chipRect = null;
11986
+ }
11987
+ }
11988
+ /** The magnifier's candle/bar loop: each bar's cell spans its open→close time (shifted left
11989
+ * by half a chart bar). Candles: wick always, body once the cell is wide enough to carry
11990
+ * one. OHLC bars: the high–low spine with open/close ticks once the cell has the room. */
11991
+ paintMagnifierBars(ctx, d, proj, bars, barMs, halfPitch, x0, x1, mode, upColor, downColor) {
11992
+ ctx.setLineDash([]);
11993
+ ctx.lineWidth = 1;
11994
+ for (const bar of bars) {
11995
+ const cx0 = proj.xOf(bar.time) - halfPitch;
11996
+ const cx1 = proj.xOf(bar.time + barMs) - halfPitch;
11997
+ if (cx1 < x0 || cx0 > x1) continue;
11998
+ const yHigh = proj.yOf(bar.high, d.paneId);
11999
+ const yLow = proj.yOf(bar.low, d.paneId);
12000
+ const yOpen = proj.yOf(bar.open, d.paneId);
12001
+ const yClose = proj.yOf(bar.close, d.paneId);
12002
+ if (yHigh == null || yLow == null || yOpen == null || yClose == null) continue;
12003
+ const color = bar.close >= bar.open ? upColor : downColor;
12004
+ const cellW = cx1 - cx0;
12005
+ const cx = (cx0 + cx1) / 2;
12006
+ ctx.strokeStyle = color;
12007
+ ctx.beginPath();
12008
+ ctx.moveTo(cx, yHigh);
12009
+ ctx.lineTo(cx, yLow);
12010
+ ctx.stroke();
12011
+ if (cellW < 3) continue;
12012
+ if (mode === "bars") {
12013
+ const tick = Math.max(1, cellW * 0.35);
12014
+ ctx.beginPath();
12015
+ ctx.moveTo(cx - tick, yOpen);
12016
+ ctx.lineTo(cx, yOpen);
12017
+ ctx.moveTo(cx, yClose);
12018
+ ctx.lineTo(cx + tick, yClose);
12019
+ ctx.stroke();
12020
+ } else {
12021
+ const bw = Math.max(1, cellW * 0.7);
12022
+ ctx.fillStyle = color;
12023
+ ctx.fillRect(cx - bw / 2, Math.min(yOpen, yClose), bw, Math.max(1, Math.abs(yClose - yOpen)));
12024
+ }
12025
+ }
12026
+ }
12027
+ /** The magnifier's line/area rendering: a close polyline through each cell's center (same
12028
+ * half-chart-bar shift as the candles), with an optional translucent fill down to the
12029
+ * rectangle's bottom edge for the area style. Colored like the chart's own line series. */
12030
+ paintMagnifierLine(ctx, d, proj, bars, barMs, halfPitch, yBottom, area, color) {
12031
+ const pts = [];
12032
+ for (const bar of bars) {
12033
+ const y = proj.yOf(bar.close, d.paneId);
12034
+ if (y == null) continue;
12035
+ pts.push([proj.xOf(bar.time + barMs / 2) - halfPitch, y]);
12036
+ }
12037
+ if (pts.length < 2) return;
12038
+ if (area) {
12039
+ ctx.beginPath();
12040
+ ctx.moveTo(pts[0][0], yBottom);
12041
+ for (const [px, py] of pts) ctx.lineTo(px, py);
12042
+ ctx.lineTo(pts[pts.length - 1][0], yBottom);
12043
+ ctx.closePath();
12044
+ ctx.fillStyle = withAlpha(color, 0.15);
12045
+ ctx.fill();
12046
+ }
12047
+ ctx.setLineDash([]);
12048
+ ctx.lineWidth = 1.5;
12049
+ ctx.strokeStyle = color;
12050
+ ctx.beginPath();
12051
+ ctx.moveTo(pts[0][0], pts[0][1]);
12052
+ for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
12053
+ ctx.stroke();
12054
+ }
12055
+ /** Centered muted notice inside the magnifier rect (loading / unavailable states). */
12056
+ paintMagnifierNotice(ctx, text, x0, y0, w, h, theme) {
12057
+ if (w < 60 || h < 20) return;
12058
+ ctx.save();
12059
+ ctx.beginPath();
12060
+ ctx.rect(x0, y0, w, h);
12061
+ ctx.clip();
12062
+ ctx.font = `11px ${theme.fontFamily}`;
12063
+ ctx.fillStyle = withAlpha(theme.textColor, 0.55);
12064
+ ctx.textAlign = "center";
12065
+ ctx.textBaseline = "middle";
12066
+ ctx.fillText(text, x0 + w / 2, y0 + h / 2);
12067
+ ctx.restore();
12068
+ }
12069
+ /** A bottom-center pill prompting the armed tool's placement gesture (e.g. the magnifier's
12070
+ * "drag an area"). Painted by the drawings layer while the tool is armed and no placement
12071
+ * is in progress; chart-background fill so it reads as chrome over any content. */
12072
+ paintPlacementHint(ctx, text, theme, width, height) {
12073
+ ctx.save();
12074
+ ctx.font = `11px ${theme.fontFamily}`;
12075
+ const tw = ctx.measureText(text).width;
12076
+ const pillW = tw + 24;
12077
+ const pillH = 24;
12078
+ const x = (width - pillW) / 2;
12079
+ const y = height - pillH - 14;
12080
+ roundRect(ctx, x, y, pillW, pillH, pillH / 2);
12081
+ ctx.fillStyle = theme.background;
12082
+ ctx.fill();
12083
+ ctx.strokeStyle = withAlpha(theme.textColor, 0.28);
12084
+ ctx.lineWidth = 1;
12085
+ ctx.setLineDash([]);
12086
+ ctx.stroke();
12087
+ ctx.fillStyle = theme.textColor;
12088
+ ctx.textAlign = "center";
12089
+ ctx.textBaseline = "middle";
12090
+ ctx.fillText(text, width / 2, y + pillH / 2 + 0.5);
12091
+ ctx.restore();
12092
+ }
11830
12093
  /** Paint a fixed-range volume profile: horizontal histogram rows (up/down split) anchored to
11831
12094
  * the left or right of the time span, optional VAH / VAL / POC levels across the range, and
11832
12095
  * optional developing POC / VA polylines. Recomputes from the two anchors on every paint. */
@@ -12545,6 +12808,22 @@ var DrawingInteraction = class {
12545
12808
  this.snapAt = changed ? { point: snapped, paneId } : null;
12546
12809
  return snapped;
12547
12810
  }
12811
+ /**
12812
+ * Resolve a cursor pixel through the magnet and return the snapped pixel — the same
12813
+ * conversion drawing placement uses. Updates the snap-ring marker. The measure
12814
+ * ruler goes through this so its endpoints follow weak/strong/Ctrl magnet too.
12815
+ */
12816
+ snapCursor(x, y, mode) {
12817
+ const proj = this.deps.projector();
12818
+ const paneId = proj.paneIdAtY(y) ?? "price";
12819
+ const point = this.resolve(x, y, paneId, mode);
12820
+ const sy = proj.yOf(point.price, paneId);
12821
+ return { x: proj.xOf(point.time), y: sy ?? y };
12822
+ }
12823
+ /** Drop the snap-ring marker (a transient mode ended without going through `up`). */
12824
+ clearSnapMarker() {
12825
+ this.snapAt = null;
12826
+ }
12548
12827
  /** Resolve a pixel to a data point with the segment angle locked to 45° steps around
12549
12828
  * `pivot` (Shift held on a line tool). Works in PIXEL space — the user reasons about
12550
12829
  * the angle they see, not about time/price units. The magnet is bypassed: snapping
@@ -13226,8 +13505,9 @@ function ensureStyles4() {
13226
13505
  if (!existing) document.head.appendChild(s);
13227
13506
  }
13228
13507
  var DrawingSettingsPopup = class {
13229
- constructor(host, theme) {
13508
+ constructor(host, theme, chartBarMs = () => 0) {
13230
13509
  this.host = host;
13510
+ this.chartBarMs = chartBarMs;
13231
13511
  this.el = null;
13232
13512
  this.tipEl = null;
13233
13513
  // floating hover-label (above/below the toolbar)
@@ -13252,6 +13532,14 @@ var DrawingSettingsPopup = class {
13252
13532
  this.theme = theme;
13253
13533
  this.settingsDialog = new DrawingSettingsDialog(host, theme);
13254
13534
  }
13535
+ /** The magnifier timeframe choices strictly below the chart's own bar duration
13536
+ * (`auto` rides along while at least one concrete lower step exists). */
13537
+ lowerTimeframeOptions() {
13538
+ const chartMs = this.chartBarMs();
13539
+ if (!(chartMs > 0)) return [...MAGNIFIER_TIMEFRAME_OPTIONS];
13540
+ const lower = MAGNIFIER_TIMEFRAME_OPTIONS.filter((o) => o.ms > 0 && o.ms < chartMs);
13541
+ return lower.length > 0 ? [MAGNIFIER_TIMEFRAME_OPTIONS[0], ...lower] : [];
13542
+ }
13255
13543
  setTheme(theme) {
13256
13544
  this.theme = theme;
13257
13545
  this.settingsDialog.setTheme(theme);
@@ -13294,7 +13582,20 @@ var DrawingSettingsPopup = class {
13294
13582
  const sz = drawing.size ?? "normal";
13295
13583
  bar.appendChild(this.dropdown("Icon size", STAMP_SIZE_OPTIONS, sz, (s) => stampSizeIcon(s), (v) => actions.patch({ size: v }), { label: sizeLabel }));
13296
13584
  }
13297
- 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 })));
13585
+ if (paths.has("magnifier.timeframe") && drawing instanceof Magnifier) {
13586
+ const options = this.lowerTimeframeOptions();
13587
+ if (options.length > 0) {
13588
+ bar.appendChild(
13589
+ this.dropdown("Lower timeframe", options.map((o) => o.value), drawing.magnifier.timeframe, () => "", (v) => actions.patch({ "magnifier.timeframe": v }), {
13590
+ label: (v) => magnifierTimeframeLabel(String(v)),
13591
+ labelInTrigger: true
13592
+ })
13593
+ );
13594
+ }
13595
+ bar.appendChild(this.colorButton("Up candles", BUCKET_ICON, drawing.magnifier.upColor || t.upColor, (v) => actions.patch({ "magnifier.upColor": v })));
13596
+ bar.appendChild(this.colorButton("Down candles", BUCKET_ICON, drawing.magnifier.downColor || t.downColor, (v) => actions.patch({ "magnifier.downColor": v })));
13597
+ }
13598
+ 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 })));
13298
13599
  if (paths.has("style.lineWidth")) {
13299
13600
  const wf = schema.fields.find((f) => f.path === "style.lineWidth");
13300
13601
  if (wf?.kind === "number" && (wf.min ?? 1) > 1) {
@@ -13616,6 +13917,7 @@ var DrawingSettingsPopup = class {
13616
13917
  this.colorPop = null;
13617
13918
  this.colorOwner = null;
13618
13919
  }
13920
+ opts.onClose?.();
13619
13921
  }
13620
13922
  });
13621
13923
  const el = pop.el;
@@ -13634,6 +13936,49 @@ var DrawingSettingsPopup = class {
13634
13936
  pop.show();
13635
13937
  return pop;
13636
13938
  }
13939
+ /**
13940
+ * A standalone timeframe menu for the magnifier's ON-CHART chip. The chip lives on
13941
+ * canvas, so a transient invisible anchor is dropped at its pixel rect for the popover
13942
+ * to position against, and removed again when the menu closes. Independent of the
13943
+ * quick toolbar — the chip works without selecting the drawing first.
13944
+ */
13945
+ openMagnifierTimeframeMenu(rect, current, onPick) {
13946
+ ensureStyles4();
13947
+ closeOpenPopovers();
13948
+ const options = this.lowerTimeframeOptions();
13949
+ const anchor = document.createElement("div");
13950
+ anchor.style.cssText = `position:absolute;left:${rect.x}px;top:${rect.y}px;width:${rect.w}px;height:${rect.h}px;pointer-events:none;`;
13951
+ this.host.appendChild(anchor);
13952
+ this.menuPop = this.hostFloat(anchor, {
13953
+ zIndex: 26,
13954
+ padding: "4px",
13955
+ onClose: () => anchor.remove(),
13956
+ fill: (menu, pop) => {
13957
+ if (options.length === 0) {
13958
+ const note = document.createElement("div");
13959
+ note.style.cssText = "padding:6px 10px;opacity:0.65;white-space:nowrap;";
13960
+ note.textContent = "No lower timeframe available";
13961
+ menu.appendChild(note);
13962
+ return;
13963
+ }
13964
+ for (const o of options) {
13965
+ const item = document.createElement("button");
13966
+ item.type = "button";
13967
+ item.className = "vela-dpop-item";
13968
+ item.dataset.active = o.value === current ? "1" : "0";
13969
+ 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;";
13970
+ item.textContent = o.label;
13971
+ item.addEventListener("click", (e) => {
13972
+ e.stopPropagation();
13973
+ pop.hide();
13974
+ onPick(o.value);
13975
+ });
13976
+ menu.appendChild(item);
13977
+ }
13978
+ }
13979
+ });
13980
+ this.menuOwner = anchor;
13981
+ }
13637
13982
  /** A floating list of one-shot actions (icon + label rows) opened by the kebab. */
13638
13983
  openActionMenu(anchor, rows) {
13639
13984
  this.menuPop = this.hostFloat(anchor, {
@@ -13744,10 +14089,13 @@ var DrawingSettingsPopup = class {
13744
14089
  let cur = current;
13745
14090
  const paint = (v) => {
13746
14091
  b.replaceChildren();
13747
- const ic = document.createElement("span");
13748
- ic.style.cssText = "display:flex;";
13749
- ic.innerHTML = sized(render(v));
13750
- b.appendChild(ic);
14092
+ const glyph = render(v);
14093
+ if (glyph) {
14094
+ const ic = document.createElement("span");
14095
+ ic.style.cssText = "display:flex;";
14096
+ ic.innerHTML = sized(glyph);
14097
+ b.appendChild(ic);
14098
+ }
13751
14099
  if (opts.label && opts.labelInTrigger) {
13752
14100
  const tx = document.createElement("span");
13753
14101
  tx.textContent = opts.label(v);
@@ -13789,10 +14137,13 @@ var DrawingSettingsPopup = class {
13789
14137
  item.className = "vela-dpop-item";
13790
14138
  item.dataset.active = active ? "1" : "0";
13791
14139
  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;`;
13792
- const ic = document.createElement("span");
13793
- ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
13794
- ic.innerHTML = sized(render(v), 18);
13795
- item.appendChild(ic);
14140
+ const glyph = render(v);
14141
+ if (glyph) {
14142
+ const ic = document.createElement("span");
14143
+ ic.style.cssText = "display:flex;flex:none;width:22px;justify-content:center;";
14144
+ ic.innerHTML = sized(glyph, 18);
14145
+ item.appendChild(ic);
14146
+ }
13796
14147
  if (label) {
13797
14148
  const tx = document.createElement("span");
13798
14149
  tx.textContent = label(v);
@@ -13950,30 +14301,39 @@ var MeasureOverlay = class {
13950
14301
  isFinished() {
13951
14302
  return this.state === "finished";
13952
14303
  }
13953
- /** A press: begin the measurement, or finish it on the second click. */
13954
- down(x, y) {
14304
+ /** A press: begin the measurement, or finish it on the second click.
14305
+ * `x,y` are the raw cursor (drag-slop vs click-move-click). `gx,gy` are the
14306
+ * graphic endpoints — magnet-snapped when the magnet is on, else the same as `x,y`. */
14307
+ down(x, y, gx = x, gy = y) {
13955
14308
  if (this.state === "measuring") {
13956
- this.end = { x, y };
14309
+ this.end = { x: gx, y: gy };
13957
14310
  this.state = "finished";
13958
14311
  return;
13959
14312
  }
13960
- this.start = { x, y };
13961
- this.end = { x, y };
14313
+ this.start = { x: gx, y: gy };
14314
+ this.end = { x: gx, y: gy };
13962
14315
  this.pressX = x;
13963
14316
  this.pressY = y;
13964
14317
  this.state = "measuring";
13965
14318
  }
14319
+ /** Size the in-progress ruler. `x,y` are the graphic (magnet-snapped) cursor. */
13966
14320
  move(x, y) {
13967
14321
  if (this.state === "measuring") this.end = { x, y };
13968
14322
  }
13969
14323
  /** A release: finish if the press was actually dragged (press-drag-release), else wait
13970
- * for the second click (click-move-click). */
13971
- up(x, y) {
14324
+ * for the second click (click-move-click). `x,y` are the raw cursor (slop); `gx,gy`
14325
+ * are the graphic end (magnet-snapped when the magnet is on). */
14326
+ up(x, y, gx = x, gy = y) {
13972
14327
  if (this.state === "measuring" && Math.hypot(x - this.pressX, y - this.pressY) > DRAG_SLOP3) {
13973
- this.end = { x, y };
14328
+ this.end = { x: gx, y: gy };
13974
14329
  this.state = "finished";
13975
14330
  }
13976
14331
  }
14332
+ /** Current graphic endpoints in media pixels, or null when idle. */
14333
+ points() {
14334
+ if (!this.start || !this.end) return null;
14335
+ return { start: this.start, end: this.end };
14336
+ }
13977
14337
  clear() {
13978
14338
  this.state = "idle";
13979
14339
  this.start = null;
@@ -14135,6 +14495,9 @@ var UserDrawingController = class {
14135
14495
  this.intentCb = null;
14136
14496
  /** Another chart's in-progress placement, mirrored here as a ghost (drawings sync). */
14137
14497
  this.externalGhost = null;
14498
+ /** Core-pushed series gateway (finer-timeframe bars for data-driven drawings). */
14499
+ this.seriesGw = null;
14500
+ this.seriesGwUnsub = null;
14138
14501
  /** Last draft fingerprint reported upstream — gates the per-render emission to actual changes. */
14139
14502
  this.lastDraftKey = null;
14140
14503
  this.measure = new MeasureOverlay();
@@ -14162,7 +14525,7 @@ var UserDrawingController = class {
14162
14525
  this.textEditor = null;
14163
14526
  this.painter = new DrawingPainter();
14164
14527
  this.ctx = canvas.getContext("2d");
14165
- this.popup = new DrawingSettingsPopup(overlayHost, deps.theme());
14528
+ this.popup = new DrawingSettingsPopup(overlayHost, deps.theme(), () => deps.chartBarMs());
14166
14529
  this.toolbar = new DrawingToolbar(
14167
14530
  toolbarHost,
14168
14531
  deps.theme(),
@@ -14220,6 +14583,22 @@ var UserDrawingController = class {
14220
14583
  const shown = this.toolbarVisible && !this.mobileLayout;
14221
14584
  this.deps.setToolbarGutter(shown ? this.toolbarCollapsed ? TOOLBAR_COLLAPSED_WIDTH : TOOLBAR_WIDTH : 0);
14222
14585
  }
14586
+ /** Core push: the series gateway data-driven drawings read finer-timeframe bars
14587
+ * through (surfaced to them as `Projector.seriesInRange`). A landed background
14588
+ * fetch repaints both this layer and the interleave slices under the series. */
14589
+ setSeriesGateway(gateway) {
14590
+ this.seriesGwUnsub?.();
14591
+ this.seriesGw = gateway;
14592
+ this.seriesGwUnsub = gateway.onUpdate(() => {
14593
+ this.invalidateSlices();
14594
+ this.render();
14595
+ this.deps.requestDataPaint();
14596
+ });
14597
+ }
14598
+ /** The pushed series gateway, or null before the core provides one. */
14599
+ get seriesGateway() {
14600
+ return this.seriesGw;
14601
+ }
14223
14602
  /** Core push: mirror (or clear) another chart's in-progress placement as a ghost. */
14224
14603
  setExternalGhost(doc) {
14225
14604
  this.externalGhost = doc ? deserializeDrawing(doc) : null;
@@ -14337,8 +14716,34 @@ var UserDrawingController = class {
14337
14716
  /** Should the drawing layer win this press (vs pan)? */
14338
14717
  claim(x, y) {
14339
14718
  if (this.measureMode || this.eraserMode) return true;
14719
+ if (this.magnifierChipAt(x, y)) return true;
14340
14720
  return this.interaction.claim(x, y);
14341
14721
  }
14722
+ /** The topmost visible (unlocked) magnifier whose timeframe chip contains (x, y) —
14723
+ * the chip's rect is what the painter measured last frame. */
14724
+ magnifierChipAt(x, y) {
14725
+ for (let i = this.drawings.length - 1; i >= 0; i -= 1) {
14726
+ const d = this.drawings[i];
14727
+ if (!(d instanceof Magnifier) || !d.visible || d.locked) continue;
14728
+ const r = d.chipRect;
14729
+ if (r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return d;
14730
+ }
14731
+ return null;
14732
+ }
14733
+ /** Open the on-chart chip's timeframe menu; the pick patches the drawing like a
14734
+ * settings-popup edit (same intent, same undo step). */
14735
+ openMagnifierChipMenu(drawing) {
14736
+ const rect = drawing.chipRect;
14737
+ if (!rect) return;
14738
+ const id = drawing.id;
14739
+ this.popup.openMagnifierTimeframeMenu(rect, drawing.magnifier.timeframe, (value) => {
14740
+ const d = this.drawings.find((x) => x.id === id);
14741
+ if (!(d instanceof Magnifier)) return;
14742
+ d.applySettings({ "magnifier.timeframe": value });
14743
+ this.render();
14744
+ this.emit({ kind: "edit", doc: d.serialize() });
14745
+ });
14746
+ }
14342
14747
  /** Delete the (unlocked) drawing under the cursor. True when one was removed.
14343
14748
  * Shared by the eraser (click + drag) and the middle-click shortcut. */
14344
14749
  deleteAt(x, y) {
@@ -14350,11 +14755,13 @@ var UserDrawingController = class {
14350
14755
  }
14351
14756
  /** Shift+press on the empty plot: arm the measure ruler AND start it at (x, y) in one
14352
14757
  * gesture — the equivalent of clicking the toolbar's Measure button, then pressing.
14353
- * Returns false when a mode/tool is already active (the normal press path owns it). */
14354
- beginMeasureAt(x, y) {
14758
+ * `snap` is the effective magnet (sticky mode, or Ctrl/Cmd-forced strong). Returns
14759
+ * false when a mode/tool is already active (the normal press path owns it). */
14760
+ beginMeasureAt(x, y, snap = "off") {
14355
14761
  if (this.measureMode || this.eraserMode || this.activeTool != null) return false;
14356
14762
  this.withModeIntent(() => this.toggleMeasure());
14357
- this.measure.down(x, y);
14763
+ const g = this.interaction.snapCursor(x, y, snap);
14764
+ this.measure.down(x, y, g.x, g.y);
14358
14765
  this.render();
14359
14766
  return true;
14360
14767
  }
@@ -14376,11 +14783,19 @@ var UserDrawingController = class {
14376
14783
  return;
14377
14784
  }
14378
14785
  if (this.measureMode) {
14379
- this.measure.down(x, y);
14786
+ const g = this.interaction.snapCursor(x, y, snap);
14787
+ this.measure.down(x, y, g.x, g.y);
14380
14788
  if (this.measure.isFinished()) this.withModeIntent(() => this.exitMeasure(false));
14381
14789
  this.render();
14382
14790
  return;
14383
14791
  }
14792
+ if (this.activeTool == null) {
14793
+ const chipOwner = this.magnifierChipAt(x, y);
14794
+ if (chipOwner) {
14795
+ this.openMagnifierChipMenu(chipOwner);
14796
+ return;
14797
+ }
14798
+ }
14384
14799
  this.interaction.down(x, y, snap, shift);
14385
14800
  }
14386
14801
  pointerMove(x, y, snap = "off", shift = false) {
@@ -14389,7 +14804,8 @@ var UserDrawingController = class {
14389
14804
  return;
14390
14805
  }
14391
14806
  if (this.measureMode) {
14392
- this.measure.move(x, y);
14807
+ const g = this.interaction.snapCursor(x, y, snap);
14808
+ this.measure.move(g.x, g.y);
14393
14809
  this.render();
14394
14810
  return;
14395
14811
  }
@@ -14407,13 +14823,14 @@ var UserDrawingController = class {
14407
14823
  this.render();
14408
14824
  }
14409
14825
  }
14410
- pointerUp(x, y) {
14826
+ pointerUp(x, y, snap = "off") {
14411
14827
  if (this.eraserMode) {
14412
14828
  this.erasing = false;
14413
14829
  return;
14414
14830
  }
14415
14831
  if (this.measureMode) {
14416
- this.measure.up(x, y);
14832
+ const g = this.interaction.snapCursor(x, y, snap);
14833
+ this.measure.up(x, y, g.x, g.y);
14417
14834
  if (this.measure.isFinished()) this.withModeIntent(() => this.exitMeasure(false));
14418
14835
  this.render();
14419
14836
  return;
@@ -14458,6 +14875,7 @@ var UserDrawingController = class {
14458
14875
  /** Leave ruler mode. `clearGraphic` keeps a just-finished measurement on screen (false). */
14459
14876
  exitMeasure(clearGraphic = true) {
14460
14877
  this.measureMode = false;
14878
+ this.interaction.clearSnapMarker();
14461
14879
  if (clearGraphic) this.measure.clear();
14462
14880
  this.toolbar.setMeasureActive(false);
14463
14881
  this.render();
@@ -14596,17 +15014,31 @@ var UserDrawingController = class {
14596
15014
  /** Cursor hint while hovering — `'pointer'` over a drawing/handle, else null. */
14597
15015
  cursorAt(x, y) {
14598
15016
  if (this.eraserMode) return "pointer";
15017
+ if (this.activeTool == null && this.magnifierChipAt(x, y)) return "pointer";
14599
15018
  return this.interaction.cursorAt(x, y);
14600
15019
  }
14601
- /** Right-click while placing: cancel the in-progress drawing and revert to the
14602
- * pointer the gesture is an explicit escape, so it disarms even in
14603
- * stay-in-drawing-mode (where Escape would leave the tool armed). Returns whether
14604
- * the press was consumed; false lets the host's context menu open normally. */
15020
+ /** Right-click: an explicit escape back to the pointer. Cancels an in-progress
15021
+ * placement or measurement, and also plain-disarms an armed-but-idle drawing
15022
+ * tool or the eraser — so a right-click ALWAYS reverts to the pointer, even in
15023
+ * stay-in-drawing-mode (where Escape would leave a drawing tool armed).
15024
+ * Persistent toggles (magnet, stay-mode, favorites) are untouched. Returns
15025
+ * whether the press was consumed; false lets the host's context menu open
15026
+ * normally. */
14605
15027
  cancelPlacement() {
14606
- if (!this.interaction.isPlacing()) return false;
14607
- this.interaction.cancel();
14608
- if (this.activeTool != null) this.emit({ kind: "arm", type: null });
14609
- return true;
15028
+ if (this.measureMode || this.eraserMode) {
15029
+ this.withModeIntent(() => this.measureMode ? this.exitMeasure() : this.exitEraser());
15030
+ return true;
15031
+ }
15032
+ if (this.interaction.isPlacing()) {
15033
+ this.interaction.cancel();
15034
+ if (this.activeTool != null) this.emit({ kind: "arm", type: null });
15035
+ return true;
15036
+ }
15037
+ if (this.activeTool != null) {
15038
+ this.emit({ kind: "arm", type: null });
15039
+ return true;
15040
+ }
15041
+ return false;
14610
15042
  }
14611
15043
  /** Double-click over a drawing → suppress the chart's view reset (single-click already
14612
15044
  * opens settings). Returns true only when a drawing is under the cursor. */
@@ -14632,6 +15064,10 @@ var UserDrawingController = class {
14632
15064
  return true;
14633
15065
  }
14634
15066
  if (this.interaction.cancel()) return true;
15067
+ if (this.measureMode) {
15068
+ this.withModeIntent(() => this.exitMeasure());
15069
+ return true;
15070
+ }
14635
15071
  if (this.selectedIds.size) {
14636
15072
  this.clearSelection();
14637
15073
  return true;
@@ -14757,6 +15193,7 @@ var UserDrawingController = class {
14757
15193
  if (!sctx) continue;
14758
15194
  sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
14759
15195
  sctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
15196
+ this.painter.seriesLook = this.deps.seriesLook();
14760
15197
  this.painter.paintAll(sctx, drawings, proj, theme, EMPTY_TARGETS);
14761
15198
  const slices = out.get(paneId) ?? [];
14762
15199
  slices.push({ beforeZ, canvas });
@@ -14784,6 +15221,7 @@ var UserDrawingController = class {
14784
15221
  dragged: this.interaction.activeDragId(),
14785
15222
  mutedLabel: edited instanceof TextLabel ? edited.id : null
14786
15223
  };
15224
+ this.painter.seriesLook = this.deps.seriesLook();
14787
15225
  this.painter.paintAll(ctx, this.drawings.filter((d) => !this.isInterleaved(d)), proj, this.deps.theme(), targets);
14788
15226
  this.painter.paintHighlights(ctx, this.drawings.filter((d) => this.isInterleaved(d)), proj, handleIdsFor(targets));
14789
15227
  this.layoutTextEditor();
@@ -14791,6 +15229,10 @@ var UserDrawingController = class {
14791
15229
  if (ghost) this.painter.paintGhost(ctx, ghost, proj, this.deps.theme());
14792
15230
  if (this.externalGhost) this.painter.paintGhost(ctx, this.externalGhost, proj, this.deps.theme());
14793
15231
  this.emitDraft(ghost);
15232
+ if (this.activeTool && !ghost) {
15233
+ const hint = getDrawingType(this.activeTool)?.placementHint;
15234
+ if (hint) this.painter.paintPlacementHint(ctx, hint, this.deps.theme(), proj.width, proj.height);
15235
+ }
14794
15236
  const markers = this.interaction.placingMarkers(proj);
14795
15237
  if (markers) this.painter.paintHandles(ctx, markers);
14796
15238
  const m = this.interaction.snapMarker();
@@ -14802,6 +15244,9 @@ var UserDrawingController = class {
14802
15244
  this.closeTextEditor();
14803
15245
  this.popup.destroy();
14804
15246
  this.toolbar.destroy();
15247
+ this.seriesGwUnsub?.();
15248
+ this.seriesGwUnsub = null;
15249
+ this.seriesGw = null;
14805
15250
  this.intentCb = null;
14806
15251
  this.drawings = [];
14807
15252
  }
@@ -15199,7 +15644,7 @@ function mergeSlices(indicator, user) {
15199
15644
  }
15200
15645
 
15201
15646
  // src/renderers/native/drawings/Projector.ts
15202
- function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
15647
+ function createProjector(coords, paneOf, paneIdAtY, barsInRange, seriesInRange) {
15203
15648
  return {
15204
15649
  xOf: (time) => coords.timeToX(time),
15205
15650
  yOf: (price, paneId) => {
@@ -15220,6 +15665,7 @@ function createProjector(coords, paneOf, paneIdAtY, barsInRange) {
15220
15665
  },
15221
15666
  barsBetween: (t1, t2) => Math.abs(coords.timeToLogical(t2) - coords.timeToLogical(t1)),
15222
15667
  barsInRange: barsInRange ? (from, to) => barsInRange(from, to) : void 0,
15668
+ seriesInRange,
15223
15669
  width: coords.width,
15224
15670
  height: coords.height
15225
15671
  };
@@ -17164,13 +17610,13 @@ var NativeRenderer = class {
17164
17610
  resetView: () => this.resetView(),
17165
17611
  // User drawings claim a gesture before pan when armed / over a drawing.
17166
17612
  drawingsClaim: (x, y) => this.userDrawings?.claim(x, y) ?? false,
17167
- drawingsMeasureStart: (x, y) => this.userDrawings?.beginMeasureAt(x, y) ?? false,
17613
+ drawingsMeasureStart: (x, y, snap) => this.userDrawings?.beginMeasureAt(x, y, snap) ?? false,
17168
17614
  drawingsDeleteAt: (x, y) => this.userDrawings?.deleteAt(x, y) ?? false,
17169
17615
  drawingsCancelPlacement: () => this.userDrawings?.cancelPlacement() ?? false,
17170
17616
  drawingsSnapMode: () => this.snapMode,
17171
17617
  drawingsPointerDown: (x, y, snap, shift) => this.userDrawings?.pointerDown(x, y, snap, shift),
17172
17618
  drawingsPointerMove: (x, y, snap, shift) => this.userDrawings?.pointerMove(x, y, snap, shift),
17173
- drawingsPointerUp: (x, y) => this.userDrawings?.pointerUp(x, y),
17619
+ drawingsPointerUp: (x, y, snap) => this.userDrawings?.pointerUp(x, y, snap),
17174
17620
  drawingsCursor: (x, y) => this.userDrawings?.cursorAt(x, y) ?? null,
17175
17621
  drawingsDblClick: (x, y) => this.userDrawings?.dblClick(x, y) ?? false,
17176
17622
  drawingsClearTransient: () => this.userDrawings?.clearTransient()
@@ -17197,6 +17643,23 @@ var NativeRenderer = class {
17197
17643
  seriesBoundaries: (paneId) => this.scene.seriesBoundaries(paneId),
17198
17644
  priceZ: (paneId) => paneId === PRICE_PANE_ID ? this.scene.candleZ : null,
17199
17645
  requestDataPaint: () => this.scheduler.invalidate(3 /* Light */),
17646
+ // The look the price series ACTUALLY paints with: candle colors resolved through
17647
+ // the per-style override, line/area colors through their configured styles — so
17648
+ // series-mirroring content (the magnifier inset) matches the chart exactly.
17649
+ seriesLook: () => {
17650
+ const st = this.scene.style;
17651
+ const paint = effectiveCandlePaint(st.candle, this.scene.candleOverride, this.theme.upColor, this.theme.downColor);
17652
+ const barsUp = st.bars.upColor ?? this.theme.upColor;
17653
+ const barsDown = st.bars.downColor ?? this.theme.downColor;
17654
+ const style = this.scene.priceStyle;
17655
+ return {
17656
+ style,
17657
+ upColor: style === "bars" ? barsUp : paint.up,
17658
+ downColor: style === "bars" ? barsDown : paint.down,
17659
+ lineColor: style === "area" ? st.area.lineColor ?? this.theme.upColor : st.line.color ?? this.theme.upColor
17660
+ };
17661
+ },
17662
+ chartBarMs: () => this.coords.barInterval,
17200
17663
  snap: (pt, paneId, mode, cursorPx) => this.snapToCandle(pt, paneId, mode, cursorPx),
17201
17664
  setSnapMode: (mode) => this.setSnapMode(mode),
17202
17665
  setToolbarGutter: (px) => this.setToolbarGutter(px)
@@ -17485,7 +17948,8 @@ var NativeRenderer = class {
17485
17948
  }
17486
17949
  const skipFit = opts?.preserveView === true && this.didInitialFit;
17487
17950
  if (this.coords.width > 0 && !skipFit) {
17488
- this.fitContent();
17951
+ if (this.didInitialFit) this.reframeKeepZoom();
17952
+ else this.fitContent();
17489
17953
  this.didInitialFit = true;
17490
17954
  }
17491
17955
  if (!this.introPlayed && this.bars.length > 0) {
@@ -18133,6 +18597,7 @@ var NativeRenderer = class {
18133
18597
  this.scaleDragHeight = res.height;
18134
18598
  this.scaleDragStart = { ...res.holder.scale };
18135
18599
  res.holder.manualScale = { ...res.holder.scale };
18600
+ this.axisScaleButtons?.reposition();
18136
18601
  this.scheduler.invalidate(4 /* Full */);
18137
18602
  }
18138
18603
  /** Rescale the grabbed scale around its center by the total drag (down ⇒ zoom out). */
@@ -18164,6 +18629,7 @@ var NativeRenderer = class {
18164
18629
  const res = this.resolveScaleHolder(x, y);
18165
18630
  if (!res) return;
18166
18631
  res.holder.manualScale = null;
18632
+ this.axisScaleButtons?.reposition();
18167
18633
  this.scheduler.invalidate(4 /* Full */);
18168
18634
  }
18169
18635
  /**
@@ -18587,7 +19053,8 @@ var NativeRenderer = class {
18587
19053
  return p ? { scale: p.scale, bounds: p.bounds, collapsed: p.collapsed } : null;
18588
19054
  },
18589
19055
  (y) => this.paneNodeAtY(y)?.id ?? null,
18590
- (from, to) => this.barsInTimeRange(from, to)
19056
+ (from, to) => this.barsInTimeRange(from, to),
19057
+ this.userDrawings?.seriesGateway ? (tf, from, to) => this.userDrawings.seriesGateway.seriesInRange(tf, from, to) : void 0
18591
19058
  );
18592
19059
  }
18593
19060
  /** OHLC bars whose open-time falls within `[from, to]` (inclusive) — the data a regression
@@ -18801,6 +19268,20 @@ var NativeRenderer = class {
18801
19268
  this.coords.setViewport(v);
18802
19269
  this.targetBarSpacing = v.barSpacing;
18803
19270
  }
19271
+ /** Re-frame after a series replacement (a symbol/timeframe switch): keep the user's
19272
+ * zoom (bar spacing), re-anchor the newest bars at the default right offset.
19273
+ * `clampViewport`'s fit-all-bars floor deliberately does NOT apply — a progressive
19274
+ * head may still be backfilling toward the previous depth, and raising the spacing
19275
+ * to its temporary bar count would lose the zoom this exists to keep. */
19276
+ reframeKeepZoom() {
19277
+ this.animator?.stop();
19278
+ this.panVelocity = 0;
19279
+ for (const pane of this.scene.panes.values()) pane.manualScale = null;
19280
+ for (const sl of this.scene.indicatorScales.values()) sl.manualScale = null;
19281
+ const v = { barSpacing: clampBarSpacing(this.coords.getViewport().barSpacing), rightOffset: defaultViewport().rightOffset };
19282
+ this.coords.setViewport(v);
19283
+ this.targetBarSpacing = v.barSpacing;
19284
+ }
18804
19285
  paneBoundsFor(paneId) {
18805
19286
  const p = this.scene.panes.get(paneId);
18806
19287
  return { top: p?.bounds.top ?? 0, height: p?.bounds.height ?? 0, rightAxis: this.rightAxisW };
@@ -19367,6 +19848,12 @@ var IndicatorHandleImpl = class {
19367
19848
  get visible() {
19368
19849
  return this.visibleState;
19369
19850
  }
19851
+ inputValues() {
19852
+ return this.controller.inputValuesOf(this.id);
19853
+ }
19854
+ propValues() {
19855
+ return this.controller.propValuesOf(this.id);
19856
+ }
19370
19857
  setInput(key, value) {
19371
19858
  this.controller.applyInputs(this.id, { [key]: value });
19372
19859
  }
@@ -19488,17 +19975,179 @@ function presetToRange(preset, bars) {
19488
19975
  return { from: Math.max(first, from), to: last };
19489
19976
  }
19490
19977
 
19491
- // src/core/price-styles/BarTransform.ts
19492
- function barTransformFor(style) {
19493
- return chartType(style)?.barTransform ?? null;
19494
- }
19495
- function parseExtendedTicker(symbol) {
19496
- const at = symbol.lastIndexOf(";");
19497
- if (at <= 0 || at === symbol.length - 1) return { symbol, modifier: null, transform: null };
19498
- const modifier = symbol.slice(at + 1).toLowerCase();
19499
- if (modifier !== "standard" && !tickerModifierIds().includes(modifier)) return { symbol, modifier: null, transform: null };
19500
- return { symbol: symbol.slice(0, at), modifier, transform: barTransformFor(modifier) };
19501
- }
19978
+ // src/core/engine/DrawingSeriesService.ts
19979
+ var MAX_BARS = 5e3;
19980
+ var PAD_FRAC = 0.25;
19981
+ var MAX_ENTRIES = 16;
19982
+ var RETRY_MS = 15e3;
19983
+ var AUTO_STEPS = ["240", "60", "30", "15", "5", "1"];
19984
+ var DrawingSeriesService = class {
19985
+ constructor(deps) {
19986
+ this.deps = deps;
19987
+ /** Cached windows per `market|timeframe` key, newest-used last (LRU across keys). */
19988
+ this.cache = /* @__PURE__ */ new Map();
19989
+ this.listeners = /* @__PURE__ */ new Set();
19990
+ }
19991
+ seriesInRange(timeframe, from, to) {
19992
+ if (!this.deps.canFetch()) return { state: "unavailable", reason: "no-source" };
19993
+ const resolved = this.resolveTimeframe(timeframe);
19994
+ if (typeof resolved !== "string") return { state: "unavailable", reason: resolved.reason };
19995
+ const barMs = timeframeToMs(resolved);
19996
+ const lo = Math.min(from, to);
19997
+ const hi = Math.max(from, to);
19998
+ if (!(hi > lo) || !(barMs > 0)) return { state: "unavailable", reason: "not-lower" };
19999
+ if ((hi - lo) / barMs > MAX_BARS) return { state: "unavailable", reason: "too-wide" };
20000
+ const key = `${this.deps.marketKey()}|${resolved}`;
20001
+ const entries = this.cache.get(key) ?? [];
20002
+ const covering = entries.find((e) => e.from <= lo && e.to >= hi);
20003
+ if (covering) {
20004
+ if (covering.pending) return this.loading(entries, resolved, barMs, lo, hi);
20005
+ if (covering.failedAt > 0) {
20006
+ if (Date.now() - covering.failedAt < RETRY_MS) return this.loading(entries, resolved, barMs, lo, hi);
20007
+ entries.splice(entries.indexOf(covering), 1);
20008
+ } else {
20009
+ this.maybeRefresh(key, covering, resolved, barMs, hi);
20010
+ return { state: "ready", bars: this.slice(covering.bars, lo, hi), timeframe: resolved, barMs };
20011
+ }
20012
+ }
20013
+ this.fetchWindow(key, entries, resolved, lo, hi);
20014
+ return this.loading(entries, resolved, barMs, lo, hi);
20015
+ }
20016
+ onUpdate(listener) {
20017
+ this.listeners.add(listener);
20018
+ return () => this.listeners.delete(listener);
20019
+ }
20020
+ // ── internals ──
20021
+ /** `'auto'` → the largest standard step at least 4× finer than the chart (else the finest
20022
+ * step still below it); an explicit timeframe passes only when strictly finer. Failures
20023
+ * distinguish "this pick isn't lower" from "NOTHING lower exists" (the chart is already
20024
+ * at the finest offered step) so the consumer can word its notice honestly. */
20025
+ resolveTimeframe(timeframe) {
20026
+ const chartMs = timeframeToMs(this.deps.chartTimeframe());
20027
+ const finest = AUTO_STEPS[AUTO_STEPS.length - 1];
20028
+ if (timeframeToMs(finest) >= chartMs) return { reason: "none-lower" };
20029
+ const tf = timeframe.trim() || "auto";
20030
+ if (tf === "auto") {
20031
+ for (const step of AUTO_STEPS) {
20032
+ if (timeframeToMs(step) <= chartMs / 4) return step;
20033
+ }
20034
+ return finest;
20035
+ }
20036
+ return timeframeToMs(tf) < chartMs ? tf : { reason: "not-lower" };
20037
+ }
20038
+ /** The `loading` answer, carrying best-effort PARTIAL bars from settled overlapping
20039
+ * windows — a widened window keeps painting what it already has while it fetches. */
20040
+ loading(entries, timeframe, barMs, lo, hi) {
20041
+ const partial = /* @__PURE__ */ new Map();
20042
+ for (const e of entries) {
20043
+ if (e.pending || e.failedAt > 0) continue;
20044
+ if (e.to < lo || e.from > hi) continue;
20045
+ for (const b of this.slice(e.bars, lo, hi)) partial.set(b.time, b);
20046
+ }
20047
+ if (partial.size === 0) return { state: "loading", timeframe, barMs };
20048
+ const bars = [...partial.values()].sort((a, b) => a.time - b.time);
20049
+ return { state: "loading", timeframe, barMs, bars };
20050
+ }
20051
+ /** Kick ONE background fetch for the padded window. Any OVERLAPPING in-flight fetch
20052
+ * defers this one (a corner drag repaints per pointer move — kicking a window per
20053
+ * frame would spam the provider); when it lands, the next paint re-evaluates. */
20054
+ fetchWindow(key, entries, timeframe, lo, hi) {
20055
+ if (entries.some((e) => e.pending && e.to >= lo && e.from <= hi)) return;
20056
+ const pad = (hi - lo) * PAD_FRAC;
20057
+ const entry = { from: lo - pad, to: hi + pad, bars: [], fetchedAt: 0, pending: true, failedAt: 0 };
20058
+ entries.push(entry);
20059
+ this.cache.set(key, entries);
20060
+ this.evict();
20061
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
20062
+ entry.bars = bars;
20063
+ entry.fetchedAt = Date.now();
20064
+ entry.pending = false;
20065
+ this.absorbOverlaps(key, entry);
20066
+ this.fire();
20067
+ }).catch(() => {
20068
+ entry.pending = false;
20069
+ entry.failedAt = Date.now();
20070
+ this.fire();
20071
+ });
20072
+ }
20073
+ /** A window whose right edge reaches the newest fetched bar refreshes at most once per
20074
+ * bar interval — new closed bars ride the feed's cache, only the live tail re-fetches. */
20075
+ maybeRefresh(key, entry, timeframe, barMs, hi) {
20076
+ if (entry.pending) return;
20077
+ const lastBar = entry.bars.length > 0 ? entry.bars[entry.bars.length - 1].time : entry.from;
20078
+ if (hi < lastBar) return;
20079
+ if (Date.now() - entry.fetchedAt < barMs) return;
20080
+ entry.pending = true;
20081
+ void this.deps.fetchBars(timeframe, { from: entry.from, to: entry.to }).then((bars) => {
20082
+ entry.bars = bars;
20083
+ entry.fetchedAt = Date.now();
20084
+ entry.pending = false;
20085
+ this.fire();
20086
+ }).catch(() => {
20087
+ entry.pending = false;
20088
+ entry.fetchedAt = Date.now();
20089
+ });
20090
+ }
20091
+ /** Merge windows that overlap `entry` into it (dedupe by bar time) so a key's list
20092
+ * converges instead of accumulating slivers. */
20093
+ absorbOverlaps(key, entry) {
20094
+ const entries = this.cache.get(key);
20095
+ if (!entries) return;
20096
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
20097
+ const other = entries[i];
20098
+ if (other === entry || other.pending || other.failedAt > 0) continue;
20099
+ if (other.to < entry.from || other.from > entry.to) continue;
20100
+ const byTime = /* @__PURE__ */ new Map();
20101
+ for (const b of other.bars) byTime.set(b.time, b);
20102
+ for (const b of entry.bars) byTime.set(b.time, b);
20103
+ entry.bars = [...byTime.values()].sort((a, b) => a.time - b.time);
20104
+ entry.from = Math.min(entry.from, other.from);
20105
+ entry.to = Math.max(entry.to, other.to);
20106
+ entry.fetchedAt = Math.min(entry.fetchedAt, other.fetchedAt || entry.fetchedAt);
20107
+ entries.splice(i, 1);
20108
+ }
20109
+ }
20110
+ /** Drop the oldest settled windows once the global count passes {@link MAX_ENTRIES}. */
20111
+ evict() {
20112
+ let total = 0;
20113
+ for (const entries of this.cache.values()) total += entries.length;
20114
+ while (total > MAX_ENTRIES) {
20115
+ let oldestKey = null;
20116
+ let oldestIdx = -1;
20117
+ let oldestAt = Infinity;
20118
+ for (const [key, entries2] of this.cache) {
20119
+ for (let i = 0; i < entries2.length; i += 1) {
20120
+ const e = entries2[i];
20121
+ if (e.pending) continue;
20122
+ const at = e.fetchedAt || e.failedAt;
20123
+ if (at < oldestAt) {
20124
+ oldestKey = key;
20125
+ oldestIdx = i;
20126
+ oldestAt = at;
20127
+ }
20128
+ }
20129
+ }
20130
+ if (oldestKey == null) return;
20131
+ const entries = this.cache.get(oldestKey);
20132
+ entries.splice(oldestIdx, 1);
20133
+ if (entries.length === 0) this.cache.delete(oldestKey);
20134
+ total -= 1;
20135
+ }
20136
+ }
20137
+ /** Bars whose open time falls within `[lo, hi]` (ascending input → linear scan is fine). */
20138
+ slice(bars, lo, hi) {
20139
+ const out = [];
20140
+ for (const b of bars) {
20141
+ if (b.time < lo) continue;
20142
+ if (b.time > hi) break;
20143
+ out.push(b);
20144
+ }
20145
+ return out;
20146
+ }
20147
+ fire() {
20148
+ for (const l of [...this.listeners]) l();
20149
+ }
20150
+ };
19502
20151
 
19503
20152
  // src/core/engine/EngineOrchestrator.ts
19504
20153
  var VIEWPORT_DEBOUNCE_MS = 150;
@@ -19506,6 +20155,7 @@ var RUN_EMIT_THROTTLE_MS = 1e3;
19506
20155
  var PREVIEW_BARS = 300;
19507
20156
  var SINGLE_LOAD_BARS = 5e3;
19508
20157
  var CHUNK_BARS = 1e4;
20158
+ var FIRST_PAINT_BARS = 100;
19509
20159
  var GAP_FACTOR = 1.5;
19510
20160
  var HEAL_COOLDOWN_MS = 5e3;
19511
20161
  var EngineOrchestrator = class _EngineOrchestrator {
@@ -19623,7 +20273,13 @@ var EngineOrchestrator = class _EngineOrchestrator {
19623
20273
  const initialStyle = this.renderer.readFeature("priceStyle");
19624
20274
  if (typeof initialStyle === "string") this.priceStyle = initialStyle;
19625
20275
  this.barTransform = barTransformFor(initialStyle);
19626
- this.drawings = new DrawingController(this.renderer, this.events, config.drawings);
20276
+ const drawingSeries = new DrawingSeriesService({
20277
+ fetchBars: (tf, range) => this.fetchSeries(this.config.market.symbol ?? "", tf, range),
20278
+ canFetch: () => !!this.feed.loadRange && !this.config.market.data?.length && !!this.config.market.symbol,
20279
+ chartTimeframe: () => this.config.market.timeframe ?? "60",
20280
+ marketKey: () => `${this.config.market.symbol ?? ""}|${this.config.market.session ?? ""}`
20281
+ });
20282
+ this.drawings = new DrawingController(this.renderer, this.events, config.drawings, drawingSeries);
19627
20283
  this.unresolvedUnsub = this.feed.onUnresolved?.((info) => {
19628
20284
  this.endLoad();
19629
20285
  this.events.emit("data:unresolved", info);
@@ -19775,6 +20431,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
19775
20431
  let painted = false;
19776
20432
  const paint = (bars, final) => {
19777
20433
  if (this.generation !== gen || !final && bars.length === 0) return;
20434
+ if (!painted && !final && bars.length < Math.min(requested, FIRST_PAINT_BARS)) return;
19778
20435
  this.setBarSeries(bars, painted ? { preserveView: true } : void 0);
19779
20436
  if (!painted && bars.length > 0) {
19780
20437
  painted = true;
@@ -20405,6 +21062,15 @@ var EngineOrchestrator = class _EngineOrchestrator {
20405
21062
  record.pendingCause = "inputs";
20406
21063
  if (record.session) record.session.update(record.inputValues);
20407
21064
  else if (record.native && !record.hidden) record.native.instance.setInputs(record.inputValues);
21065
+ this.events.emit("indicator:inputs", { id });
21066
+ }
21067
+ /** IndicatorController: the CURRENT stored input values (defaults merged with edits). */
21068
+ inputValuesOf(id) {
21069
+ return { ...this.registry.get(id)?.inputValues };
21070
+ }
21071
+ /** IndicatorController: the CURRENT declaration-prop overrides. */
21072
+ propValuesOf(id) {
21073
+ return { ...this.registry.get(id)?.propValues };
20408
21074
  }
20409
21075
  /** IndicatorController: re-run an indicator with merged declaration-prop overrides.
20410
21076
  * Same lifecycle as {@link applyInputs} — a prop change replays the whole script.
@@ -20418,6 +21084,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20418
21084
  if (!record.hidden) this.setLoading(record, true);
20419
21085
  record.pendingCause = "inputs";
20420
21086
  record.session.update(record.inputValues, record.propValues);
21087
+ this.events.emit("indicator:inputs", { id });
20421
21088
  }
20422
21089
  /** IndicatorController: tear down an indicator and (if now empty) its pane. */
20423
21090
  /** Live handles of every indicator on the chart (script + native), insertion order. */
@@ -20605,8 +21272,8 @@ var EngineOrchestrator = class _EngineOrchestrator {
20605
21272
  onModel: (model) => {
20606
21273
  const first = !record.announced;
20607
21274
  const cause = record.pendingCause ?? "history";
21275
+ if (!this.applyModel(id, model)) return;
20608
21276
  record.pendingCause = void 0;
20609
- this.applyModel(id, model);
20610
21277
  this.emitContextChanged(id);
20611
21278
  this.emitScriptRun(id, cause, first);
20612
21279
  },
@@ -20899,10 +21566,16 @@ var EngineOrchestrator = class _EngineOrchestrator {
20899
21566
  * Apply an emitted model. First emission mounts (and routes the pane); a pending
20900
21567
  * structural change (after an input edit) remounts idempotently; everything else
20901
21568
  * (live tick / viewport re-run) value-patches.
21569
+ *
21570
+ * Returns false when the model was DEFERRED — an output-free model arriving while
21571
+ * the record is still loading and the chart has no bars (see below); every other
21572
+ * outcome, including the hidden drop, returns true so the caller's event semantics
21573
+ * stay unchanged.
20902
21574
  */
20903
21575
  applyModel(id, model) {
20904
21576
  const record = this.registry.get(id);
20905
- if (!record || record.hidden) return;
21577
+ if (!record || record.hidden) return true;
21578
+ if (record.loading && this.bars.length === 0 && !_EngineOrchestrator.modelHasOutput(model)) return false;
20906
21579
  const handle = this.handles.get(id);
20907
21580
  if (!record.renderHandle) {
20908
21581
  const paneId2 = this.routePane(id, model, record.options ?? {});
@@ -20912,7 +21585,7 @@ var EngineOrchestrator = class _EngineOrchestrator {
20912
21585
  record.renderHandle = this.renderer.mountIndicator(model);
20913
21586
  record.pendingStructural = false;
20914
21587
  this.announce(record, handle);
20915
- return;
21588
+ return true;
20916
21589
  }
20917
21590
  let paneId = record.model?.paneId ?? "price";
20918
21591
  const prevOwnScale = record.model?.ownScale === true;
@@ -20940,6 +21613,11 @@ var EngineOrchestrator = class _EngineOrchestrator {
20940
21613
  }
20941
21614
  if (record.loading) this.setLoading(record, false);
20942
21615
  this.announce(record, handle);
21616
+ return true;
21617
+ }
21618
+ /** True when the model carries ANY executed output — series, drawings, bar colors, or trades. */
21619
+ static modelHasOutput(model) {
21620
+ 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;
20943
21621
  }
20944
21622
  routePane(id, model, options) {
20945
21623
  if (options.pane === "new") return `pane-${id}`;