acttrader-charts 1.0.13 → 1.0.15

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.
package/dist/index.js CHANGED
@@ -117,6 +117,33 @@ var _ScaleManager = class _ScaleManager {
117
117
  };
118
118
  _ScaleManager.VERTICAL_PADDING_PX = 20;
119
119
  var ScaleManager = _ScaleManager;
120
+ function niceStep(rawStep) {
121
+ if (!isFinite(rawStep) || rawStep <= 0) return 1;
122
+ const exp = Math.floor(Math.log10(rawStep));
123
+ const base = Math.pow(10, exp);
124
+ const frac = rawStep / base;
125
+ const nice = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
126
+ return nice * base;
127
+ }
128
+ function generatePriceTicks(range, chartHeight, targetPxPerLabel = 48) {
129
+ const { min, max } = range;
130
+ if (!isFinite(min) || !isFinite(max) || max <= min) return [min];
131
+ const approxSteps = Math.max(2, Math.floor(chartHeight / targetPxPerLabel));
132
+ const rawStep = (max - min) / approxSteps;
133
+ const step = niceStep(rawStep);
134
+ const first = Math.ceil(min / step) * step;
135
+ const ticks = [];
136
+ const eps = step * 1e-6;
137
+ for (let v = first; v <= max + eps; v += step) {
138
+ ticks.push(v);
139
+ }
140
+ return ticks;
141
+ }
142
+ function precisionForStep(step, minPrecision = 0) {
143
+ if (!isFinite(step) || step <= 0) return minPrecision;
144
+ const exp = Math.floor(Math.log10(step));
145
+ return Math.max(minPrecision, -exp);
146
+ }
120
147
 
121
148
  // src/core/EventEmitter.ts
122
149
  var EventEmitter = class {
@@ -169,6 +196,11 @@ function resolveCssVarsInTheme(theme, container) {
169
196
  container.appendChild(tmp);
170
197
  const resolve = (value) => {
171
198
  if (!value.includes("var(")) return value;
199
+ value = value.replace(
200
+ /rgba\(\s*(var\([^)]+\))\s*,\s*([0-9.]+)\s*\)/g,
201
+ "rgb($1 / $2)"
202
+ );
203
+ tmp.style.color = "";
172
204
  tmp.style.color = value;
173
205
  const resolved = getComputedStyle(tmp).color;
174
206
  return resolved || value;
@@ -535,7 +567,8 @@ var DEFAULT_LABELS = {
535
567
  toggleDrawingTitle: "Toggle Drawing Tools",
536
568
  toggleFullscreenTitle: "Toggle Fullscreen",
537
569
  exitFullscreenTitle: "Exit Fullscreen",
538
- settingsTitle: "Settings"
570
+ settingsTitle: "Settings",
571
+ tfcToggleTitle: "Trade from Charts"
539
572
  },
540
573
  bottomBar: {
541
574
  durations: {
@@ -726,8 +759,7 @@ function resolveLabels(override) {
726
759
 
727
760
  // src/interaction/PanHandler.ts
728
761
  var _PanHandler = class _PanHandler {
729
- // px — below this = click, not drag
730
- constructor(canvas, getViewport, setViewport, totalBars, onUpdate, isLocked = () => false, panY, onDragStart, onDragEnd) {
762
+ constructor(canvas, getViewport, setViewport, totalBars, onUpdate, isLocked = () => false, panY, onDragStart, onDragEnd, momentumOptions) {
731
763
  this.canvas = canvas;
732
764
  this.getViewport = getViewport;
733
765
  this.setViewport = setViewport;
@@ -745,7 +777,12 @@ var _PanHandler = class _PanHandler {
745
777
  this.dragStartY = 0;
746
778
  this.hasPanned = false;
747
779
  this.dragStartFired = false;
780
+ // ── Velocity sample buffer (rolling 100 ms window) ─────────────────────────
781
+ this._velBuffer = [];
782
+ // ── In-flight momentum RAF ─────────────────────────────────────────────────
783
+ this._momentumRafId = null;
748
784
  this.onMouseDown = (e) => {
785
+ this._stopMomentum();
749
786
  if (this.isLocked()) return;
750
787
  this.isDragging = true;
751
788
  this.isTouchDrag = false;
@@ -754,10 +791,15 @@ var _PanHandler = class _PanHandler {
754
791
  this.dragStartX = e.clientX;
755
792
  this.dragStartY = e.clientY;
756
793
  this.hasPanned = false;
794
+ this._velBuffer.length = 0;
757
795
  };
758
796
  this.onTouchStart = (e) => {
797
+ if (e.touches.length !== 1) {
798
+ this._stopMomentum();
799
+ return;
800
+ }
801
+ this._stopMomentum();
759
802
  if (this.isLocked()) return;
760
- if (e.touches.length !== 1) return;
761
803
  this.isDragging = true;
762
804
  this.isTouchDrag = true;
763
805
  this.lastX = e.touches[0].clientX;
@@ -765,6 +807,7 @@ var _PanHandler = class _PanHandler {
765
807
  this.dragStartX = e.touches[0].clientX;
766
808
  this.dragStartY = e.touches[0].clientY;
767
809
  this.dragStartFired = true;
810
+ this._velBuffer.length = 0;
768
811
  this.onDragStart?.();
769
812
  };
770
813
  this.onMouseMove = (e) => {
@@ -787,6 +830,7 @@ var _PanHandler = class _PanHandler {
787
830
  this.canvas.style.cursor = "grabbing";
788
831
  }
789
832
  if (this.hasPanned) {
833
+ this._recordVelocitySample(deltaX, performance.now());
790
834
  this.pan(deltaX);
791
835
  if (this.panY) this.panY(deltaY);
792
836
  }
@@ -800,6 +844,7 @@ var _PanHandler = class _PanHandler {
800
844
  e.preventDefault();
801
845
  const deltaX = e.touches[0].clientX - this.lastX;
802
846
  const deltaY = e.touches[0].clientY - this.lastY;
847
+ this._recordVelocitySample(deltaX, performance.now());
803
848
  this.pan(deltaX);
804
849
  if (this.panY) this.panY(deltaY);
805
850
  this.lastX = e.touches[0].clientX;
@@ -811,9 +856,16 @@ var _PanHandler = class _PanHandler {
811
856
  this.hasPanned = false;
812
857
  if (this.dragStartFired) {
813
858
  this.dragStartFired = false;
814
- this.onDragEnd?.();
859
+ const velocity = this._computeReleaseVelocity();
860
+ this._velBuffer.length = 0;
861
+ const launched = this._startMomentum(velocity);
862
+ if (!launched) this.onDragEnd?.();
815
863
  }
816
864
  };
865
+ this._momentumEnabled = momentumOptions?.enabled ?? true;
866
+ this._momentumDecay = Math.min(0.99, Math.max(0.8, momentumOptions?.decay ?? 0.95));
867
+ this._momentumThreshold = momentumOptions?.threshold ?? 0.3;
868
+ this._momentumMaxVelocity = momentumOptions?.maxVelocity ?? 6;
817
869
  this.attach();
818
870
  }
819
871
  attach() {
@@ -824,12 +876,14 @@ var _PanHandler = class _PanHandler {
824
876
  window.addEventListener("touchmove", this.onTouchMove, { passive: false });
825
877
  window.addEventListener("touchend", this.onMouseUp);
826
878
  }
827
- /** Cancel any in-progress pan gesture (e.g. when a drawing drag takes over). */
879
+ /** Cancel any in-progress pan gesture or momentum (e.g. when a drawing drag takes over). */
828
880
  cancel() {
881
+ this._stopMomentum();
829
882
  this.isDragging = false;
830
883
  this.hasPanned = false;
831
884
  }
832
885
  destroy() {
886
+ this._stopMomentum();
833
887
  this.canvas.removeEventListener("mousedown", this.onMouseDown);
834
888
  window.removeEventListener("mousemove", this.onMouseMove);
835
889
  window.removeEventListener("mouseup", this.onMouseUp);
@@ -837,6 +891,73 @@ var _PanHandler = class _PanHandler {
837
891
  window.removeEventListener("touchmove", this.onTouchMove);
838
892
  window.removeEventListener("touchend", this.onMouseUp);
839
893
  }
894
+ // ── Velocity tracking ────────────────────────────────────────────────────────
895
+ _recordVelocitySample(dx, t) {
896
+ this._velBuffer.push({ dx, t });
897
+ const cutoff = t - _PanHandler.VEL_WINDOW_MS;
898
+ let i = 0;
899
+ while (i < this._velBuffer.length && this._velBuffer[i].t < cutoff) i++;
900
+ if (i > 0) this._velBuffer.splice(0, i);
901
+ }
902
+ _computeReleaseVelocity() {
903
+ const buf = this._velBuffer;
904
+ if (buf.length < 2) return 0;
905
+ const totalDx = buf.reduce((acc, s) => acc + s.dx, 0);
906
+ const dt = buf[buf.length - 1].t - buf[0].t;
907
+ if (dt <= 0) return 0;
908
+ const raw = totalDx / dt;
909
+ return Math.sign(raw) * Math.min(Math.abs(raw), this._momentumMaxVelocity);
910
+ }
911
+ // ── Momentum animation ────────────────────────────────────────────────────────
912
+ /**
913
+ * Launch momentum scrolling with the given initial velocity (px/ms).
914
+ * Returns `true` if momentum was successfully launched, `false` otherwise.
915
+ */
916
+ _startMomentum(velocity) {
917
+ if (!this._momentumEnabled) return false;
918
+ if (Math.abs(velocity) < this._momentumThreshold) return false;
919
+ let lastTime = null;
920
+ let vel = velocity;
921
+ const decay = this._momentumDecay;
922
+ const tick = (now) => {
923
+ if (lastTime === null) {
924
+ lastTime = now;
925
+ this._momentumRafId = requestAnimationFrame(tick);
926
+ return;
927
+ }
928
+ const dt = now - lastTime;
929
+ lastTime = now;
930
+ vel *= Math.pow(decay, dt / 16.67);
931
+ const pixelDelta = vel * dt;
932
+ if (Math.abs(pixelDelta) < 0.5) {
933
+ this._stopMomentum();
934
+ this.onDragEnd?.();
935
+ return;
936
+ }
937
+ if (this.isLocked()) {
938
+ this._stopMomentum();
939
+ this.onDragEnd?.();
940
+ return;
941
+ }
942
+ const prevStart = this.getViewport().startIndex;
943
+ this.pan(pixelDelta);
944
+ if (this.getViewport().startIndex === prevStart) {
945
+ this._stopMomentum();
946
+ this.onDragEnd?.();
947
+ return;
948
+ }
949
+ this._momentumRafId = requestAnimationFrame(tick);
950
+ };
951
+ this._momentumRafId = requestAnimationFrame(tick);
952
+ return true;
953
+ }
954
+ _stopMomentum() {
955
+ if (this._momentumRafId !== null) {
956
+ cancelAnimationFrame(this._momentumRafId);
957
+ this._momentumRafId = null;
958
+ }
959
+ }
960
+ // ── Core pan logic ────────────────────────────────────────────────────────────
840
961
  pan(deltaX) {
841
962
  const vp = this.getViewport();
842
963
  const visibleBars = vp.endIndex - vp.startIndex;
@@ -850,6 +971,7 @@ var _PanHandler = class _PanHandler {
850
971
  }
851
972
  };
852
973
  _PanHandler.DRAG_THRESHOLD = 4;
974
+ _PanHandler.VEL_WINDOW_MS = 100;
853
975
  var PanHandler = _PanHandler;
854
976
 
855
977
  // src/interaction/ZoomHandler.ts
@@ -889,11 +1011,11 @@ var ZoomHandler = class {
889
1011
  Math.max(MIN_VISIBLE_BARS, Math.min(MAX_VISIBLE_BARS, visibleBars * zoomFactor))
890
1012
  );
891
1013
  if (vp.endIndex >= this.totalBars()) {
892
- const newEnd = vp.endIndex;
1014
+ const newEnd = Math.min(vp.endIndex, this.totalBars() + Math.floor(newVisible / 2));
893
1015
  const newStart = Math.max(0, newEnd - newVisible);
894
- this.setViewport({ ...vp, startIndex: newStart, endIndex: newEnd });
1016
+ this.setViewport({ ...vp, startIndex: newStart, endIndex: newStart + newVisible });
895
1017
  } else {
896
- const mouseRatio = e.offsetX / this.canvas.width;
1018
+ const mouseRatio = e.offsetX / this.getChartWidth();
897
1019
  const pivotBar = vp.startIndex + visibleBars * mouseRatio;
898
1020
  const newStart = Math.max(0, pivotBar - newVisible * mouseRatio);
899
1021
  this.setViewport({ ...vp, startIndex: newStart, endIndex: newStart + newVisible });
@@ -1430,18 +1552,18 @@ function renderHighLow(ctx, bars, viewport, priceRange, scale, theme, chartWidth
1430
1552
  }
1431
1553
 
1432
1554
  // src/overlays/GridOverlay.ts
1433
- function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines = 6) {
1555
+ function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines) {
1434
1556
  ctx.save();
1435
1557
  ctx.globalAlpha = 0.6;
1436
1558
  ctx.strokeStyle = theme.grid;
1437
1559
  ctx.lineWidth = 1;
1438
- const step = (priceRange.max - priceRange.min) / gridLines;
1439
1560
  ctx.beginPath();
1440
- for (let i = 0; i <= gridLines; i++) {
1441
- const price = priceRange.min + step * i;
1442
- const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1443
- ctx.moveTo(0, y);
1444
- ctx.lineTo(chartWidth, y);
1561
+ {
1562
+ for (const price of generatePriceTicks(priceRange, chartHeight)) {
1563
+ const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1564
+ ctx.moveTo(0, y);
1565
+ ctx.lineTo(chartWidth, y);
1566
+ }
1445
1567
  }
1446
1568
  ctx.stroke();
1447
1569
  const vertCols = Math.max(1, Math.floor(chartWidth / 80));
@@ -1458,19 +1580,19 @@ function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, grid
1458
1580
 
1459
1581
  // src/overlays/CrosshairOverlay.ts
1460
1582
  var SUB_DAILY = ["1m", "5m", "15m", "30m", "1h", "4h"];
1461
- function formatCrosshairTime(ts, timeframe) {
1583
+ function formatCrosshairTime(ts, timeframe, timezone = "UTC") {
1462
1584
  const d = new Date(ts);
1463
1585
  if (SUB_DAILY.includes(timeframe)) {
1464
- const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
1465
- const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" });
1586
+ const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: timezone });
1587
+ const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: timezone });
1466
1588
  return `${date} ${time}`;
1467
1589
  }
1468
1590
  if (timeframe === "1M") {
1469
- return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
1591
+ return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: timezone });
1470
1592
  }
1471
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
1593
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: timezone });
1472
1594
  }
1473
- function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidth, chartHeight, priceAxisWidth, axisY, timeframe, pricePrecision = 2, cfg = DEFAULT_CROSSHAIR_CONFIG) {
1595
+ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidth, chartHeight, priceAxisWidth, axisY, timeframe, pricePrecision = 2, cfg = DEFAULT_CROSSHAIR_CONFIG, timezone = "UTC") {
1474
1596
  const x = scale.xToPixel(pos.barIndex, viewport, chartWidth);
1475
1597
  const y = scale.yToPixel(pos.price, priceRange, chartHeight);
1476
1598
  ctx.setLineDash([4, 4]);
@@ -1498,7 +1620,7 @@ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidt
1498
1620
  ctx.textAlign = "left";
1499
1621
  ctx.fillText(priceLabel, chartWidth + labelPad, y + 4);
1500
1622
  if (pos.bar) {
1501
- const timeLabel = formatCrosshairTime(pos.bar.time, timeframe);
1623
+ const timeLabel = formatCrosshairTime(pos.bar.time, timeframe, timezone);
1502
1624
  const tw = ctx.measureText(timeLabel).width;
1503
1625
  const boxW = tw + labelPad * 2;
1504
1626
  const boxX = Math.max(0, Math.min(chartWidth - boxW, x - boxW / 2));
@@ -1513,8 +1635,70 @@ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidt
1513
1635
  }
1514
1636
  }
1515
1637
 
1638
+ // src/overlays/TradeLevelCluster.ts
1639
+ function detectClusters(measured, thresholdPx, excludeLabels = /* @__PURE__ */ new Set()) {
1640
+ const eligible = measured.filter((m) => !excludeLabels.has(m.level.label)).sort((a, b) => a.y - b.y);
1641
+ if (eligible.length < 2) return [];
1642
+ const groups = [];
1643
+ let currentGroup = [eligible[0]];
1644
+ let groupStartY = eligible[0].y;
1645
+ for (let i = 1; i < eligible.length; i++) {
1646
+ const m = eligible[i];
1647
+ if (m.y - groupStartY <= thresholdPx) {
1648
+ currentGroup.push(m);
1649
+ } else {
1650
+ groups.push(currentGroup);
1651
+ currentGroup = [m];
1652
+ groupStartY = m.y;
1653
+ }
1654
+ }
1655
+ groups.push(currentGroup);
1656
+ return groups.filter((g) => g.length >= 2).map((members) => {
1657
+ const id = "cluster:" + members.map((m) => m.level.label).sort().join("|");
1658
+ const centerY = members.reduce((sum, m) => sum + m.y, 0) / members.length;
1659
+ const centerPrice = members.reduce((sum, m) => sum + m.price, 0) / members.length;
1660
+ return { id, members, centerY, centerPrice };
1661
+ });
1662
+ }
1663
+ function computeFanOutLayout(cluster, boxH, gapPx = 4) {
1664
+ const n = cluster.members.length;
1665
+ const totalH = n * boxH + (n - 1) * gapPx;
1666
+ const startY = cluster.centerY - totalH / 2;
1667
+ const sorted = [...cluster.members].sort((a, b) => a.y - b.y);
1668
+ const result = /* @__PURE__ */ new Map();
1669
+ for (let i = 0; i < sorted.length; i++) {
1670
+ result.set(sorted[i].level.label, startY + i * (boxH + gapPx));
1671
+ }
1672
+ return result;
1673
+ }
1674
+ function buildClusterBadgeLayout(cluster, chartW, ctx) {
1675
+ const n = cluster.members.length;
1676
+ const types = new Set(cluster.members.map((m) => m.level.type));
1677
+ let noun;
1678
+ if (types.size === 1) {
1679
+ const t = types.values().next().value;
1680
+ noun = t === "position" ? "Position" : t === "pending" ? "Order" : "Trade";
1681
+ } else {
1682
+ noun = "Level";
1683
+ }
1684
+ const text = `${n} ${noun}${n > 1 ? "s" : ""}`;
1685
+ const PAD_X = 12;
1686
+ const textW = ctx.measureText(text).width;
1687
+ const EXPAND_ICON_W = 16;
1688
+ const badgeW = PAD_X + textW + EXPAND_ICON_W + PAD_X;
1689
+ const badgeH = 24;
1690
+ const badgeX = (chartW - badgeW) / 2;
1691
+ const badgeY = cluster.centerY - badgeH / 2;
1692
+ return { badgeX, badgeY, badgeW, badgeH, text };
1693
+ }
1694
+ function hitTestClusterBadge(x, y, areas) {
1695
+ return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
1696
+ }
1697
+
1516
1698
  // src/overlays/TradeLevelOverlay.ts
1517
1699
  var BOX_PAD_X = 8;
1700
+ var PILL_PAD_X = 6;
1701
+ var PILL_GAP = 6;
1518
1702
  var BOX_PAD_Y = 4;
1519
1703
  var ROW_H = 16;
1520
1704
  var BOX_RADIUS = 3;
@@ -1631,7 +1815,7 @@ function isDraggable(level) {
1631
1815
  return !!level.entryPriceEditable;
1632
1816
  return false;
1633
1817
  }
1634
- function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH, precision, hoveredLabel = null, draggingLabel = null, dragNewPrice = null, selectedLabel = null, pendingLabels = /* @__PURE__ */ new Set(), positionStyle = "line", hideConfirmCancel = false, draftOrderLabel = null, draftBracketPnl = {}) {
1818
+ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH, precision, hoveredLabel = null, draggingLabel = null, dragNewPrice = null, selectedLabel = null, pendingLabels = /* @__PURE__ */ new Set(), positionStyle = "line", hideConfirmCancel = false, draftOrderLabel = null, draftBracketPnl = {}, expandedClusters = /* @__PURE__ */ new Set(), clusterThresholdPx = 0, showQuantityField = false) {
1635
1819
  if (levels.length === 0)
1636
1820
  return {
1637
1821
  hitAreas: [],
@@ -1639,7 +1823,9 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1639
1823
  hoverAreas: [],
1640
1824
  dragAreas: [],
1641
1825
  confirmAreas: [],
1642
- addBracketAreas: []
1826
+ addBracketAreas: [],
1827
+ clusterBadgeAreas: [],
1828
+ clusters: []
1643
1829
  };
1644
1830
  const colors = theme.tradeLevels;
1645
1831
  const boxH = ROW_H + BOX_PAD_Y * 2;
@@ -1667,16 +1853,52 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1667
1853
  const effectivePending = hasPending && !hideConfirmCancel;
1668
1854
  const confirmW = effectivePending ? (CONFIRM_R * 2 + CONFIRM_GAP) * 2 : 0;
1669
1855
  const closeW = effectivePending || hideConfirmCancel ? 0 : EDIT_R * 2 + EDIT_GAP + CLOSE_R * 2 + BOX_PAD_X / 2;
1670
- const bW = BOX_PAD_X + pnlW + labelW + BOX_PAD_X + confirmW + closeW;
1856
+ const isDraftLevel = level.data?._isDraft === true;
1857
+ const qtyPillText = showQuantityField && isDraftLevel && level.type === "pending" && level.lots !== void 0 ? String(level.lots) : null;
1858
+ const pillW = qtyPillText ? PILL_PAD_X + ctx.measureText(qtyPillText).width + PILL_PAD_X + PILL_GAP : 0;
1859
+ const bW = BOX_PAD_X + pillW + pnlW + labelW + BOX_PAD_X + confirmW + closeW;
1671
1860
  measured.push({ level, price, y, boxW: bW, boxH });
1672
1861
  }
1862
+ const excludeFromClustering = /* @__PURE__ */ new Set();
1863
+ if (draftOrderLabel) excludeFromClustering.add(draftOrderLabel);
1864
+ const clusters = clusterThresholdPx > 0 ? detectClusters(measured, clusterThresholdPx, excludeFromClustering) : [];
1865
+ const collapsedClusters = clusters.filter((c) => !expandedClusters.has(c.id));
1866
+ const expandedClusterList = clusters.filter((c) => expandedClusters.has(c.id));
1867
+ const collapsedLabels = /* @__PURE__ */ new Set();
1868
+ for (const c of collapsedClusters) {
1869
+ for (const m of c.members) collapsedLabels.add(m.level.label);
1870
+ }
1871
+ const expandedFanLayouts = /* @__PURE__ */ new Map();
1872
+ for (const ec of expandedClusterList) {
1873
+ expandedFanLayouts.set(ec.id, computeFanOutLayout(ec, boxH, 4));
1874
+ }
1875
+ const labelToExpandedCluster = /* @__PURE__ */ new Map();
1876
+ for (const ec of expandedClusterList) {
1877
+ for (const m of ec.members) labelToExpandedCluster.set(m.level.label, ec);
1878
+ }
1673
1879
  measured.sort((a, b) => a.y - b.y);
1674
1880
  const placed = [];
1675
1881
  const layouts = /* @__PURE__ */ new Map();
1676
1882
  for (const m of measured) {
1677
- const centeredY = m.y - m.boxH / 2;
1678
- const overlaps = placed.some((p) => rectsOverlapY(centeredY, m.boxH, p.boxY, p.boxH));
1679
- const boxY = overlaps ? centeredY - m.boxH - 2 : centeredY;
1883
+ if (collapsedLabels.has(m.level.label)) continue;
1884
+ const ec = labelToExpandedCluster.get(m.level.label);
1885
+ if (ec) {
1886
+ const fanMap = expandedFanLayouts.get(ec.id);
1887
+ const fanY = fanMap?.get(m.level.label);
1888
+ if (fanY !== void 0) {
1889
+ placed.push({ label: m.level.label, boxY: fanY, boxH: m.boxH });
1890
+ layouts.set(m.level.label, { price: m.price, y: m.y, boxY: fanY, boxW: m.boxW, boxH: m.boxH });
1891
+ continue;
1892
+ }
1893
+ }
1894
+ let boxY = m.y - m.boxH / 2;
1895
+ let attempts = 0;
1896
+ while (attempts < placed.length + 1) {
1897
+ const overlaps = placed.some((p) => rectsOverlapY(boxY, m.boxH, p.boxY, p.boxH));
1898
+ if (!overlaps) break;
1899
+ boxY -= m.boxH + 2;
1900
+ attempts++;
1901
+ }
1680
1902
  placed.push({ label: m.level.label, boxY, boxH: m.boxH });
1681
1903
  layouts.set(m.level.label, { price: m.price, y: m.y, boxY, boxW: m.boxW, boxH: m.boxH });
1682
1904
  }
@@ -1692,9 +1914,10 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1692
1914
  selectedLabel
1693
1915
  );
1694
1916
  const activeLabel = hoveredLabel ?? selectedLabel;
1917
+ const visibleMeasured = measured.filter((m) => !collapsedLabels.has(m.level.label));
1695
1918
  const drawOrder = [
1696
- ...measured.filter((m) => m.level.label !== activeLabel),
1697
- ...measured.filter((m) => m.level.label === activeLabel)
1919
+ ...visibleMeasured.filter((m) => m.level.label !== activeLabel),
1920
+ ...visibleMeasured.filter((m) => m.level.label === activeLabel)
1698
1921
  ];
1699
1922
  const hitAreas = [];
1700
1923
  const editAreas = [];
@@ -1702,6 +1925,11 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1702
1925
  const dragAreas = [];
1703
1926
  const confirmAreas = [];
1704
1927
  const addBracketAreas = [];
1928
+ const clusterBadgeAreas = [];
1929
+ const draftQtyPillAreas = [];
1930
+ for (const cluster of collapsedClusters) {
1931
+ drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas);
1932
+ }
1705
1933
  for (const { level } of drawOrder) {
1706
1934
  const layout = layouts.get(level.label);
1707
1935
  if (!layout) continue;
@@ -1730,11 +1958,148 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1730
1958
  confirmAreas,
1731
1959
  addBracketAreas,
1732
1960
  hideConfirmCancel,
1733
- level.label === draftOrderLabel ? draftBracketPnl : void 0
1961
+ level.label === draftOrderLabel ? draftBracketPnl : void 0,
1962
+ showQuantityField,
1963
+ draftQtyPillAreas
1734
1964
  );
1965
+ const ec = labelToExpandedCluster.get(level.label);
1966
+ if (ec && layout.boxY !== layout.y - boxH / 2) {
1967
+ drawFanConnector(ctx, layout.boxY + boxH / 2, layout.y, chartW, theme);
1968
+ }
1969
+ }
1970
+ if (selectedLabel !== null && !layouts.has(selectedLabel)) {
1971
+ const offLevel = levels.find((l) => l.label === selectedLabel);
1972
+ if (offLevel) {
1973
+ const bh = offLevel;
1974
+ const slDragKey = `${offLevel.label}:stopLoss`;
1975
+ const tpDragKey = `${offLevel.label}:takeProfit`;
1976
+ const fallbackX = chartW * BRACKET_BASE_RATIO;
1977
+ if (bh.stopLossPrice !== void 0) {
1978
+ drawBracketLine(
1979
+ ctx,
1980
+ bh,
1981
+ "stopLoss",
1982
+ bh.stopLossPrice,
1983
+ scale,
1984
+ priceRange,
1985
+ priceH,
1986
+ bracketLineXMap.get(slDragKey) ?? fallbackX,
1987
+ chartW,
1988
+ theme,
1989
+ precision,
1990
+ draggingLabel,
1991
+ dragNewPrice,
1992
+ hitAreas,
1993
+ dragAreas,
1994
+ hideConfirmCancel,
1995
+ bh._slOrderLabel
1996
+ );
1997
+ }
1998
+ if (bh.takeProfitPrice !== void 0) {
1999
+ drawBracketLine(
2000
+ ctx,
2001
+ bh,
2002
+ "takeProfit",
2003
+ bh.takeProfitPrice,
2004
+ scale,
2005
+ priceRange,
2006
+ priceH,
2007
+ bracketLineXMap.get(tpDragKey) ?? fallbackX,
2008
+ chartW,
2009
+ theme,
2010
+ precision,
2011
+ draggingLabel,
2012
+ dragNewPrice,
2013
+ hitAreas,
2014
+ dragAreas,
2015
+ hideConfirmCancel,
2016
+ bh._tpOrderLabel
2017
+ );
2018
+ }
2019
+ }
1735
2020
  }
1736
2021
  ctx.restore();
1737
- return { hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas };
2022
+ return {
2023
+ hitAreas,
2024
+ editAreas,
2025
+ hoverAreas,
2026
+ dragAreas,
2027
+ confirmAreas,
2028
+ addBracketAreas,
2029
+ clusterBadgeAreas,
2030
+ clusters,
2031
+ draftQtyPillArea: draftQtyPillAreas[0]
2032
+ };
2033
+ }
2034
+ var BADGE_FONT = "bold 12px 'Inter', system-ui, sans-serif";
2035
+ var BADGE_PAD_X = 12;
2036
+ var BADGE_H = 24;
2037
+ var BADGE_RADIUS = 12;
2038
+ function drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas) {
2039
+ const mono = monoColor(theme);
2040
+ const isDark = theme.background !== "#ffffff";
2041
+ ctx.save();
2042
+ ctx.strokeStyle = mono;
2043
+ ctx.globalAlpha = 0.12;
2044
+ ctx.lineWidth = 1;
2045
+ ctx.setLineDash([4, 4]);
2046
+ for (const m of cluster.members) {
2047
+ ctx.beginPath();
2048
+ ctx.moveTo(0, m.y);
2049
+ ctx.lineTo(chartW, m.y);
2050
+ ctx.stroke();
2051
+ }
2052
+ ctx.setLineDash([]);
2053
+ ctx.restore();
2054
+ ctx.save();
2055
+ ctx.font = BADGE_FONT;
2056
+ const layout = buildClusterBadgeLayout(cluster, chartW, ctx);
2057
+ const { badgeX, badgeY, badgeW, text } = layout;
2058
+ const expandIcon = " \u25B8";
2059
+ const bgColor = isDark ? "rgba(40,40,40,0.92)" : "rgba(240,240,240,0.92)";
2060
+ ctx.fillStyle = bgColor;
2061
+ ctx.beginPath();
2062
+ ctx.roundRect(badgeX, badgeY, badgeW, BADGE_H, BADGE_RADIUS);
2063
+ ctx.fill();
2064
+ ctx.strokeStyle = mono;
2065
+ ctx.globalAlpha = 0.3;
2066
+ ctx.lineWidth = 1;
2067
+ ctx.stroke();
2068
+ ctx.globalAlpha = 1;
2069
+ ctx.fillStyle = mono;
2070
+ ctx.globalAlpha = 0.85;
2071
+ ctx.textBaseline = "middle";
2072
+ ctx.textAlign = "left";
2073
+ ctx.fillText(text + expandIcon, badgeX + BADGE_PAD_X, badgeY + BADGE_H / 2);
2074
+ ctx.globalAlpha = 1;
2075
+ ctx.restore();
2076
+ clusterBadgeAreas.push({
2077
+ clusterId: cluster.id,
2078
+ x: badgeX,
2079
+ y: badgeY,
2080
+ w: badgeW,
2081
+ h: BADGE_H
2082
+ });
2083
+ }
2084
+ function drawFanConnector(ctx, fanCenterY, realY, chartW, theme) {
2085
+ if (Math.abs(fanCenterY - realY) < 2) return;
2086
+ const mono = monoColor(theme);
2087
+ ctx.save();
2088
+ ctx.strokeStyle = mono;
2089
+ ctx.globalAlpha = 0.25;
2090
+ ctx.lineWidth = 1;
2091
+ ctx.setLineDash([2, 3]);
2092
+ const connectorX = chartW - 8;
2093
+ ctx.beginPath();
2094
+ ctx.moveTo(connectorX, fanCenterY);
2095
+ ctx.lineTo(connectorX, realY);
2096
+ ctx.stroke();
2097
+ ctx.setLineDash([]);
2098
+ ctx.beginPath();
2099
+ ctx.moveTo(connectorX - 4, realY);
2100
+ ctx.lineTo(connectorX + 4, realY);
2101
+ ctx.stroke();
2102
+ ctx.restore();
1738
2103
  }
1739
2104
  function drawDotHoverBox(ctx, level, y, dotX, dotR, theme, precision) {
1740
2105
  const pl = level;
@@ -1794,7 +2159,7 @@ function drawDotHoverBox(ctx, level, y, dotX, dotR, theme, precision) {
1794
2159
  });
1795
2160
  ctx.restore();
1796
2161
  }
1797
- function drawLevel(ctx, level, layout, chartW, colors, theme, precision, hovered, selected, scale, priceRange, priceH, draggingLabel, dragNewPrice, bracketLineXMap, hasPendingChanges, positionStyle, hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas, hideConfirmCancel = false, draftBracketPnl) {
2162
+ function drawLevel(ctx, level, layout, chartW, colors, theme, precision, hovered, selected, scale, priceRange, priceH, draggingLabel, dragNewPrice, bracketLineXMap, hasPendingChanges, positionStyle, hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas, hideConfirmCancel = false, draftBracketPnl, showQuantityField = false, draftQtyPillAreas) {
1798
2163
  const { y, boxY, boxW, boxH } = layout;
1799
2164
  const color = levelColor(level, colors, theme);
1800
2165
  const active = hovered || selected;
@@ -1982,6 +2347,29 @@ function drawLevel(ctx, level, layout, chartW, colors, theme, precision, hovered
1982
2347
  ctx.fill();
1983
2348
  ctx.stroke();
1984
2349
  let textX = boxX + BOX_PAD_X;
2350
+ const isDraftLevel = level.data?._isDraft === true;
2351
+ if (showQuantityField && isDraftLevel && level.type === "pending" && level.lots !== void 0) {
2352
+ const qtyText = String(level.lots);
2353
+ ctx.font = FONT_MAIN;
2354
+ const pillContentW = ctx.measureText(qtyText).width;
2355
+ const pillW = PILL_PAD_X + pillContentW + PILL_PAD_X;
2356
+ const pillX = textX;
2357
+ ctx.save();
2358
+ ctx.strokeStyle = monoColor(theme);
2359
+ ctx.globalAlpha = 0.35;
2360
+ ctx.lineWidth = 1;
2361
+ ctx.beginPath();
2362
+ ctx.moveTo(pillX + pillW, boxY + 3);
2363
+ ctx.lineTo(pillX + pillW, boxY + boxH - 3);
2364
+ ctx.stroke();
2365
+ ctx.restore();
2366
+ ctx.fillStyle = monoColor(theme);
2367
+ ctx.textAlign = "left";
2368
+ ctx.textBaseline = "middle";
2369
+ ctx.fillText(qtyText, pillX + PILL_PAD_X, midY);
2370
+ draftQtyPillAreas?.push({ label: level.label, pillX, pillY: boxY, pillW, pillH: boxH });
2371
+ textX += pillW + PILL_GAP;
2372
+ }
1985
2373
  if (pnlText) {
1986
2374
  const pnlColor = pnl !== void 0 ? pnl >= 0 ? theme.candle.up : theme.candle.down : monoColor(theme);
1987
2375
  ctx.font = FONT_PNL;
@@ -2365,6 +2753,152 @@ function hitTestTradeLevelAddBracket(x, y, areas) {
2365
2753
  return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
2366
2754
  }
2367
2755
 
2756
+ // src/overlays/OffViewportLevelIndicator.ts
2757
+ function collectOffViewportMarkers(levels, priceRange, draggingLabel) {
2758
+ const above = [];
2759
+ const below = [];
2760
+ const { min, max } = priceRange;
2761
+ for (const level of levels) {
2762
+ if (level.label === draggingLabel) continue;
2763
+ const isDraftMarket = level.type === "pending" && level.orderType === "market" && level.data?._isDraft === true;
2764
+ if (isDraftMarket) continue;
2765
+ if (isFinite(level.price)) {
2766
+ if (level.price > max) above.push({ price: level.price, label: level.label, kind: "entry" });
2767
+ else if (level.price < min) below.push({ price: level.price, label: level.label, kind: "entry" });
2768
+ }
2769
+ const slPrice = level.stopLossPrice;
2770
+ const tpPrice = level.takeProfitPrice;
2771
+ if (slPrice !== void 0 && isFinite(slPrice)) {
2772
+ if (slPrice > max) above.push({ price: slPrice, label: level.label, kind: "sl" });
2773
+ else if (slPrice < min) below.push({ price: slPrice, label: level.label, kind: "sl" });
2774
+ }
2775
+ if (tpPrice !== void 0 && isFinite(tpPrice)) {
2776
+ if (tpPrice > max) above.push({ price: tpPrice, label: level.label, kind: "tp" });
2777
+ else if (tpPrice < min) below.push({ price: tpPrice, label: level.label, kind: "tp" });
2778
+ }
2779
+ }
2780
+ return { above, below };
2781
+ }
2782
+ function entryCount(markers) {
2783
+ let n = 0;
2784
+ for (const m of markers) if (m.kind === "entry") n++;
2785
+ return n;
2786
+ }
2787
+ function nearestEdgeMarker(markers, edge) {
2788
+ let best = markers[0];
2789
+ let bestDist = Math.abs(best.price - edge);
2790
+ for (let i = 1; i < markers.length; i++) {
2791
+ const d = Math.abs(markers[i].price - edge);
2792
+ if (d < bestDist) {
2793
+ best = markers[i];
2794
+ bestDist = d;
2795
+ }
2796
+ }
2797
+ return best;
2798
+ }
2799
+ var PILL_FONT = "11px 'Inter', system-ui, sans-serif";
2800
+ var PILL_PAD_X2 = 8;
2801
+ var PILL_RIGHT_MARGIN = 8;
2802
+ var PILL_V_MARGIN = 10;
2803
+ var PILL_H = 20;
2804
+ var PILL_RADIUS = 10;
2805
+ var ARROW_UP = "\u25B2";
2806
+ var ARROW_DOWN = "\u25BC";
2807
+ function renderOffViewportIndicators(ctx, levels, priceRange, theme, chartWidth, chartHeight, draggingLabel, selectedLabel = null) {
2808
+ const hitAreas = [];
2809
+ const PADDING_PX = 20;
2810
+ const usable = chartHeight - 2 * PADDING_PX;
2811
+ const span = priceRange.max - priceRange.min;
2812
+ const padInPrice = usable > 0 ? span / usable * PADDING_PX : 0;
2813
+ const visualRange = {
2814
+ min: priceRange.min - padInPrice,
2815
+ max: priceRange.max + padInPrice
2816
+ };
2817
+ const { above, below } = collectOffViewportMarkers(levels, visualRange, draggingLabel);
2818
+ if (above.length === 0 && below.length === 0) return { hitAreas };
2819
+ ctx.save();
2820
+ ctx.font = PILL_FONT;
2821
+ ctx.textBaseline = "middle";
2822
+ ctx.textAlign = "center";
2823
+ const PILL_GAP2 = 4;
2824
+ const renderPill = (direction, text, y, target) => {
2825
+ const textW = ctx.measureText(text).width;
2826
+ const w = Math.ceil(textW) + PILL_PAD_X2 * 2;
2827
+ const x = chartWidth - PILL_RIGHT_MARGIN - w;
2828
+ ctx.fillStyle = theme.tooltip.background;
2829
+ ctx.strokeStyle = theme.tooltip.border;
2830
+ ctx.lineWidth = 1;
2831
+ roundedRect(ctx, x, y, w, PILL_H, PILL_RADIUS);
2832
+ ctx.fill();
2833
+ ctx.stroke();
2834
+ ctx.fillStyle = theme.tooltip.text;
2835
+ ctx.fillText(text, x + w / 2, y + PILL_H / 2 + 0.5);
2836
+ hitAreas.push({
2837
+ direction,
2838
+ x,
2839
+ y,
2840
+ w,
2841
+ h: PILL_H,
2842
+ targetPrice: target.price,
2843
+ targetLabel: target.label,
2844
+ targetKind: target.kind
2845
+ });
2846
+ return w;
2847
+ };
2848
+ const drawColumn = (direction, markers, baseY) => {
2849
+ if (markers.length === 0) return;
2850
+ const arrow = direction === "up" ? ARROW_UP : ARROW_DOWN;
2851
+ const edge = direction === "up" ? priceRange.max : priceRange.min;
2852
+ const stackDir = direction === "up" ? 1 : -1;
2853
+ let nextY = baseY;
2854
+ const entries = entryCount(markers);
2855
+ if (entries > 0) {
2856
+ const target = nearestEdgeMarker(
2857
+ markers.filter((m) => m.kind === "entry"),
2858
+ edge
2859
+ );
2860
+ renderPill(direction, `${arrow} ${entries}`, nextY, target);
2861
+ nextY += stackDir * (PILL_H + PILL_GAP2);
2862
+ }
2863
+ if (selectedLabel !== null) {
2864
+ const bracketMarkers = markers.filter(
2865
+ (m) => m.kind !== "entry" && m.label === selectedLabel
2866
+ );
2867
+ for (const bm of bracketMarkers) {
2868
+ const kindLabel = bm.kind.toUpperCase();
2869
+ renderPill(direction, `${arrow} ${kindLabel}`, nextY, bm);
2870
+ nextY += stackDir * (PILL_H + PILL_GAP2);
2871
+ }
2872
+ }
2873
+ };
2874
+ drawColumn("up", above, PILL_V_MARGIN);
2875
+ drawColumn("down", below, chartHeight - PILL_V_MARGIN - PILL_H);
2876
+ ctx.restore();
2877
+ return { hitAreas };
2878
+ }
2879
+ function hitTestOffViewportIndicator(x, y, hitAreas) {
2880
+ return hitAreas.find((h) => x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h) ?? null;
2881
+ }
2882
+ function overlapsOffViewportPill(centerY, halfH, hitAreas) {
2883
+ const top = centerY - halfH;
2884
+ const bottom = centerY + halfH;
2885
+ return hitAreas.some((h) => top < h.y + h.h && bottom > h.y);
2886
+ }
2887
+ function roundedRect(ctx, x, y, w, h, r) {
2888
+ const rr = Math.min(r, w / 2, h / 2);
2889
+ ctx.beginPath();
2890
+ ctx.moveTo(x + rr, y);
2891
+ ctx.lineTo(x + w - rr, y);
2892
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
2893
+ ctx.lineTo(x + w, y + h - rr);
2894
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
2895
+ ctx.lineTo(x + rr, y + h);
2896
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
2897
+ ctx.lineTo(x, y + rr);
2898
+ ctx.quadraticCurveTo(x, y, x + rr, y);
2899
+ ctx.closePath();
2900
+ }
2901
+
2368
2902
  // src/axis/TimeAxis.ts
2369
2903
  var COUNTDOWN_LABEL_HEIGHT = 18;
2370
2904
  var COUNTDOWN_LABEL_PADDING_X = 6;
@@ -2391,45 +2925,45 @@ function formatRemaining(totalSec) {
2391
2925
  const d = Math.floor(totalSec % 604800 / 86400);
2392
2926
  return `${w}w ${d}d`;
2393
2927
  }
2394
- function formatTimestamp(ts, visibleBars, timeframe, spansMultipleDays) {
2928
+ function formatTimestamp(ts, visibleBars, timeframe, spansMultipleDays, timezone = "UTC") {
2395
2929
  const d = new Date(ts);
2396
2930
  if (timeframe && INTRADAY_TIMEFRAMES.includes(timeframe)) {
2397
2931
  const time = d.toLocaleTimeString("en-US", {
2398
2932
  hour: "2-digit",
2399
2933
  minute: "2-digit",
2400
2934
  hour12: false,
2401
- timeZone: "UTC"
2935
+ timeZone: timezone
2402
2936
  });
2403
2937
  if (spansMultipleDays) {
2404
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }) + " " + time;
2938
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: timezone }) + " " + time;
2405
2939
  }
2406
2940
  return time;
2407
2941
  }
2408
2942
  if (timeframe === "1Y") {
2409
- return d.toLocaleDateString("en-US", { year: "numeric", timeZone: "UTC" });
2943
+ return d.toLocaleDateString("en-US", { year: "numeric", timeZone: timezone });
2410
2944
  }
2411
2945
  if (timeframe === "1M") {
2412
- return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
2946
+ return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: timezone });
2413
2947
  }
2414
2948
  if (visibleBars <= 365) {
2415
2949
  return d.toLocaleDateString("en-US", {
2416
2950
  month: "short",
2417
2951
  day: "numeric",
2418
- timeZone: "UTC"
2952
+ timeZone: timezone
2419
2953
  });
2420
2954
  }
2421
- return d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: "UTC" });
2955
+ return d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: timezone });
2422
2956
  }
2423
- function renderTimeAxis(ctx, bars, viewport, scale, theme, chartWidth, axisY, maxLabels = 8, cfg = DEFAULT_TIME_AXIS_CONFIG, timeframe, countdownBarIndex) {
2957
+ function renderTimeAxis(ctx, bars, viewport, scale, theme, chartWidth, axisY, maxLabels = 8, cfg = DEFAULT_TIME_AXIS_CONFIG, timeframe, countdownBarIndex, timezone = "UTC") {
2424
2958
  const visibleBars = viewport.endIndex - viewport.startIndex;
2425
2959
  const firstBar = bars[0];
2426
2960
  const lastBar = bars[bars.length - 1];
2427
- const spansMultipleDays = !!firstBar && !!lastBar && new Date(firstBar.time).toLocaleDateString("en-US", { timeZone: "UTC" }) !== new Date(lastBar.time).toLocaleDateString("en-US", { timeZone: "UTC" });
2961
+ const spansMultipleDays = !!firstBar && !!lastBar && new Date(firstBar.time).toLocaleDateString("en-US", { timeZone: timezone }) !== new Date(lastBar.time).toLocaleDateString("en-US", { timeZone: timezone });
2428
2962
  ctx.fillStyle = theme.axisText;
2429
2963
  ctx.font = `${cfg.fontSize} ${cfg.fontFamily}`;
2430
2964
  ctx.textAlign = "center";
2431
2965
  const MIN_GAP = 12;
2432
- const sampleLabel = formatTimestamp(bars[Math.floor(bars.length / 2)]?.time ?? bars[0].time, visibleBars, timeframe, spansMultipleDays);
2966
+ const sampleLabel = formatTimestamp(bars[Math.floor(bars.length / 2)]?.time ?? bars[0].time, visibleBars, timeframe, spansMultipleDays, timezone);
2433
2967
  const labelW = ctx.measureText(sampleLabel).width;
2434
2968
  const minStep = Math.max(1, Math.floor(bars.length / maxLabels));
2435
2969
  let step = minStep;
@@ -2444,7 +2978,7 @@ function renderTimeAxis(ctx, bars, viewport, scale, theme, chartWidth, axisY, ma
2444
2978
  const barIndex = viewport.startIndex + i;
2445
2979
  if (countdownBarIndex !== void 0 && barIndex === countdownBarIndex) continue;
2446
2980
  const x = scale.xToPixel(barIndex, viewport, chartWidth);
2447
- const label = formatTimestamp(bars[i].time, visibleBars, timeframe, spansMultipleDays);
2981
+ const label = formatTimestamp(bars[i].time, visibleBars, timeframe, spansMultipleDays, timezone);
2448
2982
  const halfW = ctx.measureText(label).width / 2;
2449
2983
  const clampedX = Math.max(halfW, Math.min(chartWidth - halfW, x));
2450
2984
  ctx.fillText(label, clampedX, axisY + 14);
@@ -2479,10 +3013,20 @@ function renderCandleCountdown(ctx, lastBarTime, intervalMs, lastBarIndex, viewp
2479
3013
  }
2480
3014
 
2481
3015
  // src/axis/PriceAxis.ts
2482
- function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines = 6, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
3016
+ function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
2483
3017
  ctx.fillStyle = theme.axisText;
2484
3018
  ctx.font = `${cfg.fontSize} ${cfg.fontFamily}`;
2485
3019
  ctx.textAlign = "left";
3020
+ if (gridLines === void 0) {
3021
+ const ticks = generatePriceTicks(priceRange, chartHeight);
3022
+ const step2 = ticks.length >= 2 ? ticks[1] - ticks[0] : priceRange.max - priceRange.min;
3023
+ const precision = precisionForStep(step2, pricePrecision);
3024
+ for (const price of ticks) {
3025
+ const y = scale.yToPixel(price, priceRange, chartHeight);
3026
+ ctx.fillText(price.toFixed(precision), chartWidth + 4, y + 4);
3027
+ }
3028
+ return;
3029
+ }
2486
3030
  const step = (priceRange.max - priceRange.min) / gridLines;
2487
3031
  for (let i = 0; i <= gridLines; i++) {
2488
3032
  const price = priceRange.min + step * i;
@@ -3097,6 +3641,8 @@ var ICON_TOOL = {
3097
3641
  14
3098
3642
  )
3099
3643
  };
3644
+ var ICON_TFC = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="1.5,12.5 5,9 7,11 14,4" stroke="#26a69a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><polyline points="10,4 14,4 14,8" stroke="#26a69a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><polyline points="1.5,3.5 5,7 7,5 14,12" stroke="#ef5350" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><polyline points="10,12 14,12 14,8" stroke="#ef5350" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
3645
+ var ICON_TFC_OFF = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="1.5,12.5 5,9 7,11 14,4" stroke="#26a69a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><polyline points="10,4 14,4 14,8" stroke="#26a69a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><polyline points="1.5,3.5 5,7 7,5 14,12" stroke="#ef5350" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><polyline points="10,12 14,12 14,8" stroke="#ef5350" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
3100
3646
  var iconScrollToEnd = (c, size = 16) => `<svg viewBox="0 0 16 16" width="${size}" height="${size}" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 4L6 8L1 12Z" fill="${c}"/><path d="M6 4L11 8L6 12Z" fill="${c}"/><line x1="13.5" y1="3.5" x2="13.5" y2="12.5" stroke="${c}" stroke-width="2" stroke-linecap="round"/></svg>`;
3101
3647
  var iconResetView = (c, size = 16) => `<svg viewBox="0 0 16 16" width="${size}" height="${size}" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 2A6 6 0 1 1 2 8" stroke="${c}" stroke-width="1.6" stroke-linecap="round"/><path d="M4 10L2 8L1 10" stroke="${c}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
3102
3648
  var ICON_TAB = {
@@ -3132,7 +3678,7 @@ function injectFlyoutScrollbarStyles() {
3132
3678
  document.head.appendChild(style);
3133
3679
  }
3134
3680
  var TopBar = class {
3135
- constructor(onSeriesChange, onTimeframeChange, onAddIndicator, onDurationChange, onSettingsClick, onFullscreenToggle, onDrawingToolsToggle, showDrawingToolsBtn, showSettingsBtn, initialSeries, initialTf, initialDuration, durationMap, theme, cfg = DEFAULT_TOP_BAR_CONFIG, labels = DEFAULT_LABELS.topBar) {
3681
+ constructor(onSeriesChange, onTimeframeChange, onAddIndicator, onDurationChange, onSettingsClick, onFullscreenToggle, onDrawingToolsToggle, showDrawingToolsBtn, showSettingsBtn, showTfcBtn, initialTfcActive, onTfcToggle, initialSeries, initialTf, initialDuration, durationMap, theme, cfg = DEFAULT_TOP_BAR_CONFIG, labels = DEFAULT_LABELS.topBar) {
3136
3682
  this.onSeriesChange = onSeriesChange;
3137
3683
  this.onTimeframeChange = onTimeframeChange;
3138
3684
  this.onAddIndicator = onAddIndicator;
@@ -3140,11 +3686,14 @@ var TopBar = class {
3140
3686
  this.onSettingsClick = onSettingsClick;
3141
3687
  this.onFullscreenToggle = onFullscreenToggle;
3142
3688
  this.onDrawingToolsToggle = onDrawingToolsToggle;
3689
+ this.onTfcToggle = onTfcToggle;
3143
3690
  this.settingsBtn = null;
3144
3691
  this.isFullscreen = false;
3145
3692
  this.durationBtns = /* @__PURE__ */ new Map();
3146
3693
  this.isMobileLayout = false;
3147
3694
  this.drawingBtn = null;
3695
+ this.tfcBtn = null;
3696
+ this.tfcActive = true;
3148
3697
  this.openDropdown = null;
3149
3698
  this.indicatorOptions = [];
3150
3699
  this.activeIndicators = /* @__PURE__ */ new Set();
@@ -3152,6 +3701,7 @@ var TopBar = class {
3152
3701
  this.theme = theme;
3153
3702
  this.cfg = cfg;
3154
3703
  this.labels = labels;
3704
+ this.tfcActive = initialTfcActive;
3155
3705
  this.currentSeries = initialSeries;
3156
3706
  this.currentTf = initialTf;
3157
3707
  this.activeDuration = initialDuration;
@@ -3228,6 +3778,21 @@ var TopBar = class {
3228
3778
  this.addBtnHover(this.settingsBtn);
3229
3779
  this.rightGroup.appendChild(this.settingsBtn);
3230
3780
  }
3781
+ if (showTfcBtn) {
3782
+ this.tfcBtn = document.createElement("button");
3783
+ this.tfcBtn.title = this.labels.tfcToggleTitle;
3784
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3785
+ this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3786
+ this.tfcBtn.addEventListener("click", (e) => {
3787
+ e.stopPropagation();
3788
+ this.tfcActive = !this.tfcActive;
3789
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3790
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, this.tfcActive);
3791
+ this.onTfcToggle?.(this.tfcActive);
3792
+ });
3793
+ this.addBtnHover(this.tfcBtn);
3794
+ this.rightGroup.appendChild(this.tfcBtn);
3795
+ }
3231
3796
  this.el.append(this.leftGroup, this.rightGroup);
3232
3797
  this.docClickHandler = (e) => {
3233
3798
  if (!this.el.contains(e.target) && !this.flyout.contains(e.target)) {
@@ -3257,6 +3822,15 @@ var TopBar = class {
3257
3822
  if (!this.drawingBtn) return;
3258
3823
  this.applyIconBtnStyles(this.drawingBtn, this.theme, active);
3259
3824
  }
3825
+ setTfcActive(active) {
3826
+ if (!this.tfcBtn) return;
3827
+ this.tfcActive = active;
3828
+ this.tfcBtn.innerHTML = active ? ICON_TFC : ICON_TFC_OFF;
3829
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, active);
3830
+ if (this.isMobileLayout) {
3831
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + 2);
3832
+ }
3833
+ }
3260
3834
  setTheme(theme) {
3261
3835
  this.theme = theme;
3262
3836
  this.closeDropdown();
@@ -3265,6 +3839,7 @@ var TopBar = class {
3265
3839
  if (this.drawingBtn) this.applyIconBtnStyles(this.drawingBtn, theme, false);
3266
3840
  this.applyIconBtnStyles(this.fullscreenBtn, theme, this.isFullscreen);
3267
3841
  if (this.settingsBtn) this.applyIconBtnStyles(this.settingsBtn, theme, false);
3842
+ if (this.tfcBtn) this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3268
3843
  this.applySeriesBtnStyles(this.seriesBtn, theme, false);
3269
3844
  this.applyDropBtnStyles(this.tfBtn, theme, false);
3270
3845
  this.applyDropBtnStyles(this.indBtn, theme, false);
@@ -3305,7 +3880,7 @@ var TopBar = class {
3305
3880
  this.durationSep.style.display = btnDisplay;
3306
3881
  this.durationDropBtn.style.display = isMobile ? "" : "none";
3307
3882
  const containerSize = isMobile ? this.cfg.mobileIconBtnSize : this.cfg.iconBtnSize;
3308
- for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn]) {
3883
+ for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn, this.tfcBtn]) {
3309
3884
  if (!btn) continue;
3310
3885
  btn.style.width = containerSize;
3311
3886
  btn.style.height = containerSize;
@@ -3315,6 +3890,9 @@ var TopBar = class {
3315
3890
  if (this.settingsBtn) {
3316
3891
  this.resizeSvg(this.settingsBtn, this.getSvgSize(this.settingsBtn) + delta);
3317
3892
  }
3893
+ if (this.tfcBtn) {
3894
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + delta);
3895
+ }
3318
3896
  if (this.drawingBtn) {
3319
3897
  const drawIconSize = isMobile ? parseInt(this.cfg.mobileDrawBtnIconSize, 10) : parseInt(this.cfg.drawBtnIconFontSize, 10);
3320
3898
  this.resizeSvg(this.drawingBtn, drawIconSize);
@@ -8143,7 +8721,7 @@ function pointToSegmentDist19(px, py, x1, y1, x2, y2) {
8143
8721
  const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
8144
8722
  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
8145
8723
  }
8146
- function roundedRect(ctx, x, y, w, h, r) {
8724
+ function roundedRect2(ctx, x, y, w, h, r) {
8147
8725
  ctx.beginPath();
8148
8726
  ctx.moveTo(x + r, y);
8149
8727
  ctx.lineTo(x + w - r, y);
@@ -8194,7 +8772,7 @@ var Callout = class {
8194
8772
  ctx.beginPath();
8195
8773
  ctx.arc(tipX, tipY, 3, 0, Math.PI * 2);
8196
8774
  ctx.fill();
8197
- roundedRect(ctx, bx, by, boxW, BOX_H, RADIUS);
8775
+ roundedRect2(ctx, bx, by, boxW, BOX_H, RADIUS);
8198
8776
  ctx.fillStyle = "#37474f";
8199
8777
  ctx.fill();
8200
8778
  ctx.strokeStyle = "#546e7a";
@@ -8246,7 +8824,7 @@ var Callout = class {
8246
8824
  // src/drawings/PriceLabel.ts
8247
8825
  var HIT_TOLERANCE42 = 6;
8248
8826
  var BADGE_W = 60;
8249
- var BADGE_H = 18;
8827
+ var BADGE_H2 = 18;
8250
8828
  var ARROW_W = 7;
8251
8829
  var PriceLabel = class {
8252
8830
  constructor(point) {
@@ -8263,21 +8841,21 @@ var PriceLabel = class {
8263
8841
  ctx.fillStyle = "#26a69a";
8264
8842
  ctx.beginPath();
8265
8843
  ctx.moveTo(x, y);
8266
- ctx.lineTo(x + ARROW_W, y - BADGE_H / 2);
8267
- ctx.lineTo(x + ARROW_W, y + BADGE_H / 2);
8844
+ ctx.lineTo(x + ARROW_W, y - BADGE_H2 / 2);
8845
+ ctx.lineTo(x + ARROW_W, y + BADGE_H2 / 2);
8268
8846
  ctx.closePath();
8269
8847
  ctx.fill();
8270
8848
  const bx = x + ARROW_W;
8271
- const by = y - BADGE_H / 2;
8849
+ const by = y - BADGE_H2 / 2;
8272
8850
  ctx.fillStyle = "#26a69a";
8273
8851
  ctx.beginPath();
8274
- ctx.roundRect(bx, by, BADGE_W, BADGE_H, BADGE_H / 2);
8852
+ ctx.roundRect(bx, by, BADGE_W, BADGE_H2, BADGE_H2 / 2);
8275
8853
  ctx.fill();
8276
8854
  if (isSelected) {
8277
8855
  ctx.strokeStyle = "#80cbc4";
8278
8856
  ctx.lineWidth = 1.5;
8279
8857
  ctx.beginPath();
8280
- ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H + 4, BADGE_H / 2 + 2);
8858
+ ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H2 + 4, BADGE_H2 / 2 + 2);
8281
8859
  ctx.stroke();
8282
8860
  }
8283
8861
  ctx.font = "11px 'Inter', system-ui, sans-serif";
@@ -8291,8 +8869,8 @@ var PriceLabel = class {
8291
8869
  const ax = scale.xToPixel(this.points[0].barIndex, viewport, chartW);
8292
8870
  const ay = scale.yToPixel(this.points[0].price, priceRange, chartH);
8293
8871
  const bx = ax + ARROW_W;
8294
- const by = ay - BADGE_H / 2;
8295
- return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H + HIT_TOLERANCE42;
8872
+ const by = ay - BADGE_H2 / 2;
8873
+ return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H2 + HIT_TOLERANCE42;
8296
8874
  }
8297
8875
  getHandle() {
8298
8876
  return null;
@@ -8412,7 +8990,7 @@ var Flag = class {
8412
8990
 
8413
8991
  // src/drawings/PriceNote.ts
8414
8992
  var HIT_TOLERANCE44 = 6;
8415
- var BADGE_H2 = 16;
8993
+ var BADGE_H3 = 16;
8416
8994
  var BADGE_PADDING_X = 6;
8417
8995
  var TICK_HALF = 10;
8418
8996
  var PriceNote = class {
@@ -8438,10 +9016,10 @@ var PriceNote = class {
8438
9016
  const tw = ctx.measureText(label).width;
8439
9017
  const badgeW = tw + BADGE_PADDING_X * 2;
8440
9018
  const bx = x + TICK_HALF + 2;
8441
- const by = y - BADGE_H2 / 2;
9019
+ const by = y - BADGE_H3 / 2;
8442
9020
  ctx.fillStyle = "#ffa726";
8443
9021
  ctx.beginPath();
8444
- ctx.roundRect(bx, by, badgeW, BADGE_H2, 3);
9022
+ ctx.roundRect(bx, by, badgeW, BADGE_H3, 3);
8445
9023
  ctx.fill();
8446
9024
  ctx.fillStyle = "#ffffff";
8447
9025
  ctx.textBaseline = "middle";
@@ -8461,7 +9039,7 @@ var PriceNote = class {
8461
9039
 
8462
9040
  // src/drawings/AnchoredNote.ts
8463
9041
  var HIT_TOLERANCE45 = 6;
8464
- var BADGE_H3 = 20;
9042
+ var BADGE_H4 = 20;
8465
9043
  var PAD_H2 = 10;
8466
9044
  function pointToSegmentDist21(px, py, x1, y1, x2, y2) {
8467
9045
  const dx = x2 - x1, dy = y2 - y1;
@@ -8494,7 +9072,7 @@ var AnchoredNote = class {
8494
9072
  ctx.lineWidth = isSelected ? 1.5 : 1;
8495
9073
  const badgeW = this.getBadgeW(ctx);
8496
9074
  const bx = x2 - badgeW / 2;
8497
- const by = y2 - BADGE_H3 / 2;
9075
+ const by = y2 - BADGE_H4 / 2;
8498
9076
  ctx.strokeStyle = "#42a5f5";
8499
9077
  ctx.setLineDash([4, 3]);
8500
9078
  ctx.beginPath();
@@ -8508,12 +9086,12 @@ var AnchoredNote = class {
8508
9086
  ctx.fill();
8509
9087
  ctx.fillStyle = "#37474f";
8510
9088
  ctx.beginPath();
8511
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
9089
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8512
9090
  ctx.fill();
8513
9091
  ctx.strokeStyle = "#42a5f5";
8514
9092
  ctx.lineWidth = isSelected ? 1.5 : 1;
8515
9093
  ctx.beginPath();
8516
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
9094
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8517
9095
  ctx.stroke();
8518
9096
  const label = this.text || "Note";
8519
9097
  const isPlaceholder = !this.text;
@@ -8532,8 +9110,8 @@ var AnchoredNote = class {
8532
9110
  const y2 = scale.yToPixel(p2.price, priceRange, chartH);
8533
9111
  const approxBadgeW = Math.max(50, (this.text?.length ?? 4) * 7 + 20);
8534
9112
  const bx = x2 - approxBadgeW / 2;
8535
- const by = y2 - BADGE_H3 / 2;
8536
- if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H3 + HIT_TOLERANCE45) {
9113
+ const by = y2 - BADGE_H4 / 2;
9114
+ if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H4 + HIT_TOLERANCE45) {
8537
9115
  return true;
8538
9116
  }
8539
9117
  if (pointToSegmentDist21(x, y, x1, y1, x2, y2) <= HIT_TOLERANCE45) {
@@ -12484,6 +13062,158 @@ var TradePopover = class {
12484
13062
  }
12485
13063
  };
12486
13064
 
13065
+ // src/ui/DraftQtyFlyout.ts
13066
+ var DraftQtyFlyout = class {
13067
+ constructor(theme, onCommit) {
13068
+ this._min = 1;
13069
+ this._max = 100;
13070
+ this._step = 1;
13071
+ this.onDocMouseDown = (e) => {
13072
+ if (this.isVisible && !this.el.contains(e.target)) {
13073
+ this.syncIfValid();
13074
+ this.hide();
13075
+ }
13076
+ };
13077
+ this.theme = theme;
13078
+ this.onCommit = onCommit;
13079
+ this.el = document.createElement("div");
13080
+ Object.assign(this.el.style, {
13081
+ position: "fixed",
13082
+ display: "none",
13083
+ flexDirection: "row",
13084
+ alignItems: "center",
13085
+ gap: "4px",
13086
+ background: theme.tooltip.background,
13087
+ border: `1px solid ${theme.axisBorder}`,
13088
+ borderRadius: "5px",
13089
+ padding: "5px 7px",
13090
+ zIndex: "25",
13091
+ boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
13092
+ fontFamily: "'Inter', system-ui, sans-serif",
13093
+ fontSize: "13px",
13094
+ color: theme.tooltip.text,
13095
+ pointerEvents: "auto"
13096
+ });
13097
+ const decBtn = this.makeStepBtn("\u2212", () => this.step(-1));
13098
+ this.input = this.makeInput();
13099
+ const incBtn = this.makeStepBtn("+", () => this.step(1));
13100
+ this.el.append(decBtn, this.input, incBtn);
13101
+ this.el.addEventListener("mousedown", (e) => e.stopPropagation());
13102
+ this.el.addEventListener("touchstart", (e) => e.stopPropagation());
13103
+ document.addEventListener("mousedown", this.onDocMouseDown);
13104
+ }
13105
+ // ── Public ────────────────────────────────────────────────────────────────
13106
+ show(pillX, boxBottom, currentQty, min, max) {
13107
+ this._min = min;
13108
+ this._max = max;
13109
+ this._step = min;
13110
+ this.input.min = String(min);
13111
+ this.input.max = String(max);
13112
+ this.input.step = String(min);
13113
+ this.input.value = String(currentQty);
13114
+ this.el.style.display = "flex";
13115
+ this.el.style.left = `${pillX}px`;
13116
+ this.el.style.top = `${boxBottom + 4}px`;
13117
+ requestAnimationFrame(() => {
13118
+ this.input.focus();
13119
+ this.input.select();
13120
+ });
13121
+ }
13122
+ hide() {
13123
+ this.el.style.display = "none";
13124
+ }
13125
+ get isVisible() {
13126
+ return this.el.style.display !== "none";
13127
+ }
13128
+ setTheme(theme) {
13129
+ this.theme = theme;
13130
+ Object.assign(this.el.style, {
13131
+ background: theme.tooltip.background,
13132
+ border: `1px solid ${theme.axisBorder}`,
13133
+ color: theme.tooltip.text
13134
+ });
13135
+ this.input.style.borderBottom = `1px solid ${theme.axisBorder}`;
13136
+ }
13137
+ destroy() {
13138
+ document.removeEventListener("mousedown", this.onDocMouseDown);
13139
+ }
13140
+ // ── Private ───────────────────────────────────────────────────────────────
13141
+ makeStepBtn(label, onClick) {
13142
+ const btn = document.createElement("button");
13143
+ btn.textContent = label;
13144
+ Object.assign(btn.style, {
13145
+ background: "transparent",
13146
+ border: "none",
13147
+ color: "inherit",
13148
+ cursor: "pointer",
13149
+ fontSize: "16px",
13150
+ lineHeight: "1",
13151
+ padding: "0 4px",
13152
+ borderRadius: "3px",
13153
+ opacity: "0.7"
13154
+ });
13155
+ btn.addEventListener("mouseenter", () => {
13156
+ btn.style.opacity = "1";
13157
+ });
13158
+ btn.addEventListener("mouseleave", () => {
13159
+ btn.style.opacity = "0.7";
13160
+ });
13161
+ btn.addEventListener("mousedown", (e) => {
13162
+ e.preventDefault();
13163
+ onClick();
13164
+ });
13165
+ return btn;
13166
+ }
13167
+ makeInput() {
13168
+ const input = document.createElement("input");
13169
+ input.type = "number";
13170
+ Object.assign(input.style, {
13171
+ width: "64px",
13172
+ background: "transparent",
13173
+ border: "none",
13174
+ borderBottom: `1px solid ${this.theme.axisBorder}`,
13175
+ color: "inherit",
13176
+ font: "13px 'Inter', system-ui, sans-serif",
13177
+ outline: "none",
13178
+ textAlign: "center",
13179
+ padding: "1px 2px"
13180
+ });
13181
+ input.addEventListener("input", () => {
13182
+ this.syncIfValid();
13183
+ });
13184
+ input.addEventListener("keydown", (e) => {
13185
+ if (e.key === "Enter") {
13186
+ e.preventDefault();
13187
+ this.syncIfValid();
13188
+ this.hide();
13189
+ } else if (e.key === "Escape") {
13190
+ e.preventDefault();
13191
+ this.hide();
13192
+ }
13193
+ });
13194
+ input.addEventListener("blur", () => {
13195
+ if (this.isVisible) {
13196
+ this.syncIfValid();
13197
+ this.hide();
13198
+ }
13199
+ });
13200
+ return input;
13201
+ }
13202
+ step(direction) {
13203
+ const current = parseFloat(this.input.value) || this._min;
13204
+ const next = Math.round((current + direction * this._step) * 1e10) / 1e10;
13205
+ this.input.value = String(Math.min(this._max, Math.max(this._min, next)));
13206
+ this.input.focus();
13207
+ this.syncIfValid();
13208
+ }
13209
+ syncIfValid() {
13210
+ const val = parseFloat(this.input.value);
13211
+ if (!isNaN(val) && val >= this._min && val <= this._max) {
13212
+ this.onCommit(val);
13213
+ }
13214
+ }
13215
+ };
13216
+
12487
13217
  // src/ui/IsinPickerModal.ts
12488
13218
  var INITIAL_LIMIT = 50;
12489
13219
  var SEARCH_LIMIT = 20;
@@ -12694,9 +13424,14 @@ var TradeDragHandler = class _TradeDragHandler {
12694
13424
  this._isDraggingBracket = false;
12695
13425
  // Main line drag state
12696
13426
  this._level = null;
12697
- this._startY = 0;
12698
13427
  this._startPrice = 0;
12699
13428
  this._currentPrice = 0;
13429
+ // Pixel offset between click Y and the level's rendered Y at drag start.
13430
+ // Why: the drag hit-strip is 8 px tall; without this, the cursor-to-price
13431
+ // mapping would snap the level to wherever the cursor is instead of
13432
+ // preserving the grab point. Also immunises the drag from any priceRange
13433
+ // animation (lerp) that happens between drag-start and first mousemove.
13434
+ this._pixelOffset = 0;
12700
13435
  // Entry-price constraint bounds (applied when SL/TP exist on the level)
12701
13436
  this._mainMinPrice = -Infinity;
12702
13437
  this._mainMaxPrice = Infinity;
@@ -12704,6 +13439,7 @@ var TradeDragHandler = class _TradeDragHandler {
12704
13439
  this._bracketType = null;
12705
13440
  this._bracketStartPrice = 0;
12706
13441
  this._bracketCurrentPrice = 0;
13442
+ this._bracketPixelOffset = 0;
12707
13443
  // Bracket constraint state (populated on startBracketDrag)
12708
13444
  this._bracketParentPrice = 0;
12709
13445
  this._bracketParentSide = "buy";
@@ -12715,11 +13451,11 @@ var TradeDragHandler = class _TradeDragHandler {
12715
13451
  * Pass `slPrice` / `tpPrice` / `side` to constrain the drag between SL and TP
12716
13452
  * (used for ES/EL entry orders where the entry price must stay between brackets).
12717
13453
  */
12718
- startDrag(level, startY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
13454
+ startDrag(level, clickY, levelPixelY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
12719
13455
  this._active = true;
12720
13456
  this._isDraggingBracket = false;
12721
13457
  this._level = level;
12722
- this._startY = startY;
13458
+ this._pixelOffset = clickY - levelPixelY;
12723
13459
  this._startPrice = startPrice;
12724
13460
  this._currentPrice = startPrice;
12725
13461
  this._bracketType = null;
@@ -12751,13 +13487,14 @@ var TradeDragHandler = class _TradeDragHandler {
12751
13487
  * @param bracketType 'stopLoss' | 'takeProfit'
12752
13488
  * @param startPrice Current bracket price
12753
13489
  */
12754
- startBracketDrag(parentLevel, bracketType, startPrice, currentMarketPrice = 0, parentPriceOverride) {
13490
+ startBracketDrag(parentLevel, bracketType, startPrice, clickY, bracketPixelY, currentMarketPrice = 0, parentPriceOverride) {
12755
13491
  this._active = true;
12756
13492
  this._isDraggingBracket = true;
12757
13493
  this._level = parentLevel;
12758
13494
  this._bracketType = bracketType;
12759
13495
  this._bracketStartPrice = startPrice;
12760
13496
  this._bracketCurrentPrice = startPrice;
13497
+ this._bracketPixelOffset = clickY - bracketPixelY;
12761
13498
  this._bracketParentPrice = parentPriceOverride ?? parentLevel.price;
12762
13499
  this._bracketParentSide = parentLevel.side ?? "buy";
12763
13500
  this._bracketMarketPrice = currentMarketPrice;
@@ -12768,9 +13505,10 @@ var TradeDragHandler = class _TradeDragHandler {
12768
13505
  */
12769
13506
  onMouseMove(currentY, scale, priceRange, priceH) {
12770
13507
  if (!this._active || !this._level) return null;
12771
- const rawPrice = scale.pixelToPrice(currentY, priceRange, priceH);
12772
13508
  if (this._isDraggingBracket && this._bracketType !== null) {
12773
- const clampedPrice = this._clampBracketPrice(rawPrice, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
13509
+ const effectiveY2 = currentY - this._bracketPixelOffset;
13510
+ const rawPrice2 = scale.pixelToPrice(effectiveY2, priceRange, priceH);
13511
+ const clampedPrice = this._clampBracketPrice(rawPrice2, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
12774
13512
  this._bracketCurrentPrice = clampedPrice;
12775
13513
  return {
12776
13514
  newPrice: clampedPrice,
@@ -12778,6 +13516,8 @@ var TradeDragHandler = class _TradeDragHandler {
12778
13516
  isBracket: true
12779
13517
  };
12780
13518
  }
13519
+ const effectiveY = currentY - this._pixelOffset;
13520
+ const rawPrice = scale.pixelToPrice(effectiveY, priceRange, priceH);
12781
13521
  const constrained = Math.max(this._mainMinPrice, Math.min(this._mainMaxPrice, rawPrice));
12782
13522
  this._currentPrice = constrained;
12783
13523
  return {
@@ -12876,10 +13616,12 @@ var TradeDragHandler = class _TradeDragHandler {
12876
13616
  this._level = null;
12877
13617
  this._bracketType = null;
12878
13618
  this._currentPrice = 0;
13619
+ this._pixelOffset = 0;
12879
13620
  this._mainMinPrice = -Infinity;
12880
13621
  this._mainMaxPrice = Infinity;
12881
13622
  this._bracketStartPrice = 0;
12882
13623
  this._bracketCurrentPrice = 0;
13624
+ this._bracketPixelOffset = 0;
12883
13625
  this._bracketParentPrice = 0;
12884
13626
  this._bracketParentSide = "buy";
12885
13627
  this._bracketMarketPrice = 0;
@@ -13897,6 +14639,11 @@ var _ChartEngine = class _ChartEngine {
13897
14639
  this._hoverBuyType = "limit";
13898
14640
  this._hoverSellType = "limit";
13899
14641
  this._minLots = 1;
14642
+ this._showQuantityField = false;
14643
+ this._qtyFieldMin = 1;
14644
+ this._qtyFieldMax = 100;
14645
+ this._draftQtyFlyout = null;
14646
+ this._draftQtyPillArea = void 0;
13900
14647
  this._onOrderSubmit = void 0;
13901
14648
  this.scrollToEndBtn = null;
13902
14649
  this.scrollToEndVisible = false;
@@ -13912,6 +14659,8 @@ var _ChartEngine = class _ChartEngine {
13912
14659
  this.showBidAskLines = false;
13913
14660
  this.showActLogo = false;
13914
14661
  this.hideLevelConfirmCancel = false;
14662
+ this.levelClusteringEnabled = true;
14663
+ this.clusterThresholdDistance = 20;
13915
14664
  this.enableThemeToggle = false;
13916
14665
  this.activeIndicators = [];
13917
14666
  this.indicatorResults = /* @__PURE__ */ new Map();
@@ -13977,19 +14726,28 @@ var _ChartEngine = class _ChartEngine {
13977
14726
  this._mobileDrawingFlyout = null;
13978
14727
  // Fullscreen state
13979
14728
  this._isCssFullscreen = false;
14729
+ this.timezone = "UTC";
13980
14730
  this.settingsCanvasColors = {};
13981
14731
  // Streaming
13982
14732
  this.streamAdapter = null;
13983
14733
  this.liveBidAsk = null;
13984
14734
  this.tickClosePriceSource = "bid";
14735
+ /** True while an async data fetch is in-flight; suppresses tick processing to
14736
+ * prevent stale ticks from corrupting newly-loaded bars. */
14737
+ this._suppressTicks = false;
13985
14738
  // Trade / position levels
13986
14739
  this.levels = [];
13987
14740
  this.tradeLevelHitAreas = [];
14741
+ this.offViewportHitAreas = [];
13988
14742
  this.tradeLevelEditAreas = [];
13989
14743
  this.tradeHoverAreas = [];
13990
14744
  this.tradeLevelDragAreas = [];
13991
14745
  this.tradeLevelConfirmAreas = [];
13992
14746
  this.tradeAddBracketAreas = [];
14747
+ this.clusterBadgeAreas = [];
14748
+ this.lastClusters = [];
14749
+ /** Set of cluster IDs that are currently expanded (fanned out). */
14750
+ this.expandedClusters = /* @__PURE__ */ new Set();
13993
14751
  this.hoveredTradeLabel = null;
13994
14752
  this.selectedTradeLabel = null;
13995
14753
  /** Estimated PNL text for draft order SL/TP bracket lines, set by setDraftBracketPnl(). */
@@ -14004,6 +14762,8 @@ var _ChartEngine = class _ChartEngine {
14004
14762
  // Inline drag confirmation — accumulates changes per level until user confirms
14005
14763
  this.pendingLevelDrags = /* @__PURE__ */ new Map();
14006
14764
  // Trade from Charts settings
14765
+ this.tfcEnabled = true;
14766
+ this.tfcActive = true;
14007
14767
  this.tradeDisplayFilter = "all";
14008
14768
  this.positionRenderStyle = "dot";
14009
14769
  this.tradesThresholdForHorizontalLine = 2;
@@ -14014,7 +14774,9 @@ var _ChartEngine = class _ChartEngine {
14014
14774
  this.dataLoader = null;
14015
14775
  this.isFetching = false;
14016
14776
  this.noMoreData = false;
14777
+ this._loadGeneration = 0;
14017
14778
  this.maxCandles = 200;
14779
+ this.prefetchThreshold = 80;
14018
14780
  this.defaultVisibleBars = DEFAULT_VISIBLE_BARS;
14019
14781
  this.defaultMobileVisibleBars = void 0;
14020
14782
  this.mobileBarDivisor = 2;
@@ -14052,7 +14814,12 @@ var _ChartEngine = class _ChartEngine {
14052
14814
  if (this.tradeButton) {
14053
14815
  if (isInChartArea && y >= 0 && y <= priceH && !this._isTradingActive && !hoveringDotLevel) {
14054
14816
  this.tradeButton.setY(y);
14055
- this.tradeButton.show();
14817
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
14818
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
14819
+ this.tradeButton.hide();
14820
+ } else if (this.tfcActive) {
14821
+ this.tradeButton.show();
14822
+ }
14056
14823
  this._tradeButtonClickData = { price, y };
14057
14824
  const allBars = this.dataStore.all;
14058
14825
  const lastClose = allBars.length > 0 ? allBars[allBars.length - 1].close : price;
@@ -14154,7 +14921,7 @@ var _ChartEngine = class _ChartEngine {
14154
14921
  return;
14155
14922
  }
14156
14923
  if (!this.drawingManager.getActiveTool()) {
14157
- if (hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas) || hitTestTradeLevelEdit(x, y, this.tradeLevelEditAreas) || hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas)) {
14924
+ if (hitTestClusterBadge(x, y, this.clusterBadgeAreas) || hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas) || hitTestTradeLevelEdit(x, y, this.tradeLevelEditAreas) || hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas) || hitTestOffViewportIndicator(x, y, this.offViewportHitAreas)) {
14158
14925
  this.drawCanvas.style.cursor = "pointer";
14159
14926
  this.scheduleRender();
14160
14927
  return;
@@ -14366,6 +15133,25 @@ var _ChartEngine = class _ChartEngine {
14366
15133
  this.scheduleRender();
14367
15134
  return;
14368
15135
  }
15136
+ const clusterHitTouch = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
15137
+ if (clusterHitTouch) {
15138
+ e.preventDefault();
15139
+ this.panHandler.cancel();
15140
+ if (this.expandedClusters.has(clusterHitTouch.clusterId)) {
15141
+ this.expandedClusters.delete(clusterHitTouch.clusterId);
15142
+ } else {
15143
+ this.expandedClusters.add(clusterHitTouch.clusterId);
15144
+ }
15145
+ this.renderTradeLayer();
15146
+ return;
15147
+ }
15148
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
15149
+ if (offVpHit) {
15150
+ e.preventDefault();
15151
+ this.panHandler.cancel();
15152
+ this.scrollToCenterPrice(offVpHit.targetPrice);
15153
+ return;
15154
+ }
14369
15155
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
14370
15156
  if (confirmHit) {
14371
15157
  e.preventDefault();
@@ -14373,6 +15159,15 @@ var _ChartEngine = class _ChartEngine {
14373
15159
  else this.revertPendingChanges(confirmHit.label);
14374
15160
  return;
14375
15161
  }
15162
+ if (this._showQuantityField && this._draftQtyPillArea) {
15163
+ const p = this._draftQtyPillArea;
15164
+ if (x >= p.pillX && x <= p.pillX + p.pillW && y >= p.pillY && y <= p.pillY + p.pillH) {
15165
+ e.preventDefault();
15166
+ e.stopPropagation();
15167
+ this.openDraftQtyFlyout(p);
15168
+ return;
15169
+ }
15170
+ }
14376
15171
  const closeHit = hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas);
14377
15172
  if (closeHit) {
14378
15173
  e.preventDefault();
@@ -14422,6 +15217,10 @@ var _ChartEngine = class _ChartEngine {
14422
15217
  }
14423
15218
  this.drawingManager.selectDrawing(null);
14424
15219
  this.hideMobileDrawingFlyout();
15220
+ if (this.expandedClusters.size > 0) {
15221
+ this.expandedClusters.clear();
15222
+ this.renderTradeLayer();
15223
+ }
14425
15224
  const isInChartArea = x < chartW && y < timeAxisY;
14426
15225
  if (isInChartArea) {
14427
15226
  if (this._mobileCrosshairActive) {
@@ -14571,7 +15370,12 @@ var _ChartEngine = class _ChartEngine {
14571
15370
  if (this._isTradingActive) {
14572
15371
  this.tradeButton.hide();
14573
15372
  } else {
14574
- this.tradeButton.show();
15373
+ const _btnHalfH2 = this.uiConfig.tradeButton.size / 2;
15374
+ if (overlapsOffViewportPill(y, _btnHalfH2, this.offViewportHitAreas)) {
15375
+ this.tradeButton.hide();
15376
+ } else if (this.tfcActive) {
15377
+ this.tradeButton.show();
15378
+ }
14575
15379
  }
14576
15380
  this._tradeButtonClickData = { price, y };
14577
15381
  const _allBars = this.dataStore.all;
@@ -14653,12 +15457,36 @@ var _ChartEngine = class _ChartEngine {
14653
15457
  const rawBarIndex = this.scaleManager.pixelToBarIndex(x, this.viewport, chartW);
14654
15458
  const price = this.scaleManager.pixelToPrice(y, priceRange, priceH);
14655
15459
  const barIndex = Math.round(rawBarIndex);
15460
+ const clusterHit = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
15461
+ if (clusterHit) {
15462
+ if (this.expandedClusters.has(clusterHit.clusterId)) {
15463
+ this.expandedClusters.delete(clusterHit.clusterId);
15464
+ } else {
15465
+ this.expandedClusters.add(clusterHit.clusterId);
15466
+ }
15467
+ this.renderTradeLayer();
15468
+ return;
15469
+ }
15470
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
15471
+ if (offVpHit) {
15472
+ this.scrollToCenterPrice(offVpHit.targetPrice);
15473
+ return;
15474
+ }
14656
15475
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
14657
15476
  if (confirmHit) {
14658
15477
  if (confirmHit.action === "confirm") this.applyPendingChanges(confirmHit.label);
14659
15478
  else this.revertPendingChanges(confirmHit.label);
14660
15479
  return;
14661
15480
  }
15481
+ if (this._showQuantityField && this._draftQtyPillArea) {
15482
+ const p = this._draftQtyPillArea;
15483
+ if (x >= p.pillX && x <= p.pillX + p.pillW && y >= p.pillY && y <= p.pillY + p.pillH) {
15484
+ e.preventDefault();
15485
+ e.stopPropagation();
15486
+ this.openDraftQtyFlyout(p);
15487
+ return;
15488
+ }
15489
+ }
14662
15490
  const closedLevel = hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas);
14663
15491
  if (closedLevel) {
14664
15492
  this._handleTradeLevelClose(closedLevel);
@@ -14737,6 +15565,10 @@ var _ChartEngine = class _ChartEngine {
14737
15565
  this.renderTradeLayer();
14738
15566
  }
14739
15567
  }
15568
+ if (this.expandedClusters.size > 0) {
15569
+ this.expandedClusters.clear();
15570
+ this.renderTradeLayer();
15571
+ }
14740
15572
  const timeAxisY = rect.height - this.timeAxisHeight;
14741
15573
  if (x >= chartW) {
14742
15574
  if (y <= priceH) {
@@ -14852,6 +15684,12 @@ var _ChartEngine = class _ChartEngine {
14852
15684
  this.scheduleRender();
14853
15685
  };
14854
15686
  this.onKeyDown = (e) => {
15687
+ if (e.key === "Escape" && this.expandedClusters.size > 0) {
15688
+ this.expandedClusters.clear();
15689
+ this.renderTradeLayer();
15690
+ e.preventDefault();
15691
+ return;
15692
+ }
14855
15693
  if (e.key === "Escape" && this.pendingLevelDrags.size > 0) {
14856
15694
  this.revertPendingChanges();
14857
15695
  e.preventDefault();
@@ -14871,6 +15709,7 @@ var _ChartEngine = class _ChartEngine {
14871
15709
  this.onTick = (tick) => {
14872
15710
  const all = this.dataStore.all;
14873
15711
  if (all.length === 0) return;
15712
+ if (this._suppressTicks) return;
14874
15713
  this.liveBidAsk = { bid: tick.bid, ask: tick.ask };
14875
15714
  this.setDotTickActive();
14876
15715
  const last = all[all.length - 1];
@@ -14938,7 +15777,9 @@ var _ChartEngine = class _ChartEngine {
14938
15777
  durationTimeframeMap,
14939
15778
  uiConfig,
14940
15779
  labels,
14941
- tickActivityMs = 3e4
15780
+ tickActivityMs = 3e4,
15781
+ hideSymbolAndTick = false,
15782
+ showBottomBar = false
14942
15783
  } = config;
14943
15784
  this.dataLoader = dataLoader ?? null;
14944
15785
  this.drawingToolsEnabled = config.showDrawingTools !== false;
@@ -14947,6 +15788,7 @@ var _ChartEngine = class _ChartEngine {
14947
15788
  this.labels = resolveLabels(labels);
14948
15789
  this.tickActivityMs = tickActivityMs;
14949
15790
  this.maxCandles = config.maxCandles ?? 200;
15791
+ this.prefetchThreshold = config.prefetchThreshold ?? 80;
14950
15792
  this.defaultVisibleBars = config.defaultVisibleBars ?? DEFAULT_VISIBLE_BARS;
14951
15793
  this.defaultMobileVisibleBars = config.defaultMobileVisibleBars;
14952
15794
  this.explicitMobileDivisor = config.mobileBarDivisor !== void 0;
@@ -14986,7 +15828,11 @@ var _ChartEngine = class _ChartEngine {
14986
15828
  this.showBidAskLines = config.showBidAskLines ?? false;
14987
15829
  this.showActLogo = config.showActLogo ?? false;
14988
15830
  this.hideLevelConfirmCancel = config.hideLevelConfirmCancel ?? false;
15831
+ this.levelClusteringEnabled = config.levelClusteringEnabled ?? true;
15832
+ this.clusterThresholdDistance = config.clusterThresholdDistance ?? 20;
14989
15833
  this.enableThemeToggle = config.enableThemeToggle ?? false;
15834
+ this.tfcEnabled = config.tfcEnabled !== false;
15835
+ this.tfcActive = this.tfcEnabled;
14990
15836
  this.showCandleCountdown = config.showCandleCountdown ?? true;
14991
15837
  this.candleCountdownTimeframes = config.candleCountdownTimeframes;
14992
15838
  this._onOrderSubmit = onOrderSubmit;
@@ -14994,6 +15840,11 @@ var _ChartEngine = class _ChartEngine {
14994
15840
  console.warn("[acttrader-charts] ChartConfig.minLots is deprecated. DraftOrderQtyInput has been removed. The value is stored and used as the submitted lot size.");
14995
15841
  this._minLots = config.minLots;
14996
15842
  }
15843
+ if (config.showQuantityField !== void 0) this._showQuantityField = config.showQuantityField;
15844
+ if (config.quantityFieldConfig) {
15845
+ if (config.quantityFieldConfig.minLots !== void 0) this._qtyFieldMin = config.quantityFieldConfig.minLots;
15846
+ if (config.quantityFieldConfig.maxLots !== void 0) this._qtyFieldMax = config.quantityFieldConfig.maxLots;
15847
+ }
14997
15848
  if (config.onLotsChange !== void 0) {
14998
15849
  console.warn("[acttrader-charts] ChartConfig.onLotsChange is deprecated. DraftOrderQtyInput has been removed; this callback will never fire.");
14999
15850
  }
@@ -15002,6 +15853,7 @@ var _ChartEngine = class _ChartEngine {
15002
15853
  }
15003
15854
  this.themeName = theme === "dark" ? "dark" : "light";
15004
15855
  this.themeOverrides = config.themeOverrides;
15856
+ this.timezone = config.timezone === "local" ? Intl.DateTimeFormat().resolvedOptions().timeZone : config.timezone ?? "UTC";
15005
15857
  this.aggregateFrom = config.aggregateFrom;
15006
15858
  const _baseTheme = this.themeName === "dark" ? DARK_THEME : LIGHT_THEME;
15007
15859
  this.theme = deepMergeTheme(_baseTheme, this.themeOverrides?.[this.themeName]);
@@ -15050,6 +15902,11 @@ var _ChartEngine = class _ChartEngine {
15050
15902
  this.drawingToolsEnabled ? () => this.toggleDrawingTools() : null,
15051
15903
  this.drawingToolsEnabled,
15052
15904
  this.settingsEnabled,
15905
+ this.tfcEnabled,
15906
+ this.tfcActive,
15907
+ this.tfcEnabled ? (enabled) => {
15908
+ this.setTfcActive(enabled);
15909
+ } : null,
15053
15910
  this.series,
15054
15911
  this.timeframe,
15055
15912
  null,
@@ -15201,7 +16058,7 @@ var _ChartEngine = class _ChartEngine {
15201
16058
  this.loadingOverlayEl.appendChild(loadingLabel);
15202
16059
  this.canvasWrap.appendChild(this.loadingOverlayEl);
15203
16060
  }
15204
- if (showUI) {
16061
+ if (showUI && !hideSymbolAndTick) {
15205
16062
  this.siEl = document.createElement("div");
15206
16063
  Object.assign(this.siEl.style, {
15207
16064
  position: "absolute",
@@ -15345,6 +16202,11 @@ var _ChartEngine = class _ChartEngine {
15345
16202
  const rect = this._cachedDrawRect ?? this.drawCanvas.getBoundingClientRect();
15346
16203
  const y = e.clientY - rect.top;
15347
16204
  const { priceH } = this.getChartLayout();
16205
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
16206
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
16207
+ this.tradeButton?.hide();
16208
+ return;
16209
+ }
15348
16210
  if (y >= 0 && y <= priceH) {
15349
16211
  this.tradeButton?.setY(y);
15350
16212
  const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
@@ -15359,7 +16221,7 @@ var _ChartEngine = class _ChartEngine {
15359
16221
  }
15360
16222
  middleRow.appendChild(this.canvasWrap);
15361
16223
  this.wrapper.appendChild(middleRow);
15362
- if (showUI) {
16224
+ if (showUI && showBottomBar) {
15363
16225
  const resolvedDurationMap = { ...DURATION_TIMEFRAME_MAP, ...durationTimeframeMap };
15364
16226
  this.bottomBar = new BottomBar(
15365
16227
  (d, tf) => {
@@ -15434,6 +16296,12 @@ var _ChartEngine = class _ChartEngine {
15434
16296
  },
15435
16297
  () => {
15436
16298
  this._isPanning = false;
16299
+ },
16300
+ {
16301
+ enabled: config.momentumScrollEnabled,
16302
+ decay: config.momentumDecay,
16303
+ threshold: config.momentumThreshold,
16304
+ maxVelocity: config.momentumMaxVelocity
15437
16305
  }
15438
16306
  );
15439
16307
  this.zoomHandler = new ZoomHandler(
@@ -15509,9 +16377,31 @@ var _ChartEngine = class _ChartEngine {
15509
16377
  return (this.tradePopover?.isVisible ?? false) || this.draftOrderLabel !== null || (this.tradeDragHandler?.active ?? false) || this.pendingLevelDrags.size > 0;
15510
16378
  }
15511
16379
  // ── Public API ────────────────────────────────────────────────────────────
16380
+ /**
16381
+ * Completely resets the chart to a clean, empty state.
16382
+ *
16383
+ * Cancels any in-flight data fetch, clears all bars, resets the price/time
16384
+ * axes, and discards the live bid/ask price line. The chart will render as
16385
+ * blank until the next `loadData()` call.
16386
+ *
16387
+ * Call this before switching to a new symbol so that no previous symbol's
16388
+ * data, price lines, or stale ticks can bleed into the new chart:
16389
+ * ```
16390
+ * chart.setSymbol('GBPUSD').resetData();
16391
+ * // … fetch new bars, then:
16392
+ * chart.loadData(newBars);
16393
+ * ```
16394
+ */
16395
+ resetData() {
16396
+ ++this._loadGeneration;
16397
+ this.setLoading(false);
16398
+ return this.loadData([]);
16399
+ }
15512
16400
  loadData(bars, fitAll = false) {
15513
16401
  this.isFetching = false;
15514
16402
  this.noMoreData = false;
16403
+ this._suppressTicks = false;
16404
+ this.liveBidAsk = null;
15515
16405
  this.pricePanOffset = 0;
15516
16406
  this.priceScaleFactor = 1;
15517
16407
  this._displayedPriceRange = null;
@@ -15658,6 +16548,8 @@ var _ChartEngine = class _ChartEngine {
15658
16548
  }
15659
16549
  triggerLoad(reason) {
15660
16550
  if (!this.dataLoader) return;
16551
+ const gen = ++this._loadGeneration;
16552
+ this._suppressTicks = true;
15661
16553
  this.isFetching = true;
15662
16554
  this.noMoreData = false;
15663
16555
  const end = /* @__PURE__ */ new Date();
@@ -15669,6 +16561,7 @@ var _ChartEngine = class _ChartEngine {
15669
16561
  const interval = this.timeframeToInterval(this.getBaseInterval());
15670
16562
  this.setLoading(true);
15671
16563
  this.dataLoader({ start, end, interval }).then((bars) => {
16564
+ if (gen !== this._loadGeneration) return;
15672
16565
  this.loadData(this.maybeAggregate(bars), reason === "duration");
15673
16566
  this.setLoading(false);
15674
16567
  this.emitter.emit("dataLoaded", {
@@ -15678,25 +16571,17 @@ var _ChartEngine = class _ChartEngine {
15678
16571
  end
15679
16572
  });
15680
16573
  }).catch(() => {
16574
+ if (gen !== this._loadGeneration) return;
15681
16575
  this.isFetching = false;
15682
16576
  this.setLoading(false);
15683
16577
  });
15684
16578
  }
15685
16579
  checkNeedMoreData() {
15686
16580
  if (!this.dataLoader || this.isFetching || this.noMoreData) return;
15687
- const threshold = Math.max(
15688
- 20,
15689
- Math.round(
15690
- initialVisibleBars(
15691
- this.mobileBarDivisor,
15692
- this.explicitMobileDivisor,
15693
- this.defaultVisibleBars,
15694
- this.defaultMobileVisibleBars
15695
- ) * 0.25
15696
- )
15697
- );
16581
+ const threshold = Math.max(20, this.prefetchThreshold);
15698
16582
  if (this._targetViewport.startIndex > threshold) return;
15699
16583
  if (this.dataStore.length === 0) return;
16584
+ const gen = this._loadGeneration;
15700
16585
  this.isFetching = true;
15701
16586
  const before = this.dataStore.all[0].time;
15702
16587
  const end = new Date(before - 1);
@@ -15708,9 +16593,11 @@ var _ChartEngine = class _ChartEngine {
15708
16593
  const interval = this.timeframeToInterval(this.getBaseInterval());
15709
16594
  this.setLoading(true);
15710
16595
  this.dataLoader({ start, end, interval }).then((bars) => {
16596
+ if (gen !== this._loadGeneration) return;
15711
16597
  this.prependData(this.maybeAggregate(bars));
15712
16598
  this.setLoading(false);
15713
16599
  }).catch(() => {
16600
+ if (gen !== this._loadGeneration) return;
15714
16601
  this.isFetching = false;
15715
16602
  this.setLoading(false);
15716
16603
  });
@@ -15922,6 +16809,7 @@ var _ChartEngine = class _ChartEngine {
15922
16809
  this.leftBar?.setTheme(this.theme);
15923
16810
  this.tradeButton?.setTheme(this.theme, this.priceAxisWidth);
15924
16811
  this.tradePopover?.setTheme(this.theme);
16812
+ this._draftQtyFlyout?.setTheme(this.theme);
15925
16813
  this.indicatorSettingsDialog?.setTheme(this.theme);
15926
16814
  this.chartSettingsDialog?.setTheme(this.theme);
15927
16815
  this.applyScrollToEndBtnStyles();
@@ -15952,11 +16840,21 @@ var _ChartEngine = class _ChartEngine {
15952
16840
  this.leftBar?.setTheme(this.theme);
15953
16841
  this.tradeButton?.setTheme(this.theme, this.priceAxisWidth);
15954
16842
  this.tradePopover?.setTheme(this.theme);
16843
+ this._draftQtyFlyout?.setTheme(this.theme);
15955
16844
  this.indicatorSettingsDialog?.setTheme(this.theme);
15956
16845
  this.chartSettingsDialog?.setTheme(this.theme);
15957
16846
  this.scheduleRender();
15958
16847
  return this;
15959
16848
  }
16849
+ /**
16850
+ * Changes the display timezone for all time-axis and crosshair labels.
16851
+ * Accepts any IANA string (e.g. `"America/New_York"`), `"UTC"`, or `"local"`.
16852
+ */
16853
+ setTimezone(tz) {
16854
+ this.timezone = tz === "local" ? Intl.DateTimeFormat().resolvedOptions().timeZone : tz;
16855
+ this.scheduleRender();
16856
+ return this;
16857
+ }
15960
16858
  setSeries(series) {
15961
16859
  this.series = series;
15962
16860
  this.topBar?.setSeries(series);
@@ -15965,6 +16863,31 @@ var _ChartEngine = class _ChartEngine {
15965
16863
  this.emitStateChange();
15966
16864
  return this;
15967
16865
  }
16866
+ /**
16867
+ * Toggle TFC (Trade from Charts) on or off at runtime.
16868
+ * When disabled, all trade levels, the trade button, and trade popover are hidden.
16869
+ * Only functional when `tfcEnabled` config option is `true` (default).
16870
+ */
16871
+ setTfcActive(enabled) {
16872
+ if (!this.tfcEnabled) return this;
16873
+ this.tfcActive = enabled;
16874
+ this.topBar?.setTfcActive(enabled);
16875
+ if (!enabled) {
16876
+ this.tradeButton?.hide();
16877
+ this.tradePopover?.hide();
16878
+ if (this.draftOrderLabel !== null) {
16879
+ this.removeDraftOrder();
16880
+ }
16881
+ }
16882
+ if (this.chartSettingsDialog) {
16883
+ this.chartSettingsDialog.destroy();
16884
+ this.chartSettingsDialog = null;
16885
+ }
16886
+ this.renderTradeLayer();
16887
+ this.emitter.emit("tfcToggle", { enabled });
16888
+ this.emitStateChange();
16889
+ return this;
16890
+ }
15968
16891
  clearDuration() {
15969
16892
  if (this.duration === void 0) return;
15970
16893
  this.duration = void 0;
@@ -16008,7 +16931,8 @@ var _ChartEngine = class _ChartEngine {
16008
16931
  })),
16009
16932
  positionRenderStyle: this.positionRenderStyle,
16010
16933
  tradeDisplayFilter: this.tradeDisplayFilter,
16011
- canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0
16934
+ canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0,
16935
+ tfcEnabled: this.tfcActive
16012
16936
  };
16013
16937
  }
16014
16938
  /**
@@ -16080,9 +17004,14 @@ var _ChartEngine = class _ChartEngine {
16080
17004
  if (state.canvasColors) {
16081
17005
  this.settingsCanvasColors = state.canvasColors;
16082
17006
  }
17007
+ if (state.tfcEnabled !== void 0 && this.tfcEnabled) {
17008
+ this.tfcActive = state.tfcEnabled;
17009
+ this.topBar?.setTfcActive(state.tfcEnabled);
17010
+ }
16083
17011
  } finally {
16084
17012
  this.isRestoringState = false;
16085
17013
  }
17014
+ this.emitStateChange();
16086
17015
  this.scheduleRender();
16087
17016
  return this;
16088
17017
  }
@@ -16144,6 +17073,7 @@ var _ChartEngine = class _ChartEngine {
16144
17073
  }
16145
17074
  if (this.siStreamDot)
16146
17075
  this.siStreamDot.style.background = this.theme.ui.stream[this.streamStatus];
17076
+ this.liveBidAsk = null;
16147
17077
  return this;
16148
17078
  }
16149
17079
  /**
@@ -16207,6 +17137,38 @@ var _ChartEngine = class _ChartEngine {
16207
17137
  this.tradePopover?.setLots(lots);
16208
17138
  return this;
16209
17139
  }
17140
+ /**
17141
+ * Updates the quantity (lots) shown in the draft order QTY pill and
17142
+ * submitted when the order is confirmed.
17143
+ * @param qty Lot size to display and submit.
17144
+ */
17145
+ setDraftQty(qty) {
17146
+ if (this.draftOrderLabel) {
17147
+ const idx = this.levels.findIndex((l) => l.label === this.draftOrderLabel);
17148
+ if (idx !== -1) {
17149
+ const lvl = this.levels[idx];
17150
+ this.levels = [
17151
+ ...this.levels.slice(0, idx),
17152
+ { ...lvl, lots: qty },
17153
+ ...this.levels.slice(idx + 1)
17154
+ ];
17155
+ this.renderTradeLayer();
17156
+ }
17157
+ }
17158
+ return this;
17159
+ }
17160
+ openDraftQtyFlyout(pill) {
17161
+ if (!this._draftQtyFlyout) {
17162
+ this._draftQtyFlyout = new DraftQtyFlyout(this.theme, (qty) => this.setDraftQty(qty));
17163
+ this.getFloatingRoot().appendChild(this._draftQtyFlyout.el);
17164
+ }
17165
+ const draft = this.levels.find((l) => l.label === this.draftOrderLabel);
17166
+ const currentQty = draft?.lots ?? this._qtyFieldMin;
17167
+ const rect = this.canvasWrap.getBoundingClientRect();
17168
+ const vpX = rect.left + pill.pillX;
17169
+ const vpY = rect.top + pill.pillY + pill.pillH;
17170
+ this._draftQtyFlyout.show(vpX, vpY, currentQty, this._qtyFieldMin, this._qtyFieldMax);
17171
+ }
16210
17172
  /**
16211
17173
  * @deprecated DraftOrderQtyInput has been removed. This method is a no-op
16212
17174
  * kept for backward compatibility. It will be removed in a future version.
@@ -16682,7 +17644,27 @@ var _ChartEngine = class _ChartEngine {
16682
17644
  */
16683
17645
  selectLevel(label) {
16684
17646
  this.selectedTradeLabel = label;
16685
- if (label !== null) this.crosshair = null;
17647
+ if (label !== null) {
17648
+ this.crosshair = null;
17649
+ const cluster = this.lastClusters.find(
17650
+ (c) => c.members.some((m) => m.level.label === label)
17651
+ );
17652
+ if (cluster) this.expandedClusters.add(cluster.id);
17653
+ }
17654
+ this.renderTradeLayer();
17655
+ return this;
17656
+ }
17657
+ /** Enable or disable trade-level fan-out clustering at runtime. */
17658
+ setLevelClusteringEnabled(enabled) {
17659
+ this.levelClusteringEnabled = enabled;
17660
+ this.expandedClusters.clear();
17661
+ this.renderTradeLayer();
17662
+ return this;
17663
+ }
17664
+ /** Update the cluster proximity threshold (in pixels) at runtime. Only effective when levelClusteringEnabled is true. */
17665
+ setClusterThresholdDistance(px) {
17666
+ this.clusterThresholdDistance = px;
17667
+ this.expandedClusters.clear();
16686
17668
  this.renderTradeLayer();
16687
17669
  return this;
16688
17670
  }
@@ -16697,6 +17679,7 @@ var _ChartEngine = class _ChartEngine {
16697
17679
  this.chartSettingsDialog?.destroy();
16698
17680
  this.isinPickerModal?.destroy();
16699
17681
  this.tradePopover?.destroy();
17682
+ this._draftQtyFlyout?.destroy();
16700
17683
  this.leftBar?.destroy();
16701
17684
  this.drawCanvas.removeEventListener("mousemove", this.onMouseMove);
16702
17685
  this.drawCanvas.removeEventListener("mouseleave", this.onMouseLeave);
@@ -16865,7 +17848,7 @@ var _ChartEngine = class _ChartEngine {
16865
17848
  theme: this.themeName
16866
17849
  },
16867
17850
  this.labels.dialogs,
16868
- !!this._onOrderSubmit,
17851
+ !!this._onOrderSubmit && this.tfcActive,
16869
17852
  this.enableThemeToggle
16870
17853
  );
16871
17854
  this.getFloatingRoot().appendChild(this.chartSettingsDialog.el);
@@ -16909,7 +17892,8 @@ var _ChartEngine = class _ChartEngine {
16909
17892
  const modals = [
16910
17893
  this.isinPickerModal?.el,
16911
17894
  this.indicatorSettingsDialog?.el,
16912
- this.chartSettingsDialog?.el
17895
+ this.chartSettingsDialog?.el,
17896
+ this._draftQtyFlyout?.el
16913
17897
  ];
16914
17898
  for (const el of modals) {
16915
17899
  if (el && el.parentElement !== root) root.appendChild(el);
@@ -16976,11 +17960,12 @@ var _ChartEngine = class _ChartEngine {
16976
17960
  const dpr = window.devicePixelRatio || 1;
16977
17961
  const { tradeCtx: ctx, tradeCanvas: canvas, theme, viewport } = this;
16978
17962
  ctx.clearRect(0, 0, canvas.width, canvas.height);
16979
- if (this.levels.length === 0 || this.dataStore.length === 0) {
17963
+ if (!this.tfcActive || this.levels.length === 0 || this.dataStore.length === 0) {
16980
17964
  this.tradeLevelHitAreas = [];
16981
17965
  this.tradeLevelEditAreas = [];
16982
17966
  this.tradeHoverAreas = [];
16983
17967
  this.tradeAddBracketAreas = [];
17968
+ this.offViewportHitAreas = [];
16984
17969
  return;
16985
17970
  }
16986
17971
  const { chartW, priceH } = this.getChartLayout();
@@ -17035,6 +18020,7 @@ var _ChartEngine = class _ChartEngine {
17035
18020
  // Include true pending orders AND entry-order positions (ES/EL)
17036
18021
  processedLevels.filter((l) => l.type === "pending" || isEntryOrder(l))
17037
18022
  ) : processedLevels;
18023
+ const clusterThreshold = this.levelClusteringEnabled ? this.clusterThresholdDistance : 0;
17038
18024
  const result = renderTradeLevels(
17039
18025
  ctx,
17040
18026
  filteredLevels,
@@ -17052,9 +18038,23 @@ var _ChartEngine = class _ChartEngine {
17052
18038
  this.positionRenderStyle,
17053
18039
  this.hideLevelConfirmCancel,
17054
18040
  this.draftOrderLabel,
17055
- this.draftBracketPnl
18041
+ this.draftBracketPnl,
18042
+ this.expandedClusters,
18043
+ clusterThreshold,
18044
+ this._showQuantityField
17056
18045
  );
17057
18046
  ctx.restore();
18047
+ const offVpResult = renderOffViewportIndicators(
18048
+ ctx,
18049
+ filteredLevels,
18050
+ priceRange,
18051
+ theme,
18052
+ chartW,
18053
+ priceH,
18054
+ this.tradeDragHandler?.dragLabel ?? null,
18055
+ this.selectedTradeLabel
18056
+ );
18057
+ this.offViewportHitAreas = offVpResult.hitAreas;
17058
18058
  ctx.restore();
17059
18059
  this.tradeLevelHitAreas = result.hitAreas;
17060
18060
  this.tradeLevelEditAreas = result.editAreas;
@@ -17062,6 +18062,15 @@ var _ChartEngine = class _ChartEngine {
17062
18062
  this.tradeLevelDragAreas = result.dragAreas;
17063
18063
  this.tradeLevelConfirmAreas = result.confirmAreas;
17064
18064
  this.tradeAddBracketAreas = result.addBracketAreas;
18065
+ this.clusterBadgeAreas = result.clusterBadgeAreas;
18066
+ this.lastClusters = result.clusters;
18067
+ this._draftQtyPillArea = result.draftQtyPillArea;
18068
+ if (this.expandedClusters.size > 0) {
18069
+ const currentIds = new Set(result.clusters.map((c) => c.id));
18070
+ for (const id of this.expandedClusters) {
18071
+ if (!currentIds.has(id)) this.expandedClusters.delete(id);
18072
+ }
18073
+ }
17065
18074
  }
17066
18075
  // ── Inline pending-change confirmation ────────────────────────────────────
17067
18076
  /** Returns the effective entry price for a level, preferring any staged main drag. */
@@ -17175,6 +18184,7 @@ var _ChartEngine = class _ChartEngine {
17175
18184
  side,
17176
18185
  price,
17177
18186
  orderType,
18187
+ lots: this._qtyFieldMin,
17178
18188
  data: { _isDraft: true }
17179
18189
  };
17180
18190
  this.levels = [...this.levels, draftLevel];
@@ -17198,6 +18208,8 @@ var _ChartEngine = class _ChartEngine {
17198
18208
  this.selectedTradeLabel = null;
17199
18209
  this.draftOrderLabel = null;
17200
18210
  this.draftBracketPnl = {};
18211
+ this._draftQtyFlyout?.hide();
18212
+ this._draftQtyPillArea = void 0;
17201
18213
  this.renderTradeLayer();
17202
18214
  this.emitter.emit("draftCancelled", { label, isFullscreen: this.isFullscreen });
17203
18215
  }
@@ -17211,7 +18223,7 @@ var _ChartEngine = class _ChartEngine {
17211
18223
  const draft = level;
17212
18224
  this._onOrderSubmit?.({
17213
18225
  price: mainEntry?.newPrice ?? draft.price,
17214
- lots: this._minLots,
18226
+ lots: draft.lots ?? this._qtyFieldMin,
17215
18227
  side: draft.side,
17216
18228
  orderType: draft.orderType,
17217
18229
  ...draft.stopLossPrice !== void 0 ? { stopLoss: draft.stopLossPrice } : {},
@@ -17689,7 +18701,8 @@ var _ChartEngine = class _ChartEngine {
17689
18701
  isMobileDevice() ? 3 : void 0,
17690
18702
  this.uiConfig.timeAxis,
17691
18703
  this.timeframe,
17692
- countdownBarIndex
18704
+ countdownBarIndex,
18705
+ this.timezone
17693
18706
  );
17694
18707
  if (showCountdown) {
17695
18708
  const lastBar2 = this.dataStore.all[this.dataStore.length - 1];
@@ -17722,7 +18735,8 @@ var _ChartEngine = class _ChartEngine {
17722
18735
  subPaneY,
17723
18736
  this.timeframe,
17724
18737
  this.dataStore.getPricePrecision(),
17725
- this.uiConfig.crosshair
18738
+ this.uiConfig.crosshair,
18739
+ this.timezone
17726
18740
  );
17727
18741
  }
17728
18742
  if (this.logoImage) {
@@ -17956,7 +18970,12 @@ var _ChartEngine = class _ChartEngine {
17956
18970
  if (this._isTradingActive) {
17957
18971
  this.tradeButton.hide();
17958
18972
  } else {
17959
- this.tradeButton.show();
18973
+ const _btnHalfH1 = this.uiConfig.tradeButton.size / 2;
18974
+ if (overlapsOffViewportPill(y, _btnHalfH1, this.offViewportHitAreas)) {
18975
+ this.tradeButton.hide();
18976
+ } else if (this.tfcActive) {
18977
+ this.tradeButton.show();
18978
+ }
17960
18979
  }
17961
18980
  this._tradeButtonClickData = { price, y };
17962
18981
  const _allBars = this.dataStore.all;
@@ -17969,6 +18988,9 @@ var _ChartEngine = class _ChartEngine {
17969
18988
  /** Shared helper: initiate a trade level main-line drag from a hit area. */
17970
18989
  _startTradeDragFromHit(dragHit, level, hitY) {
17971
18990
  if (!this.tradeDragHandler) return;
18991
+ const { priceH } = this.getChartLayout();
18992
+ const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
18993
+ const levelPixelY = this.scaleManager.yToPixel(dragHit.price, priceRange, priceH);
17972
18994
  if (dragHit.isBracket) {
17973
18995
  this._pendingBracketOrderLabel = dragHit.bracketOrderLabel ?? null;
17974
18996
  this._isBracketNew = false;
@@ -17981,6 +19003,8 @@ var _ChartEngine = class _ChartEngine {
17981
19003
  level,
17982
19004
  dragHit.bracketType,
17983
19005
  dragHit.price,
19006
+ hitY,
19007
+ levelPixelY,
17984
19008
  marketPrice,
17985
19009
  this._effectiveEntryPrice(level.label, level.price)
17986
19010
  );
@@ -18000,6 +19024,7 @@ var _ChartEngine = class _ChartEngine {
18000
19024
  this.tradeDragHandler.startDrag(
18001
19025
  level,
18002
19026
  hitY,
19027
+ levelPixelY,
18003
19028
  dragHit.price,
18004
19029
  posLevel.stopLossPrice,
18005
19030
  posLevel.takeProfitPrice,
@@ -18014,6 +19039,7 @@ var _ChartEngine = class _ChartEngine {
18014
19039
  this.tradeDragHandler.startDrag(
18015
19040
  level,
18016
19041
  hitY,
19042
+ levelPixelY,
18017
19043
  dragHit.price,
18018
19044
  void 0,
18019
19045
  void 0,
@@ -18043,6 +19069,8 @@ var _ChartEngine = class _ChartEngine {
18043
19069
  level,
18044
19070
  addHit.bracketType,
18045
19071
  initialPrice,
19072
+ hitY,
19073
+ btnCenterY,
18046
19074
  marketPrice,
18047
19075
  this._effectiveEntryPrice(level.label, level.price)
18048
19076
  );
@@ -18129,6 +19157,7 @@ var _ChartEngine = class _ChartEngine {
18129
19157
  * This happens when the system clock was behind feed time during the initial data load. */
18130
19158
  fillTickGap(fromTime, toTime) {
18131
19159
  if (!this.dataLoader) return;
19160
+ if (this.isFetching) return;
18132
19161
  const interval = this.timeframeToInterval(this.getBaseInterval());
18133
19162
  this.dataLoader({ start: new Date(fromTime), end: new Date(toTime), interval }).then((rawBars) => {
18134
19163
  const bars = this.maybeAggregate(rawBars);
@@ -18163,6 +19192,7 @@ var _ChartEngine = class _ChartEngine {
18163
19192
  * tick-reconstructed OHLCV with authoritative exchange data. No-op if no loader. */
18164
19193
  correctCompletedBar(barTime, intervalMs) {
18165
19194
  if (!this.dataLoader) return;
19195
+ if (this.isFetching) return;
18166
19196
  const start = new Date(barTime);
18167
19197
  const end = new Date(barTime + intervalMs * 3);
18168
19198
  const interval = this.timeframeToInterval(this.getBaseInterval());
@@ -18202,6 +19232,7 @@ var _ChartEngine = class _ChartEngine {
18202
19232
  this.scheduleRender();
18203
19233
  }
18204
19234
  scrollToEnd() {
19235
+ this.panHandler.cancel();
18205
19236
  if (this.scrollAnimId !== null) {
18206
19237
  cancelAnimationFrame(this.scrollAnimId);
18207
19238
  this.scrollAnimId = null;
@@ -18233,6 +19264,38 @@ var _ChartEngine = class _ChartEngine {
18233
19264
  };
18234
19265
  this.scrollAnimId = requestAnimationFrame(step);
18235
19266
  }
19267
+ /**
19268
+ * Smooth-scroll the Y axis so `price` lands at the vertical center of the
19269
+ * chart. Animates `pricePanOffset` only — horizontal viewport is preserved.
19270
+ * Used by off-viewport level indicator clicks.
19271
+ */
19272
+ scrollToCenterPrice(price) {
19273
+ if (!isFinite(price) || this.dataStore.length === 0) return;
19274
+ if (this.scrollAnimId !== null) {
19275
+ cancelAnimationFrame(this.scrollAnimId);
19276
+ this.scrollAnimId = null;
19277
+ }
19278
+ const base = this.dataStore.getPriceRange(this.viewport);
19279
+ const naturalMid = (base.min + base.max) / 2;
19280
+ const startPan = this.pricePanOffset;
19281
+ const targetPan = price - naturalMid;
19282
+ if (Math.abs(targetPan - startPan) < 1e-9) return;
19283
+ const DURATION = 350;
19284
+ const t0 = performance.now();
19285
+ const ease = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
19286
+ const step = (now) => {
19287
+ const raw = Math.min((now - t0) / DURATION, 1);
19288
+ const prog = ease(raw);
19289
+ this.pricePanOffset = startPan + (targetPan - startPan) * prog;
19290
+ this.render();
19291
+ if (raw < 1) {
19292
+ this.scrollAnimId = requestAnimationFrame(step);
19293
+ } else {
19294
+ this.scrollAnimId = null;
19295
+ }
19296
+ };
19297
+ this.scrollAnimId = requestAnimationFrame(step);
19298
+ }
18236
19299
  resetPriceAxis() {
18237
19300
  this.priceScaleFactor = 1;
18238
19301
  this.pricePanOffset = 0;
@@ -18240,6 +19303,7 @@ var _ChartEngine = class _ChartEngine {
18240
19303
  this.scheduleRender();
18241
19304
  }
18242
19305
  resetTimeAxis() {
19306
+ this.panHandler.cancel();
18243
19307
  const total = this.dataStore.length;
18244
19308
  if (total === 0) return;
18245
19309
  const newStart = Math.max(