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.cjs CHANGED
@@ -116,6 +116,33 @@ var _ScaleManager = class _ScaleManager {
116
116
  };
117
117
  _ScaleManager.VERTICAL_PADDING_PX = 20;
118
118
  var ScaleManager = _ScaleManager;
119
+ function niceStep(rawStep) {
120
+ if (!isFinite(rawStep) || rawStep <= 0) return 1;
121
+ const exp = Math.floor(Math.log10(rawStep));
122
+ const base = Math.pow(10, exp);
123
+ const frac = rawStep / base;
124
+ const nice = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
125
+ return nice * base;
126
+ }
127
+ function generatePriceTicks(range, chartHeight, targetPxPerLabel = 48) {
128
+ const { min, max } = range;
129
+ if (!isFinite(min) || !isFinite(max) || max <= min) return [min];
130
+ const approxSteps = Math.max(2, Math.floor(chartHeight / targetPxPerLabel));
131
+ const rawStep = (max - min) / approxSteps;
132
+ const step = niceStep(rawStep);
133
+ const first = Math.ceil(min / step) * step;
134
+ const ticks = [];
135
+ const eps = step * 1e-6;
136
+ for (let v = first; v <= max + eps; v += step) {
137
+ ticks.push(v);
138
+ }
139
+ return ticks;
140
+ }
141
+ function precisionForStep(step, minPrecision = 0) {
142
+ if (!isFinite(step) || step <= 0) return minPrecision;
143
+ const exp = Math.floor(Math.log10(step));
144
+ return Math.max(minPrecision, -exp);
145
+ }
119
146
 
120
147
  // src/core/EventEmitter.ts
121
148
  var EventEmitter = class {
@@ -168,6 +195,11 @@ function resolveCssVarsInTheme(theme, container) {
168
195
  container.appendChild(tmp);
169
196
  const resolve = (value) => {
170
197
  if (!value.includes("var(")) return value;
198
+ value = value.replace(
199
+ /rgba\(\s*(var\([^)]+\))\s*,\s*([0-9.]+)\s*\)/g,
200
+ "rgb($1 / $2)"
201
+ );
202
+ tmp.style.color = "";
171
203
  tmp.style.color = value;
172
204
  const resolved = getComputedStyle(tmp).color;
173
205
  return resolved || value;
@@ -534,7 +566,8 @@ var DEFAULT_LABELS = {
534
566
  toggleDrawingTitle: "Toggle Drawing Tools",
535
567
  toggleFullscreenTitle: "Toggle Fullscreen",
536
568
  exitFullscreenTitle: "Exit Fullscreen",
537
- settingsTitle: "Settings"
569
+ settingsTitle: "Settings",
570
+ tfcToggleTitle: "Trade from Charts"
538
571
  },
539
572
  bottomBar: {
540
573
  durations: {
@@ -725,8 +758,7 @@ function resolveLabels(override) {
725
758
 
726
759
  // src/interaction/PanHandler.ts
727
760
  var _PanHandler = class _PanHandler {
728
- // px — below this = click, not drag
729
- constructor(canvas, getViewport, setViewport, totalBars, onUpdate, isLocked = () => false, panY, onDragStart, onDragEnd) {
761
+ constructor(canvas, getViewport, setViewport, totalBars, onUpdate, isLocked = () => false, panY, onDragStart, onDragEnd, momentumOptions) {
730
762
  this.canvas = canvas;
731
763
  this.getViewport = getViewport;
732
764
  this.setViewport = setViewport;
@@ -744,7 +776,12 @@ var _PanHandler = class _PanHandler {
744
776
  this.dragStartY = 0;
745
777
  this.hasPanned = false;
746
778
  this.dragStartFired = false;
779
+ // ── Velocity sample buffer (rolling 100 ms window) ─────────────────────────
780
+ this._velBuffer = [];
781
+ // ── In-flight momentum RAF ─────────────────────────────────────────────────
782
+ this._momentumRafId = null;
747
783
  this.onMouseDown = (e) => {
784
+ this._stopMomentum();
748
785
  if (this.isLocked()) return;
749
786
  this.isDragging = true;
750
787
  this.isTouchDrag = false;
@@ -753,10 +790,15 @@ var _PanHandler = class _PanHandler {
753
790
  this.dragStartX = e.clientX;
754
791
  this.dragStartY = e.clientY;
755
792
  this.hasPanned = false;
793
+ this._velBuffer.length = 0;
756
794
  };
757
795
  this.onTouchStart = (e) => {
796
+ if (e.touches.length !== 1) {
797
+ this._stopMomentum();
798
+ return;
799
+ }
800
+ this._stopMomentum();
758
801
  if (this.isLocked()) return;
759
- if (e.touches.length !== 1) return;
760
802
  this.isDragging = true;
761
803
  this.isTouchDrag = true;
762
804
  this.lastX = e.touches[0].clientX;
@@ -764,6 +806,7 @@ var _PanHandler = class _PanHandler {
764
806
  this.dragStartX = e.touches[0].clientX;
765
807
  this.dragStartY = e.touches[0].clientY;
766
808
  this.dragStartFired = true;
809
+ this._velBuffer.length = 0;
767
810
  this.onDragStart?.();
768
811
  };
769
812
  this.onMouseMove = (e) => {
@@ -786,6 +829,7 @@ var _PanHandler = class _PanHandler {
786
829
  this.canvas.style.cursor = "grabbing";
787
830
  }
788
831
  if (this.hasPanned) {
832
+ this._recordVelocitySample(deltaX, performance.now());
789
833
  this.pan(deltaX);
790
834
  if (this.panY) this.panY(deltaY);
791
835
  }
@@ -799,6 +843,7 @@ var _PanHandler = class _PanHandler {
799
843
  e.preventDefault();
800
844
  const deltaX = e.touches[0].clientX - this.lastX;
801
845
  const deltaY = e.touches[0].clientY - this.lastY;
846
+ this._recordVelocitySample(deltaX, performance.now());
802
847
  this.pan(deltaX);
803
848
  if (this.panY) this.panY(deltaY);
804
849
  this.lastX = e.touches[0].clientX;
@@ -810,9 +855,16 @@ var _PanHandler = class _PanHandler {
810
855
  this.hasPanned = false;
811
856
  if (this.dragStartFired) {
812
857
  this.dragStartFired = false;
813
- this.onDragEnd?.();
858
+ const velocity = this._computeReleaseVelocity();
859
+ this._velBuffer.length = 0;
860
+ const launched = this._startMomentum(velocity);
861
+ if (!launched) this.onDragEnd?.();
814
862
  }
815
863
  };
864
+ this._momentumEnabled = momentumOptions?.enabled ?? true;
865
+ this._momentumDecay = Math.min(0.99, Math.max(0.8, momentumOptions?.decay ?? 0.95));
866
+ this._momentumThreshold = momentumOptions?.threshold ?? 0.3;
867
+ this._momentumMaxVelocity = momentumOptions?.maxVelocity ?? 6;
816
868
  this.attach();
817
869
  }
818
870
  attach() {
@@ -823,12 +875,14 @@ var _PanHandler = class _PanHandler {
823
875
  window.addEventListener("touchmove", this.onTouchMove, { passive: false });
824
876
  window.addEventListener("touchend", this.onMouseUp);
825
877
  }
826
- /** Cancel any in-progress pan gesture (e.g. when a drawing drag takes over). */
878
+ /** Cancel any in-progress pan gesture or momentum (e.g. when a drawing drag takes over). */
827
879
  cancel() {
880
+ this._stopMomentum();
828
881
  this.isDragging = false;
829
882
  this.hasPanned = false;
830
883
  }
831
884
  destroy() {
885
+ this._stopMomentum();
832
886
  this.canvas.removeEventListener("mousedown", this.onMouseDown);
833
887
  window.removeEventListener("mousemove", this.onMouseMove);
834
888
  window.removeEventListener("mouseup", this.onMouseUp);
@@ -836,6 +890,73 @@ var _PanHandler = class _PanHandler {
836
890
  window.removeEventListener("touchmove", this.onTouchMove);
837
891
  window.removeEventListener("touchend", this.onMouseUp);
838
892
  }
893
+ // ── Velocity tracking ────────────────────────────────────────────────────────
894
+ _recordVelocitySample(dx, t) {
895
+ this._velBuffer.push({ dx, t });
896
+ const cutoff = t - _PanHandler.VEL_WINDOW_MS;
897
+ let i = 0;
898
+ while (i < this._velBuffer.length && this._velBuffer[i].t < cutoff) i++;
899
+ if (i > 0) this._velBuffer.splice(0, i);
900
+ }
901
+ _computeReleaseVelocity() {
902
+ const buf = this._velBuffer;
903
+ if (buf.length < 2) return 0;
904
+ const totalDx = buf.reduce((acc, s) => acc + s.dx, 0);
905
+ const dt = buf[buf.length - 1].t - buf[0].t;
906
+ if (dt <= 0) return 0;
907
+ const raw = totalDx / dt;
908
+ return Math.sign(raw) * Math.min(Math.abs(raw), this._momentumMaxVelocity);
909
+ }
910
+ // ── Momentum animation ────────────────────────────────────────────────────────
911
+ /**
912
+ * Launch momentum scrolling with the given initial velocity (px/ms).
913
+ * Returns `true` if momentum was successfully launched, `false` otherwise.
914
+ */
915
+ _startMomentum(velocity) {
916
+ if (!this._momentumEnabled) return false;
917
+ if (Math.abs(velocity) < this._momentumThreshold) return false;
918
+ let lastTime = null;
919
+ let vel = velocity;
920
+ const decay = this._momentumDecay;
921
+ const tick = (now) => {
922
+ if (lastTime === null) {
923
+ lastTime = now;
924
+ this._momentumRafId = requestAnimationFrame(tick);
925
+ return;
926
+ }
927
+ const dt = now - lastTime;
928
+ lastTime = now;
929
+ vel *= Math.pow(decay, dt / 16.67);
930
+ const pixelDelta = vel * dt;
931
+ if (Math.abs(pixelDelta) < 0.5) {
932
+ this._stopMomentum();
933
+ this.onDragEnd?.();
934
+ return;
935
+ }
936
+ if (this.isLocked()) {
937
+ this._stopMomentum();
938
+ this.onDragEnd?.();
939
+ return;
940
+ }
941
+ const prevStart = this.getViewport().startIndex;
942
+ this.pan(pixelDelta);
943
+ if (this.getViewport().startIndex === prevStart) {
944
+ this._stopMomentum();
945
+ this.onDragEnd?.();
946
+ return;
947
+ }
948
+ this._momentumRafId = requestAnimationFrame(tick);
949
+ };
950
+ this._momentumRafId = requestAnimationFrame(tick);
951
+ return true;
952
+ }
953
+ _stopMomentum() {
954
+ if (this._momentumRafId !== null) {
955
+ cancelAnimationFrame(this._momentumRafId);
956
+ this._momentumRafId = null;
957
+ }
958
+ }
959
+ // ── Core pan logic ────────────────────────────────────────────────────────────
839
960
  pan(deltaX) {
840
961
  const vp = this.getViewport();
841
962
  const visibleBars = vp.endIndex - vp.startIndex;
@@ -849,6 +970,7 @@ var _PanHandler = class _PanHandler {
849
970
  }
850
971
  };
851
972
  _PanHandler.DRAG_THRESHOLD = 4;
973
+ _PanHandler.VEL_WINDOW_MS = 100;
852
974
  var PanHandler = _PanHandler;
853
975
 
854
976
  // src/interaction/ZoomHandler.ts
@@ -888,11 +1010,11 @@ var ZoomHandler = class {
888
1010
  Math.max(MIN_VISIBLE_BARS, Math.min(MAX_VISIBLE_BARS, visibleBars * zoomFactor))
889
1011
  );
890
1012
  if (vp.endIndex >= this.totalBars()) {
891
- const newEnd = vp.endIndex;
1013
+ const newEnd = Math.min(vp.endIndex, this.totalBars() + Math.floor(newVisible / 2));
892
1014
  const newStart = Math.max(0, newEnd - newVisible);
893
- this.setViewport({ ...vp, startIndex: newStart, endIndex: newEnd });
1015
+ this.setViewport({ ...vp, startIndex: newStart, endIndex: newStart + newVisible });
894
1016
  } else {
895
- const mouseRatio = e.offsetX / this.canvas.width;
1017
+ const mouseRatio = e.offsetX / this.getChartWidth();
896
1018
  const pivotBar = vp.startIndex + visibleBars * mouseRatio;
897
1019
  const newStart = Math.max(0, pivotBar - newVisible * mouseRatio);
898
1020
  this.setViewport({ ...vp, startIndex: newStart, endIndex: newStart + newVisible });
@@ -1429,18 +1551,18 @@ function renderHighLow(ctx, bars, viewport, priceRange, scale, theme, chartWidth
1429
1551
  }
1430
1552
 
1431
1553
  // src/overlays/GridOverlay.ts
1432
- function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines = 6) {
1554
+ function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines) {
1433
1555
  ctx.save();
1434
1556
  ctx.globalAlpha = 0.6;
1435
1557
  ctx.strokeStyle = theme.grid;
1436
1558
  ctx.lineWidth = 1;
1437
- const step = (priceRange.max - priceRange.min) / gridLines;
1438
1559
  ctx.beginPath();
1439
- for (let i = 0; i <= gridLines; i++) {
1440
- const price = priceRange.min + step * i;
1441
- const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1442
- ctx.moveTo(0, y);
1443
- ctx.lineTo(chartWidth, y);
1560
+ {
1561
+ for (const price of generatePriceTicks(priceRange, chartHeight)) {
1562
+ const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1563
+ ctx.moveTo(0, y);
1564
+ ctx.lineTo(chartWidth, y);
1565
+ }
1444
1566
  }
1445
1567
  ctx.stroke();
1446
1568
  const vertCols = Math.max(1, Math.floor(chartWidth / 80));
@@ -1457,19 +1579,19 @@ function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, grid
1457
1579
 
1458
1580
  // src/overlays/CrosshairOverlay.ts
1459
1581
  var SUB_DAILY = ["1m", "5m", "15m", "30m", "1h", "4h"];
1460
- function formatCrosshairTime(ts, timeframe) {
1582
+ function formatCrosshairTime(ts, timeframe, timezone = "UTC") {
1461
1583
  const d = new Date(ts);
1462
1584
  if (SUB_DAILY.includes(timeframe)) {
1463
- const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
1464
- const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" });
1585
+ const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: timezone });
1586
+ const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: timezone });
1465
1587
  return `${date} ${time}`;
1466
1588
  }
1467
1589
  if (timeframe === "1M") {
1468
- return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
1590
+ return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: timezone });
1469
1591
  }
1470
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
1592
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: timezone });
1471
1593
  }
1472
- function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidth, chartHeight, priceAxisWidth, axisY, timeframe, pricePrecision = 2, cfg = DEFAULT_CROSSHAIR_CONFIG) {
1594
+ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidth, chartHeight, priceAxisWidth, axisY, timeframe, pricePrecision = 2, cfg = DEFAULT_CROSSHAIR_CONFIG, timezone = "UTC") {
1473
1595
  const x = scale.xToPixel(pos.barIndex, viewport, chartWidth);
1474
1596
  const y = scale.yToPixel(pos.price, priceRange, chartHeight);
1475
1597
  ctx.setLineDash([4, 4]);
@@ -1497,7 +1619,7 @@ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidt
1497
1619
  ctx.textAlign = "left";
1498
1620
  ctx.fillText(priceLabel, chartWidth + labelPad, y + 4);
1499
1621
  if (pos.bar) {
1500
- const timeLabel = formatCrosshairTime(pos.bar.time, timeframe);
1622
+ const timeLabel = formatCrosshairTime(pos.bar.time, timeframe, timezone);
1501
1623
  const tw = ctx.measureText(timeLabel).width;
1502
1624
  const boxW = tw + labelPad * 2;
1503
1625
  const boxX = Math.max(0, Math.min(chartWidth - boxW, x - boxW / 2));
@@ -1512,8 +1634,70 @@ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidt
1512
1634
  }
1513
1635
  }
1514
1636
 
1637
+ // src/overlays/TradeLevelCluster.ts
1638
+ function detectClusters(measured, thresholdPx, excludeLabels = /* @__PURE__ */ new Set()) {
1639
+ const eligible = measured.filter((m) => !excludeLabels.has(m.level.label)).sort((a, b) => a.y - b.y);
1640
+ if (eligible.length < 2) return [];
1641
+ const groups = [];
1642
+ let currentGroup = [eligible[0]];
1643
+ let groupStartY = eligible[0].y;
1644
+ for (let i = 1; i < eligible.length; i++) {
1645
+ const m = eligible[i];
1646
+ if (m.y - groupStartY <= thresholdPx) {
1647
+ currentGroup.push(m);
1648
+ } else {
1649
+ groups.push(currentGroup);
1650
+ currentGroup = [m];
1651
+ groupStartY = m.y;
1652
+ }
1653
+ }
1654
+ groups.push(currentGroup);
1655
+ return groups.filter((g) => g.length >= 2).map((members) => {
1656
+ const id = "cluster:" + members.map((m) => m.level.label).sort().join("|");
1657
+ const centerY = members.reduce((sum, m) => sum + m.y, 0) / members.length;
1658
+ const centerPrice = members.reduce((sum, m) => sum + m.price, 0) / members.length;
1659
+ return { id, members, centerY, centerPrice };
1660
+ });
1661
+ }
1662
+ function computeFanOutLayout(cluster, boxH, gapPx = 4) {
1663
+ const n = cluster.members.length;
1664
+ const totalH = n * boxH + (n - 1) * gapPx;
1665
+ const startY = cluster.centerY - totalH / 2;
1666
+ const sorted = [...cluster.members].sort((a, b) => a.y - b.y);
1667
+ const result = /* @__PURE__ */ new Map();
1668
+ for (let i = 0; i < sorted.length; i++) {
1669
+ result.set(sorted[i].level.label, startY + i * (boxH + gapPx));
1670
+ }
1671
+ return result;
1672
+ }
1673
+ function buildClusterBadgeLayout(cluster, chartW, ctx) {
1674
+ const n = cluster.members.length;
1675
+ const types = new Set(cluster.members.map((m) => m.level.type));
1676
+ let noun;
1677
+ if (types.size === 1) {
1678
+ const t = types.values().next().value;
1679
+ noun = t === "position" ? "Position" : t === "pending" ? "Order" : "Trade";
1680
+ } else {
1681
+ noun = "Level";
1682
+ }
1683
+ const text = `${n} ${noun}${n > 1 ? "s" : ""}`;
1684
+ const PAD_X = 12;
1685
+ const textW = ctx.measureText(text).width;
1686
+ const EXPAND_ICON_W = 16;
1687
+ const badgeW = PAD_X + textW + EXPAND_ICON_W + PAD_X;
1688
+ const badgeH = 24;
1689
+ const badgeX = (chartW - badgeW) / 2;
1690
+ const badgeY = cluster.centerY - badgeH / 2;
1691
+ return { badgeX, badgeY, badgeW, badgeH, text };
1692
+ }
1693
+ function hitTestClusterBadge(x, y, areas) {
1694
+ return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
1695
+ }
1696
+
1515
1697
  // src/overlays/TradeLevelOverlay.ts
1516
1698
  var BOX_PAD_X = 8;
1699
+ var PILL_PAD_X = 6;
1700
+ var PILL_GAP = 6;
1517
1701
  var BOX_PAD_Y = 4;
1518
1702
  var ROW_H = 16;
1519
1703
  var BOX_RADIUS = 3;
@@ -1630,7 +1814,7 @@ function isDraggable(level) {
1630
1814
  return !!level.entryPriceEditable;
1631
1815
  return false;
1632
1816
  }
1633
- 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 = {}) {
1817
+ 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) {
1634
1818
  if (levels.length === 0)
1635
1819
  return {
1636
1820
  hitAreas: [],
@@ -1638,7 +1822,9 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1638
1822
  hoverAreas: [],
1639
1823
  dragAreas: [],
1640
1824
  confirmAreas: [],
1641
- addBracketAreas: []
1825
+ addBracketAreas: [],
1826
+ clusterBadgeAreas: [],
1827
+ clusters: []
1642
1828
  };
1643
1829
  const colors = theme.tradeLevels;
1644
1830
  const boxH = ROW_H + BOX_PAD_Y * 2;
@@ -1666,16 +1852,52 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1666
1852
  const effectivePending = hasPending && !hideConfirmCancel;
1667
1853
  const confirmW = effectivePending ? (CONFIRM_R * 2 + CONFIRM_GAP) * 2 : 0;
1668
1854
  const closeW = effectivePending || hideConfirmCancel ? 0 : EDIT_R * 2 + EDIT_GAP + CLOSE_R * 2 + BOX_PAD_X / 2;
1669
- const bW = BOX_PAD_X + pnlW + labelW + BOX_PAD_X + confirmW + closeW;
1855
+ const isDraftLevel = level.data?._isDraft === true;
1856
+ const qtyPillText = showQuantityField && isDraftLevel && level.type === "pending" && level.lots !== void 0 ? String(level.lots) : null;
1857
+ const pillW = qtyPillText ? PILL_PAD_X + ctx.measureText(qtyPillText).width + PILL_PAD_X + PILL_GAP : 0;
1858
+ const bW = BOX_PAD_X + pillW + pnlW + labelW + BOX_PAD_X + confirmW + closeW;
1670
1859
  measured.push({ level, price, y, boxW: bW, boxH });
1671
1860
  }
1861
+ const excludeFromClustering = /* @__PURE__ */ new Set();
1862
+ if (draftOrderLabel) excludeFromClustering.add(draftOrderLabel);
1863
+ const clusters = clusterThresholdPx > 0 ? detectClusters(measured, clusterThresholdPx, excludeFromClustering) : [];
1864
+ const collapsedClusters = clusters.filter((c) => !expandedClusters.has(c.id));
1865
+ const expandedClusterList = clusters.filter((c) => expandedClusters.has(c.id));
1866
+ const collapsedLabels = /* @__PURE__ */ new Set();
1867
+ for (const c of collapsedClusters) {
1868
+ for (const m of c.members) collapsedLabels.add(m.level.label);
1869
+ }
1870
+ const expandedFanLayouts = /* @__PURE__ */ new Map();
1871
+ for (const ec of expandedClusterList) {
1872
+ expandedFanLayouts.set(ec.id, computeFanOutLayout(ec, boxH, 4));
1873
+ }
1874
+ const labelToExpandedCluster = /* @__PURE__ */ new Map();
1875
+ for (const ec of expandedClusterList) {
1876
+ for (const m of ec.members) labelToExpandedCluster.set(m.level.label, ec);
1877
+ }
1672
1878
  measured.sort((a, b) => a.y - b.y);
1673
1879
  const placed = [];
1674
1880
  const layouts = /* @__PURE__ */ new Map();
1675
1881
  for (const m of measured) {
1676
- const centeredY = m.y - m.boxH / 2;
1677
- const overlaps = placed.some((p) => rectsOverlapY(centeredY, m.boxH, p.boxY, p.boxH));
1678
- const boxY = overlaps ? centeredY - m.boxH - 2 : centeredY;
1882
+ if (collapsedLabels.has(m.level.label)) continue;
1883
+ const ec = labelToExpandedCluster.get(m.level.label);
1884
+ if (ec) {
1885
+ const fanMap = expandedFanLayouts.get(ec.id);
1886
+ const fanY = fanMap?.get(m.level.label);
1887
+ if (fanY !== void 0) {
1888
+ placed.push({ label: m.level.label, boxY: fanY, boxH: m.boxH });
1889
+ layouts.set(m.level.label, { price: m.price, y: m.y, boxY: fanY, boxW: m.boxW, boxH: m.boxH });
1890
+ continue;
1891
+ }
1892
+ }
1893
+ let boxY = m.y - m.boxH / 2;
1894
+ let attempts = 0;
1895
+ while (attempts < placed.length + 1) {
1896
+ const overlaps = placed.some((p) => rectsOverlapY(boxY, m.boxH, p.boxY, p.boxH));
1897
+ if (!overlaps) break;
1898
+ boxY -= m.boxH + 2;
1899
+ attempts++;
1900
+ }
1679
1901
  placed.push({ label: m.level.label, boxY, boxH: m.boxH });
1680
1902
  layouts.set(m.level.label, { price: m.price, y: m.y, boxY, boxW: m.boxW, boxH: m.boxH });
1681
1903
  }
@@ -1691,9 +1913,10 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1691
1913
  selectedLabel
1692
1914
  );
1693
1915
  const activeLabel = hoveredLabel ?? selectedLabel;
1916
+ const visibleMeasured = measured.filter((m) => !collapsedLabels.has(m.level.label));
1694
1917
  const drawOrder = [
1695
- ...measured.filter((m) => m.level.label !== activeLabel),
1696
- ...measured.filter((m) => m.level.label === activeLabel)
1918
+ ...visibleMeasured.filter((m) => m.level.label !== activeLabel),
1919
+ ...visibleMeasured.filter((m) => m.level.label === activeLabel)
1697
1920
  ];
1698
1921
  const hitAreas = [];
1699
1922
  const editAreas = [];
@@ -1701,6 +1924,11 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1701
1924
  const dragAreas = [];
1702
1925
  const confirmAreas = [];
1703
1926
  const addBracketAreas = [];
1927
+ const clusterBadgeAreas = [];
1928
+ const draftQtyPillAreas = [];
1929
+ for (const cluster of collapsedClusters) {
1930
+ drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas);
1931
+ }
1704
1932
  for (const { level } of drawOrder) {
1705
1933
  const layout = layouts.get(level.label);
1706
1934
  if (!layout) continue;
@@ -1729,11 +1957,148 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1729
1957
  confirmAreas,
1730
1958
  addBracketAreas,
1731
1959
  hideConfirmCancel,
1732
- level.label === draftOrderLabel ? draftBracketPnl : void 0
1960
+ level.label === draftOrderLabel ? draftBracketPnl : void 0,
1961
+ showQuantityField,
1962
+ draftQtyPillAreas
1733
1963
  );
1964
+ const ec = labelToExpandedCluster.get(level.label);
1965
+ if (ec && layout.boxY !== layout.y - boxH / 2) {
1966
+ drawFanConnector(ctx, layout.boxY + boxH / 2, layout.y, chartW, theme);
1967
+ }
1968
+ }
1969
+ if (selectedLabel !== null && !layouts.has(selectedLabel)) {
1970
+ const offLevel = levels.find((l) => l.label === selectedLabel);
1971
+ if (offLevel) {
1972
+ const bh = offLevel;
1973
+ const slDragKey = `${offLevel.label}:stopLoss`;
1974
+ const tpDragKey = `${offLevel.label}:takeProfit`;
1975
+ const fallbackX = chartW * BRACKET_BASE_RATIO;
1976
+ if (bh.stopLossPrice !== void 0) {
1977
+ drawBracketLine(
1978
+ ctx,
1979
+ bh,
1980
+ "stopLoss",
1981
+ bh.stopLossPrice,
1982
+ scale,
1983
+ priceRange,
1984
+ priceH,
1985
+ bracketLineXMap.get(slDragKey) ?? fallbackX,
1986
+ chartW,
1987
+ theme,
1988
+ precision,
1989
+ draggingLabel,
1990
+ dragNewPrice,
1991
+ hitAreas,
1992
+ dragAreas,
1993
+ hideConfirmCancel,
1994
+ bh._slOrderLabel
1995
+ );
1996
+ }
1997
+ if (bh.takeProfitPrice !== void 0) {
1998
+ drawBracketLine(
1999
+ ctx,
2000
+ bh,
2001
+ "takeProfit",
2002
+ bh.takeProfitPrice,
2003
+ scale,
2004
+ priceRange,
2005
+ priceH,
2006
+ bracketLineXMap.get(tpDragKey) ?? fallbackX,
2007
+ chartW,
2008
+ theme,
2009
+ precision,
2010
+ draggingLabel,
2011
+ dragNewPrice,
2012
+ hitAreas,
2013
+ dragAreas,
2014
+ hideConfirmCancel,
2015
+ bh._tpOrderLabel
2016
+ );
2017
+ }
2018
+ }
2019
+ }
2020
+ ctx.restore();
2021
+ return {
2022
+ hitAreas,
2023
+ editAreas,
2024
+ hoverAreas,
2025
+ dragAreas,
2026
+ confirmAreas,
2027
+ addBracketAreas,
2028
+ clusterBadgeAreas,
2029
+ clusters,
2030
+ draftQtyPillArea: draftQtyPillAreas[0]
2031
+ };
2032
+ }
2033
+ var BADGE_FONT = "bold 12px 'Inter', system-ui, sans-serif";
2034
+ var BADGE_PAD_X = 12;
2035
+ var BADGE_H = 24;
2036
+ var BADGE_RADIUS = 12;
2037
+ function drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas) {
2038
+ const mono = monoColor(theme);
2039
+ const isDark = theme.background !== "#ffffff";
2040
+ ctx.save();
2041
+ ctx.strokeStyle = mono;
2042
+ ctx.globalAlpha = 0.12;
2043
+ ctx.lineWidth = 1;
2044
+ ctx.setLineDash([4, 4]);
2045
+ for (const m of cluster.members) {
2046
+ ctx.beginPath();
2047
+ ctx.moveTo(0, m.y);
2048
+ ctx.lineTo(chartW, m.y);
2049
+ ctx.stroke();
1734
2050
  }
2051
+ ctx.setLineDash([]);
2052
+ ctx.restore();
2053
+ ctx.save();
2054
+ ctx.font = BADGE_FONT;
2055
+ const layout = buildClusterBadgeLayout(cluster, chartW, ctx);
2056
+ const { badgeX, badgeY, badgeW, text } = layout;
2057
+ const expandIcon = " \u25B8";
2058
+ const bgColor = isDark ? "rgba(40,40,40,0.92)" : "rgba(240,240,240,0.92)";
2059
+ ctx.fillStyle = bgColor;
2060
+ ctx.beginPath();
2061
+ ctx.roundRect(badgeX, badgeY, badgeW, BADGE_H, BADGE_RADIUS);
2062
+ ctx.fill();
2063
+ ctx.strokeStyle = mono;
2064
+ ctx.globalAlpha = 0.3;
2065
+ ctx.lineWidth = 1;
2066
+ ctx.stroke();
2067
+ ctx.globalAlpha = 1;
2068
+ ctx.fillStyle = mono;
2069
+ ctx.globalAlpha = 0.85;
2070
+ ctx.textBaseline = "middle";
2071
+ ctx.textAlign = "left";
2072
+ ctx.fillText(text + expandIcon, badgeX + BADGE_PAD_X, badgeY + BADGE_H / 2);
2073
+ ctx.globalAlpha = 1;
2074
+ ctx.restore();
2075
+ clusterBadgeAreas.push({
2076
+ clusterId: cluster.id,
2077
+ x: badgeX,
2078
+ y: badgeY,
2079
+ w: badgeW,
2080
+ h: BADGE_H
2081
+ });
2082
+ }
2083
+ function drawFanConnector(ctx, fanCenterY, realY, chartW, theme) {
2084
+ if (Math.abs(fanCenterY - realY) < 2) return;
2085
+ const mono = monoColor(theme);
2086
+ ctx.save();
2087
+ ctx.strokeStyle = mono;
2088
+ ctx.globalAlpha = 0.25;
2089
+ ctx.lineWidth = 1;
2090
+ ctx.setLineDash([2, 3]);
2091
+ const connectorX = chartW - 8;
2092
+ ctx.beginPath();
2093
+ ctx.moveTo(connectorX, fanCenterY);
2094
+ ctx.lineTo(connectorX, realY);
2095
+ ctx.stroke();
2096
+ ctx.setLineDash([]);
2097
+ ctx.beginPath();
2098
+ ctx.moveTo(connectorX - 4, realY);
2099
+ ctx.lineTo(connectorX + 4, realY);
2100
+ ctx.stroke();
1735
2101
  ctx.restore();
1736
- return { hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas };
1737
2102
  }
1738
2103
  function drawDotHoverBox(ctx, level, y, dotX, dotR, theme, precision) {
1739
2104
  const pl = level;
@@ -1793,7 +2158,7 @@ function drawDotHoverBox(ctx, level, y, dotX, dotR, theme, precision) {
1793
2158
  });
1794
2159
  ctx.restore();
1795
2160
  }
1796
- 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) {
2161
+ 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) {
1797
2162
  const { y, boxY, boxW, boxH } = layout;
1798
2163
  const color = levelColor(level, colors, theme);
1799
2164
  const active = hovered || selected;
@@ -1981,6 +2346,29 @@ function drawLevel(ctx, level, layout, chartW, colors, theme, precision, hovered
1981
2346
  ctx.fill();
1982
2347
  ctx.stroke();
1983
2348
  let textX = boxX + BOX_PAD_X;
2349
+ const isDraftLevel = level.data?._isDraft === true;
2350
+ if (showQuantityField && isDraftLevel && level.type === "pending" && level.lots !== void 0) {
2351
+ const qtyText = String(level.lots);
2352
+ ctx.font = FONT_MAIN;
2353
+ const pillContentW = ctx.measureText(qtyText).width;
2354
+ const pillW = PILL_PAD_X + pillContentW + PILL_PAD_X;
2355
+ const pillX = textX;
2356
+ ctx.save();
2357
+ ctx.strokeStyle = monoColor(theme);
2358
+ ctx.globalAlpha = 0.35;
2359
+ ctx.lineWidth = 1;
2360
+ ctx.beginPath();
2361
+ ctx.moveTo(pillX + pillW, boxY + 3);
2362
+ ctx.lineTo(pillX + pillW, boxY + boxH - 3);
2363
+ ctx.stroke();
2364
+ ctx.restore();
2365
+ ctx.fillStyle = monoColor(theme);
2366
+ ctx.textAlign = "left";
2367
+ ctx.textBaseline = "middle";
2368
+ ctx.fillText(qtyText, pillX + PILL_PAD_X, midY);
2369
+ draftQtyPillAreas?.push({ label: level.label, pillX, pillY: boxY, pillW, pillH: boxH });
2370
+ textX += pillW + PILL_GAP;
2371
+ }
1984
2372
  if (pnlText) {
1985
2373
  const pnlColor = pnl !== void 0 ? pnl >= 0 ? theme.candle.up : theme.candle.down : monoColor(theme);
1986
2374
  ctx.font = FONT_PNL;
@@ -2364,6 +2752,152 @@ function hitTestTradeLevelAddBracket(x, y, areas) {
2364
2752
  return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
2365
2753
  }
2366
2754
 
2755
+ // src/overlays/OffViewportLevelIndicator.ts
2756
+ function collectOffViewportMarkers(levels, priceRange, draggingLabel) {
2757
+ const above = [];
2758
+ const below = [];
2759
+ const { min, max } = priceRange;
2760
+ for (const level of levels) {
2761
+ if (level.label === draggingLabel) continue;
2762
+ const isDraftMarket = level.type === "pending" && level.orderType === "market" && level.data?._isDraft === true;
2763
+ if (isDraftMarket) continue;
2764
+ if (isFinite(level.price)) {
2765
+ if (level.price > max) above.push({ price: level.price, label: level.label, kind: "entry" });
2766
+ else if (level.price < min) below.push({ price: level.price, label: level.label, kind: "entry" });
2767
+ }
2768
+ const slPrice = level.stopLossPrice;
2769
+ const tpPrice = level.takeProfitPrice;
2770
+ if (slPrice !== void 0 && isFinite(slPrice)) {
2771
+ if (slPrice > max) above.push({ price: slPrice, label: level.label, kind: "sl" });
2772
+ else if (slPrice < min) below.push({ price: slPrice, label: level.label, kind: "sl" });
2773
+ }
2774
+ if (tpPrice !== void 0 && isFinite(tpPrice)) {
2775
+ if (tpPrice > max) above.push({ price: tpPrice, label: level.label, kind: "tp" });
2776
+ else if (tpPrice < min) below.push({ price: tpPrice, label: level.label, kind: "tp" });
2777
+ }
2778
+ }
2779
+ return { above, below };
2780
+ }
2781
+ function entryCount(markers) {
2782
+ let n = 0;
2783
+ for (const m of markers) if (m.kind === "entry") n++;
2784
+ return n;
2785
+ }
2786
+ function nearestEdgeMarker(markers, edge) {
2787
+ let best = markers[0];
2788
+ let bestDist = Math.abs(best.price - edge);
2789
+ for (let i = 1; i < markers.length; i++) {
2790
+ const d = Math.abs(markers[i].price - edge);
2791
+ if (d < bestDist) {
2792
+ best = markers[i];
2793
+ bestDist = d;
2794
+ }
2795
+ }
2796
+ return best;
2797
+ }
2798
+ var PILL_FONT = "11px 'Inter', system-ui, sans-serif";
2799
+ var PILL_PAD_X2 = 8;
2800
+ var PILL_RIGHT_MARGIN = 8;
2801
+ var PILL_V_MARGIN = 10;
2802
+ var PILL_H = 20;
2803
+ var PILL_RADIUS = 10;
2804
+ var ARROW_UP = "\u25B2";
2805
+ var ARROW_DOWN = "\u25BC";
2806
+ function renderOffViewportIndicators(ctx, levels, priceRange, theme, chartWidth, chartHeight, draggingLabel, selectedLabel = null) {
2807
+ const hitAreas = [];
2808
+ const PADDING_PX = 20;
2809
+ const usable = chartHeight - 2 * PADDING_PX;
2810
+ const span = priceRange.max - priceRange.min;
2811
+ const padInPrice = usable > 0 ? span / usable * PADDING_PX : 0;
2812
+ const visualRange = {
2813
+ min: priceRange.min - padInPrice,
2814
+ max: priceRange.max + padInPrice
2815
+ };
2816
+ const { above, below } = collectOffViewportMarkers(levels, visualRange, draggingLabel);
2817
+ if (above.length === 0 && below.length === 0) return { hitAreas };
2818
+ ctx.save();
2819
+ ctx.font = PILL_FONT;
2820
+ ctx.textBaseline = "middle";
2821
+ ctx.textAlign = "center";
2822
+ const PILL_GAP2 = 4;
2823
+ const renderPill = (direction, text, y, target) => {
2824
+ const textW = ctx.measureText(text).width;
2825
+ const w = Math.ceil(textW) + PILL_PAD_X2 * 2;
2826
+ const x = chartWidth - PILL_RIGHT_MARGIN - w;
2827
+ ctx.fillStyle = theme.tooltip.background;
2828
+ ctx.strokeStyle = theme.tooltip.border;
2829
+ ctx.lineWidth = 1;
2830
+ roundedRect(ctx, x, y, w, PILL_H, PILL_RADIUS);
2831
+ ctx.fill();
2832
+ ctx.stroke();
2833
+ ctx.fillStyle = theme.tooltip.text;
2834
+ ctx.fillText(text, x + w / 2, y + PILL_H / 2 + 0.5);
2835
+ hitAreas.push({
2836
+ direction,
2837
+ x,
2838
+ y,
2839
+ w,
2840
+ h: PILL_H,
2841
+ targetPrice: target.price,
2842
+ targetLabel: target.label,
2843
+ targetKind: target.kind
2844
+ });
2845
+ return w;
2846
+ };
2847
+ const drawColumn = (direction, markers, baseY) => {
2848
+ if (markers.length === 0) return;
2849
+ const arrow = direction === "up" ? ARROW_UP : ARROW_DOWN;
2850
+ const edge = direction === "up" ? priceRange.max : priceRange.min;
2851
+ const stackDir = direction === "up" ? 1 : -1;
2852
+ let nextY = baseY;
2853
+ const entries = entryCount(markers);
2854
+ if (entries > 0) {
2855
+ const target = nearestEdgeMarker(
2856
+ markers.filter((m) => m.kind === "entry"),
2857
+ edge
2858
+ );
2859
+ renderPill(direction, `${arrow} ${entries}`, nextY, target);
2860
+ nextY += stackDir * (PILL_H + PILL_GAP2);
2861
+ }
2862
+ if (selectedLabel !== null) {
2863
+ const bracketMarkers = markers.filter(
2864
+ (m) => m.kind !== "entry" && m.label === selectedLabel
2865
+ );
2866
+ for (const bm of bracketMarkers) {
2867
+ const kindLabel = bm.kind.toUpperCase();
2868
+ renderPill(direction, `${arrow} ${kindLabel}`, nextY, bm);
2869
+ nextY += stackDir * (PILL_H + PILL_GAP2);
2870
+ }
2871
+ }
2872
+ };
2873
+ drawColumn("up", above, PILL_V_MARGIN);
2874
+ drawColumn("down", below, chartHeight - PILL_V_MARGIN - PILL_H);
2875
+ ctx.restore();
2876
+ return { hitAreas };
2877
+ }
2878
+ function hitTestOffViewportIndicator(x, y, hitAreas) {
2879
+ return hitAreas.find((h) => x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h) ?? null;
2880
+ }
2881
+ function overlapsOffViewportPill(centerY, halfH, hitAreas) {
2882
+ const top = centerY - halfH;
2883
+ const bottom = centerY + halfH;
2884
+ return hitAreas.some((h) => top < h.y + h.h && bottom > h.y);
2885
+ }
2886
+ function roundedRect(ctx, x, y, w, h, r) {
2887
+ const rr = Math.min(r, w / 2, h / 2);
2888
+ ctx.beginPath();
2889
+ ctx.moveTo(x + rr, y);
2890
+ ctx.lineTo(x + w - rr, y);
2891
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
2892
+ ctx.lineTo(x + w, y + h - rr);
2893
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
2894
+ ctx.lineTo(x + rr, y + h);
2895
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
2896
+ ctx.lineTo(x, y + rr);
2897
+ ctx.quadraticCurveTo(x, y, x + rr, y);
2898
+ ctx.closePath();
2899
+ }
2900
+
2367
2901
  // src/axis/TimeAxis.ts
2368
2902
  var COUNTDOWN_LABEL_HEIGHT = 18;
2369
2903
  var COUNTDOWN_LABEL_PADDING_X = 6;
@@ -2390,45 +2924,45 @@ function formatRemaining(totalSec) {
2390
2924
  const d = Math.floor(totalSec % 604800 / 86400);
2391
2925
  return `${w}w ${d}d`;
2392
2926
  }
2393
- function formatTimestamp(ts, visibleBars, timeframe, spansMultipleDays) {
2927
+ function formatTimestamp(ts, visibleBars, timeframe, spansMultipleDays, timezone = "UTC") {
2394
2928
  const d = new Date(ts);
2395
2929
  if (timeframe && INTRADAY_TIMEFRAMES.includes(timeframe)) {
2396
2930
  const time = d.toLocaleTimeString("en-US", {
2397
2931
  hour: "2-digit",
2398
2932
  minute: "2-digit",
2399
2933
  hour12: false,
2400
- timeZone: "UTC"
2934
+ timeZone: timezone
2401
2935
  });
2402
2936
  if (spansMultipleDays) {
2403
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }) + " " + time;
2937
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: timezone }) + " " + time;
2404
2938
  }
2405
2939
  return time;
2406
2940
  }
2407
2941
  if (timeframe === "1Y") {
2408
- return d.toLocaleDateString("en-US", { year: "numeric", timeZone: "UTC" });
2942
+ return d.toLocaleDateString("en-US", { year: "numeric", timeZone: timezone });
2409
2943
  }
2410
2944
  if (timeframe === "1M") {
2411
- return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
2945
+ return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: timezone });
2412
2946
  }
2413
2947
  if (visibleBars <= 365) {
2414
2948
  return d.toLocaleDateString("en-US", {
2415
2949
  month: "short",
2416
2950
  day: "numeric",
2417
- timeZone: "UTC"
2951
+ timeZone: timezone
2418
2952
  });
2419
2953
  }
2420
- return d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: "UTC" });
2954
+ return d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: timezone });
2421
2955
  }
2422
- function renderTimeAxis(ctx, bars, viewport, scale, theme, chartWidth, axisY, maxLabels = 8, cfg = DEFAULT_TIME_AXIS_CONFIG, timeframe, countdownBarIndex) {
2956
+ function renderTimeAxis(ctx, bars, viewport, scale, theme, chartWidth, axisY, maxLabels = 8, cfg = DEFAULT_TIME_AXIS_CONFIG, timeframe, countdownBarIndex, timezone = "UTC") {
2423
2957
  const visibleBars = viewport.endIndex - viewport.startIndex;
2424
2958
  const firstBar = bars[0];
2425
2959
  const lastBar = bars[bars.length - 1];
2426
- const spansMultipleDays = !!firstBar && !!lastBar && new Date(firstBar.time).toLocaleDateString("en-US", { timeZone: "UTC" }) !== new Date(lastBar.time).toLocaleDateString("en-US", { timeZone: "UTC" });
2960
+ const spansMultipleDays = !!firstBar && !!lastBar && new Date(firstBar.time).toLocaleDateString("en-US", { timeZone: timezone }) !== new Date(lastBar.time).toLocaleDateString("en-US", { timeZone: timezone });
2427
2961
  ctx.fillStyle = theme.axisText;
2428
2962
  ctx.font = `${cfg.fontSize} ${cfg.fontFamily}`;
2429
2963
  ctx.textAlign = "center";
2430
2964
  const MIN_GAP = 12;
2431
- const sampleLabel = formatTimestamp(bars[Math.floor(bars.length / 2)]?.time ?? bars[0].time, visibleBars, timeframe, spansMultipleDays);
2965
+ const sampleLabel = formatTimestamp(bars[Math.floor(bars.length / 2)]?.time ?? bars[0].time, visibleBars, timeframe, spansMultipleDays, timezone);
2432
2966
  const labelW = ctx.measureText(sampleLabel).width;
2433
2967
  const minStep = Math.max(1, Math.floor(bars.length / maxLabels));
2434
2968
  let step = minStep;
@@ -2443,7 +2977,7 @@ function renderTimeAxis(ctx, bars, viewport, scale, theme, chartWidth, axisY, ma
2443
2977
  const barIndex = viewport.startIndex + i;
2444
2978
  if (countdownBarIndex !== void 0 && barIndex === countdownBarIndex) continue;
2445
2979
  const x = scale.xToPixel(barIndex, viewport, chartWidth);
2446
- const label = formatTimestamp(bars[i].time, visibleBars, timeframe, spansMultipleDays);
2980
+ const label = formatTimestamp(bars[i].time, visibleBars, timeframe, spansMultipleDays, timezone);
2447
2981
  const halfW = ctx.measureText(label).width / 2;
2448
2982
  const clampedX = Math.max(halfW, Math.min(chartWidth - halfW, x));
2449
2983
  ctx.fillText(label, clampedX, axisY + 14);
@@ -2478,10 +3012,20 @@ function renderCandleCountdown(ctx, lastBarTime, intervalMs, lastBarIndex, viewp
2478
3012
  }
2479
3013
 
2480
3014
  // src/axis/PriceAxis.ts
2481
- function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines = 6, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
3015
+ function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
2482
3016
  ctx.fillStyle = theme.axisText;
2483
3017
  ctx.font = `${cfg.fontSize} ${cfg.fontFamily}`;
2484
3018
  ctx.textAlign = "left";
3019
+ if (gridLines === void 0) {
3020
+ const ticks = generatePriceTicks(priceRange, chartHeight);
3021
+ const step2 = ticks.length >= 2 ? ticks[1] - ticks[0] : priceRange.max - priceRange.min;
3022
+ const precision = precisionForStep(step2, pricePrecision);
3023
+ for (const price of ticks) {
3024
+ const y = scale.yToPixel(price, priceRange, chartHeight);
3025
+ ctx.fillText(price.toFixed(precision), chartWidth + 4, y + 4);
3026
+ }
3027
+ return;
3028
+ }
2485
3029
  const step = (priceRange.max - priceRange.min) / gridLines;
2486
3030
  for (let i = 0; i <= gridLines; i++) {
2487
3031
  const price = priceRange.min + step * i;
@@ -3096,6 +3640,8 @@ var ICON_TOOL = {
3096
3640
  14
3097
3641
  )
3098
3642
  };
3643
+ 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>`;
3644
+ 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>`;
3099
3645
  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>`;
3100
3646
  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>`;
3101
3647
  var ICON_TAB = {
@@ -3131,7 +3677,7 @@ function injectFlyoutScrollbarStyles() {
3131
3677
  document.head.appendChild(style);
3132
3678
  }
3133
3679
  var TopBar = class {
3134
- constructor(onSeriesChange, onTimeframeChange, onAddIndicator, onDurationChange, onSettingsClick, onFullscreenToggle, onDrawingToolsToggle, showDrawingToolsBtn, showSettingsBtn, initialSeries, initialTf, initialDuration, durationMap, theme, cfg = DEFAULT_TOP_BAR_CONFIG, labels = DEFAULT_LABELS.topBar) {
3680
+ 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) {
3135
3681
  this.onSeriesChange = onSeriesChange;
3136
3682
  this.onTimeframeChange = onTimeframeChange;
3137
3683
  this.onAddIndicator = onAddIndicator;
@@ -3139,11 +3685,14 @@ var TopBar = class {
3139
3685
  this.onSettingsClick = onSettingsClick;
3140
3686
  this.onFullscreenToggle = onFullscreenToggle;
3141
3687
  this.onDrawingToolsToggle = onDrawingToolsToggle;
3688
+ this.onTfcToggle = onTfcToggle;
3142
3689
  this.settingsBtn = null;
3143
3690
  this.isFullscreen = false;
3144
3691
  this.durationBtns = /* @__PURE__ */ new Map();
3145
3692
  this.isMobileLayout = false;
3146
3693
  this.drawingBtn = null;
3694
+ this.tfcBtn = null;
3695
+ this.tfcActive = true;
3147
3696
  this.openDropdown = null;
3148
3697
  this.indicatorOptions = [];
3149
3698
  this.activeIndicators = /* @__PURE__ */ new Set();
@@ -3151,6 +3700,7 @@ var TopBar = class {
3151
3700
  this.theme = theme;
3152
3701
  this.cfg = cfg;
3153
3702
  this.labels = labels;
3703
+ this.tfcActive = initialTfcActive;
3154
3704
  this.currentSeries = initialSeries;
3155
3705
  this.currentTf = initialTf;
3156
3706
  this.activeDuration = initialDuration;
@@ -3227,6 +3777,21 @@ var TopBar = class {
3227
3777
  this.addBtnHover(this.settingsBtn);
3228
3778
  this.rightGroup.appendChild(this.settingsBtn);
3229
3779
  }
3780
+ if (showTfcBtn) {
3781
+ this.tfcBtn = document.createElement("button");
3782
+ this.tfcBtn.title = this.labels.tfcToggleTitle;
3783
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3784
+ this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3785
+ this.tfcBtn.addEventListener("click", (e) => {
3786
+ e.stopPropagation();
3787
+ this.tfcActive = !this.tfcActive;
3788
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3789
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, this.tfcActive);
3790
+ this.onTfcToggle?.(this.tfcActive);
3791
+ });
3792
+ this.addBtnHover(this.tfcBtn);
3793
+ this.rightGroup.appendChild(this.tfcBtn);
3794
+ }
3230
3795
  this.el.append(this.leftGroup, this.rightGroup);
3231
3796
  this.docClickHandler = (e) => {
3232
3797
  if (!this.el.contains(e.target) && !this.flyout.contains(e.target)) {
@@ -3256,6 +3821,15 @@ var TopBar = class {
3256
3821
  if (!this.drawingBtn) return;
3257
3822
  this.applyIconBtnStyles(this.drawingBtn, this.theme, active);
3258
3823
  }
3824
+ setTfcActive(active) {
3825
+ if (!this.tfcBtn) return;
3826
+ this.tfcActive = active;
3827
+ this.tfcBtn.innerHTML = active ? ICON_TFC : ICON_TFC_OFF;
3828
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, active);
3829
+ if (this.isMobileLayout) {
3830
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + 2);
3831
+ }
3832
+ }
3259
3833
  setTheme(theme) {
3260
3834
  this.theme = theme;
3261
3835
  this.closeDropdown();
@@ -3264,6 +3838,7 @@ var TopBar = class {
3264
3838
  if (this.drawingBtn) this.applyIconBtnStyles(this.drawingBtn, theme, false);
3265
3839
  this.applyIconBtnStyles(this.fullscreenBtn, theme, this.isFullscreen);
3266
3840
  if (this.settingsBtn) this.applyIconBtnStyles(this.settingsBtn, theme, false);
3841
+ if (this.tfcBtn) this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3267
3842
  this.applySeriesBtnStyles(this.seriesBtn, theme, false);
3268
3843
  this.applyDropBtnStyles(this.tfBtn, theme, false);
3269
3844
  this.applyDropBtnStyles(this.indBtn, theme, false);
@@ -3304,7 +3879,7 @@ var TopBar = class {
3304
3879
  this.durationSep.style.display = btnDisplay;
3305
3880
  this.durationDropBtn.style.display = isMobile ? "" : "none";
3306
3881
  const containerSize = isMobile ? this.cfg.mobileIconBtnSize : this.cfg.iconBtnSize;
3307
- for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn]) {
3882
+ for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn, this.tfcBtn]) {
3308
3883
  if (!btn) continue;
3309
3884
  btn.style.width = containerSize;
3310
3885
  btn.style.height = containerSize;
@@ -3314,6 +3889,9 @@ var TopBar = class {
3314
3889
  if (this.settingsBtn) {
3315
3890
  this.resizeSvg(this.settingsBtn, this.getSvgSize(this.settingsBtn) + delta);
3316
3891
  }
3892
+ if (this.tfcBtn) {
3893
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + delta);
3894
+ }
3317
3895
  if (this.drawingBtn) {
3318
3896
  const drawIconSize = isMobile ? parseInt(this.cfg.mobileDrawBtnIconSize, 10) : parseInt(this.cfg.drawBtnIconFontSize, 10);
3319
3897
  this.resizeSvg(this.drawingBtn, drawIconSize);
@@ -8142,7 +8720,7 @@ function pointToSegmentDist19(px, py, x1, y1, x2, y2) {
8142
8720
  const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
8143
8721
  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
8144
8722
  }
8145
- function roundedRect(ctx, x, y, w, h, r) {
8723
+ function roundedRect2(ctx, x, y, w, h, r) {
8146
8724
  ctx.beginPath();
8147
8725
  ctx.moveTo(x + r, y);
8148
8726
  ctx.lineTo(x + w - r, y);
@@ -8193,7 +8771,7 @@ var Callout = class {
8193
8771
  ctx.beginPath();
8194
8772
  ctx.arc(tipX, tipY, 3, 0, Math.PI * 2);
8195
8773
  ctx.fill();
8196
- roundedRect(ctx, bx, by, boxW, BOX_H, RADIUS);
8774
+ roundedRect2(ctx, bx, by, boxW, BOX_H, RADIUS);
8197
8775
  ctx.fillStyle = "#37474f";
8198
8776
  ctx.fill();
8199
8777
  ctx.strokeStyle = "#546e7a";
@@ -8245,7 +8823,7 @@ var Callout = class {
8245
8823
  // src/drawings/PriceLabel.ts
8246
8824
  var HIT_TOLERANCE42 = 6;
8247
8825
  var BADGE_W = 60;
8248
- var BADGE_H = 18;
8826
+ var BADGE_H2 = 18;
8249
8827
  var ARROW_W = 7;
8250
8828
  var PriceLabel = class {
8251
8829
  constructor(point) {
@@ -8262,21 +8840,21 @@ var PriceLabel = class {
8262
8840
  ctx.fillStyle = "#26a69a";
8263
8841
  ctx.beginPath();
8264
8842
  ctx.moveTo(x, y);
8265
- ctx.lineTo(x + ARROW_W, y - BADGE_H / 2);
8266
- ctx.lineTo(x + ARROW_W, y + BADGE_H / 2);
8843
+ ctx.lineTo(x + ARROW_W, y - BADGE_H2 / 2);
8844
+ ctx.lineTo(x + ARROW_W, y + BADGE_H2 / 2);
8267
8845
  ctx.closePath();
8268
8846
  ctx.fill();
8269
8847
  const bx = x + ARROW_W;
8270
- const by = y - BADGE_H / 2;
8848
+ const by = y - BADGE_H2 / 2;
8271
8849
  ctx.fillStyle = "#26a69a";
8272
8850
  ctx.beginPath();
8273
- ctx.roundRect(bx, by, BADGE_W, BADGE_H, BADGE_H / 2);
8851
+ ctx.roundRect(bx, by, BADGE_W, BADGE_H2, BADGE_H2 / 2);
8274
8852
  ctx.fill();
8275
8853
  if (isSelected) {
8276
8854
  ctx.strokeStyle = "#80cbc4";
8277
8855
  ctx.lineWidth = 1.5;
8278
8856
  ctx.beginPath();
8279
- ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H + 4, BADGE_H / 2 + 2);
8857
+ ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H2 + 4, BADGE_H2 / 2 + 2);
8280
8858
  ctx.stroke();
8281
8859
  }
8282
8860
  ctx.font = "11px 'Inter', system-ui, sans-serif";
@@ -8290,8 +8868,8 @@ var PriceLabel = class {
8290
8868
  const ax = scale.xToPixel(this.points[0].barIndex, viewport, chartW);
8291
8869
  const ay = scale.yToPixel(this.points[0].price, priceRange, chartH);
8292
8870
  const bx = ax + ARROW_W;
8293
- const by = ay - BADGE_H / 2;
8294
- return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H + HIT_TOLERANCE42;
8871
+ const by = ay - BADGE_H2 / 2;
8872
+ return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H2 + HIT_TOLERANCE42;
8295
8873
  }
8296
8874
  getHandle() {
8297
8875
  return null;
@@ -8411,7 +8989,7 @@ var Flag = class {
8411
8989
 
8412
8990
  // src/drawings/PriceNote.ts
8413
8991
  var HIT_TOLERANCE44 = 6;
8414
- var BADGE_H2 = 16;
8992
+ var BADGE_H3 = 16;
8415
8993
  var BADGE_PADDING_X = 6;
8416
8994
  var TICK_HALF = 10;
8417
8995
  var PriceNote = class {
@@ -8437,10 +9015,10 @@ var PriceNote = class {
8437
9015
  const tw = ctx.measureText(label).width;
8438
9016
  const badgeW = tw + BADGE_PADDING_X * 2;
8439
9017
  const bx = x + TICK_HALF + 2;
8440
- const by = y - BADGE_H2 / 2;
9018
+ const by = y - BADGE_H3 / 2;
8441
9019
  ctx.fillStyle = "#ffa726";
8442
9020
  ctx.beginPath();
8443
- ctx.roundRect(bx, by, badgeW, BADGE_H2, 3);
9021
+ ctx.roundRect(bx, by, badgeW, BADGE_H3, 3);
8444
9022
  ctx.fill();
8445
9023
  ctx.fillStyle = "#ffffff";
8446
9024
  ctx.textBaseline = "middle";
@@ -8460,7 +9038,7 @@ var PriceNote = class {
8460
9038
 
8461
9039
  // src/drawings/AnchoredNote.ts
8462
9040
  var HIT_TOLERANCE45 = 6;
8463
- var BADGE_H3 = 20;
9041
+ var BADGE_H4 = 20;
8464
9042
  var PAD_H2 = 10;
8465
9043
  function pointToSegmentDist21(px, py, x1, y1, x2, y2) {
8466
9044
  const dx = x2 - x1, dy = y2 - y1;
@@ -8493,7 +9071,7 @@ var AnchoredNote = class {
8493
9071
  ctx.lineWidth = isSelected ? 1.5 : 1;
8494
9072
  const badgeW = this.getBadgeW(ctx);
8495
9073
  const bx = x2 - badgeW / 2;
8496
- const by = y2 - BADGE_H3 / 2;
9074
+ const by = y2 - BADGE_H4 / 2;
8497
9075
  ctx.strokeStyle = "#42a5f5";
8498
9076
  ctx.setLineDash([4, 3]);
8499
9077
  ctx.beginPath();
@@ -8507,12 +9085,12 @@ var AnchoredNote = class {
8507
9085
  ctx.fill();
8508
9086
  ctx.fillStyle = "#37474f";
8509
9087
  ctx.beginPath();
8510
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
9088
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8511
9089
  ctx.fill();
8512
9090
  ctx.strokeStyle = "#42a5f5";
8513
9091
  ctx.lineWidth = isSelected ? 1.5 : 1;
8514
9092
  ctx.beginPath();
8515
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
9093
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8516
9094
  ctx.stroke();
8517
9095
  const label = this.text || "Note";
8518
9096
  const isPlaceholder = !this.text;
@@ -8531,8 +9109,8 @@ var AnchoredNote = class {
8531
9109
  const y2 = scale.yToPixel(p2.price, priceRange, chartH);
8532
9110
  const approxBadgeW = Math.max(50, (this.text?.length ?? 4) * 7 + 20);
8533
9111
  const bx = x2 - approxBadgeW / 2;
8534
- const by = y2 - BADGE_H3 / 2;
8535
- if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H3 + HIT_TOLERANCE45) {
9112
+ const by = y2 - BADGE_H4 / 2;
9113
+ if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H4 + HIT_TOLERANCE45) {
8536
9114
  return true;
8537
9115
  }
8538
9116
  if (pointToSegmentDist21(x, y, x1, y1, x2, y2) <= HIT_TOLERANCE45) {
@@ -12483,6 +13061,158 @@ var TradePopover = class {
12483
13061
  }
12484
13062
  };
12485
13063
 
13064
+ // src/ui/DraftQtyFlyout.ts
13065
+ var DraftQtyFlyout = class {
13066
+ constructor(theme, onCommit) {
13067
+ this._min = 1;
13068
+ this._max = 100;
13069
+ this._step = 1;
13070
+ this.onDocMouseDown = (e) => {
13071
+ if (this.isVisible && !this.el.contains(e.target)) {
13072
+ this.syncIfValid();
13073
+ this.hide();
13074
+ }
13075
+ };
13076
+ this.theme = theme;
13077
+ this.onCommit = onCommit;
13078
+ this.el = document.createElement("div");
13079
+ Object.assign(this.el.style, {
13080
+ position: "fixed",
13081
+ display: "none",
13082
+ flexDirection: "row",
13083
+ alignItems: "center",
13084
+ gap: "4px",
13085
+ background: theme.tooltip.background,
13086
+ border: `1px solid ${theme.axisBorder}`,
13087
+ borderRadius: "5px",
13088
+ padding: "5px 7px",
13089
+ zIndex: "25",
13090
+ boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
13091
+ fontFamily: "'Inter', system-ui, sans-serif",
13092
+ fontSize: "13px",
13093
+ color: theme.tooltip.text,
13094
+ pointerEvents: "auto"
13095
+ });
13096
+ const decBtn = this.makeStepBtn("\u2212", () => this.step(-1));
13097
+ this.input = this.makeInput();
13098
+ const incBtn = this.makeStepBtn("+", () => this.step(1));
13099
+ this.el.append(decBtn, this.input, incBtn);
13100
+ this.el.addEventListener("mousedown", (e) => e.stopPropagation());
13101
+ this.el.addEventListener("touchstart", (e) => e.stopPropagation());
13102
+ document.addEventListener("mousedown", this.onDocMouseDown);
13103
+ }
13104
+ // ── Public ────────────────────────────────────────────────────────────────
13105
+ show(pillX, boxBottom, currentQty, min, max) {
13106
+ this._min = min;
13107
+ this._max = max;
13108
+ this._step = min;
13109
+ this.input.min = String(min);
13110
+ this.input.max = String(max);
13111
+ this.input.step = String(min);
13112
+ this.input.value = String(currentQty);
13113
+ this.el.style.display = "flex";
13114
+ this.el.style.left = `${pillX}px`;
13115
+ this.el.style.top = `${boxBottom + 4}px`;
13116
+ requestAnimationFrame(() => {
13117
+ this.input.focus();
13118
+ this.input.select();
13119
+ });
13120
+ }
13121
+ hide() {
13122
+ this.el.style.display = "none";
13123
+ }
13124
+ get isVisible() {
13125
+ return this.el.style.display !== "none";
13126
+ }
13127
+ setTheme(theme) {
13128
+ this.theme = theme;
13129
+ Object.assign(this.el.style, {
13130
+ background: theme.tooltip.background,
13131
+ border: `1px solid ${theme.axisBorder}`,
13132
+ color: theme.tooltip.text
13133
+ });
13134
+ this.input.style.borderBottom = `1px solid ${theme.axisBorder}`;
13135
+ }
13136
+ destroy() {
13137
+ document.removeEventListener("mousedown", this.onDocMouseDown);
13138
+ }
13139
+ // ── Private ───────────────────────────────────────────────────────────────
13140
+ makeStepBtn(label, onClick) {
13141
+ const btn = document.createElement("button");
13142
+ btn.textContent = label;
13143
+ Object.assign(btn.style, {
13144
+ background: "transparent",
13145
+ border: "none",
13146
+ color: "inherit",
13147
+ cursor: "pointer",
13148
+ fontSize: "16px",
13149
+ lineHeight: "1",
13150
+ padding: "0 4px",
13151
+ borderRadius: "3px",
13152
+ opacity: "0.7"
13153
+ });
13154
+ btn.addEventListener("mouseenter", () => {
13155
+ btn.style.opacity = "1";
13156
+ });
13157
+ btn.addEventListener("mouseleave", () => {
13158
+ btn.style.opacity = "0.7";
13159
+ });
13160
+ btn.addEventListener("mousedown", (e) => {
13161
+ e.preventDefault();
13162
+ onClick();
13163
+ });
13164
+ return btn;
13165
+ }
13166
+ makeInput() {
13167
+ const input = document.createElement("input");
13168
+ input.type = "number";
13169
+ Object.assign(input.style, {
13170
+ width: "64px",
13171
+ background: "transparent",
13172
+ border: "none",
13173
+ borderBottom: `1px solid ${this.theme.axisBorder}`,
13174
+ color: "inherit",
13175
+ font: "13px 'Inter', system-ui, sans-serif",
13176
+ outline: "none",
13177
+ textAlign: "center",
13178
+ padding: "1px 2px"
13179
+ });
13180
+ input.addEventListener("input", () => {
13181
+ this.syncIfValid();
13182
+ });
13183
+ input.addEventListener("keydown", (e) => {
13184
+ if (e.key === "Enter") {
13185
+ e.preventDefault();
13186
+ this.syncIfValid();
13187
+ this.hide();
13188
+ } else if (e.key === "Escape") {
13189
+ e.preventDefault();
13190
+ this.hide();
13191
+ }
13192
+ });
13193
+ input.addEventListener("blur", () => {
13194
+ if (this.isVisible) {
13195
+ this.syncIfValid();
13196
+ this.hide();
13197
+ }
13198
+ });
13199
+ return input;
13200
+ }
13201
+ step(direction) {
13202
+ const current = parseFloat(this.input.value) || this._min;
13203
+ const next = Math.round((current + direction * this._step) * 1e10) / 1e10;
13204
+ this.input.value = String(Math.min(this._max, Math.max(this._min, next)));
13205
+ this.input.focus();
13206
+ this.syncIfValid();
13207
+ }
13208
+ syncIfValid() {
13209
+ const val = parseFloat(this.input.value);
13210
+ if (!isNaN(val) && val >= this._min && val <= this._max) {
13211
+ this.onCommit(val);
13212
+ }
13213
+ }
13214
+ };
13215
+
12486
13216
  // src/ui/IsinPickerModal.ts
12487
13217
  var INITIAL_LIMIT = 50;
12488
13218
  var SEARCH_LIMIT = 20;
@@ -12693,9 +13423,14 @@ var TradeDragHandler = class _TradeDragHandler {
12693
13423
  this._isDraggingBracket = false;
12694
13424
  // Main line drag state
12695
13425
  this._level = null;
12696
- this._startY = 0;
12697
13426
  this._startPrice = 0;
12698
13427
  this._currentPrice = 0;
13428
+ // Pixel offset between click Y and the level's rendered Y at drag start.
13429
+ // Why: the drag hit-strip is 8 px tall; without this, the cursor-to-price
13430
+ // mapping would snap the level to wherever the cursor is instead of
13431
+ // preserving the grab point. Also immunises the drag from any priceRange
13432
+ // animation (lerp) that happens between drag-start and first mousemove.
13433
+ this._pixelOffset = 0;
12699
13434
  // Entry-price constraint bounds (applied when SL/TP exist on the level)
12700
13435
  this._mainMinPrice = -Infinity;
12701
13436
  this._mainMaxPrice = Infinity;
@@ -12703,6 +13438,7 @@ var TradeDragHandler = class _TradeDragHandler {
12703
13438
  this._bracketType = null;
12704
13439
  this._bracketStartPrice = 0;
12705
13440
  this._bracketCurrentPrice = 0;
13441
+ this._bracketPixelOffset = 0;
12706
13442
  // Bracket constraint state (populated on startBracketDrag)
12707
13443
  this._bracketParentPrice = 0;
12708
13444
  this._bracketParentSide = "buy";
@@ -12714,11 +13450,11 @@ var TradeDragHandler = class _TradeDragHandler {
12714
13450
  * Pass `slPrice` / `tpPrice` / `side` to constrain the drag between SL and TP
12715
13451
  * (used for ES/EL entry orders where the entry price must stay between brackets).
12716
13452
  */
12717
- startDrag(level, startY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
13453
+ startDrag(level, clickY, levelPixelY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
12718
13454
  this._active = true;
12719
13455
  this._isDraggingBracket = false;
12720
13456
  this._level = level;
12721
- this._startY = startY;
13457
+ this._pixelOffset = clickY - levelPixelY;
12722
13458
  this._startPrice = startPrice;
12723
13459
  this._currentPrice = startPrice;
12724
13460
  this._bracketType = null;
@@ -12750,13 +13486,14 @@ var TradeDragHandler = class _TradeDragHandler {
12750
13486
  * @param bracketType 'stopLoss' | 'takeProfit'
12751
13487
  * @param startPrice Current bracket price
12752
13488
  */
12753
- startBracketDrag(parentLevel, bracketType, startPrice, currentMarketPrice = 0, parentPriceOverride) {
13489
+ startBracketDrag(parentLevel, bracketType, startPrice, clickY, bracketPixelY, currentMarketPrice = 0, parentPriceOverride) {
12754
13490
  this._active = true;
12755
13491
  this._isDraggingBracket = true;
12756
13492
  this._level = parentLevel;
12757
13493
  this._bracketType = bracketType;
12758
13494
  this._bracketStartPrice = startPrice;
12759
13495
  this._bracketCurrentPrice = startPrice;
13496
+ this._bracketPixelOffset = clickY - bracketPixelY;
12760
13497
  this._bracketParentPrice = parentPriceOverride ?? parentLevel.price;
12761
13498
  this._bracketParentSide = parentLevel.side ?? "buy";
12762
13499
  this._bracketMarketPrice = currentMarketPrice;
@@ -12767,9 +13504,10 @@ var TradeDragHandler = class _TradeDragHandler {
12767
13504
  */
12768
13505
  onMouseMove(currentY, scale, priceRange, priceH) {
12769
13506
  if (!this._active || !this._level) return null;
12770
- const rawPrice = scale.pixelToPrice(currentY, priceRange, priceH);
12771
13507
  if (this._isDraggingBracket && this._bracketType !== null) {
12772
- const clampedPrice = this._clampBracketPrice(rawPrice, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
13508
+ const effectiveY2 = currentY - this._bracketPixelOffset;
13509
+ const rawPrice2 = scale.pixelToPrice(effectiveY2, priceRange, priceH);
13510
+ const clampedPrice = this._clampBracketPrice(rawPrice2, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
12773
13511
  this._bracketCurrentPrice = clampedPrice;
12774
13512
  return {
12775
13513
  newPrice: clampedPrice,
@@ -12777,6 +13515,8 @@ var TradeDragHandler = class _TradeDragHandler {
12777
13515
  isBracket: true
12778
13516
  };
12779
13517
  }
13518
+ const effectiveY = currentY - this._pixelOffset;
13519
+ const rawPrice = scale.pixelToPrice(effectiveY, priceRange, priceH);
12780
13520
  const constrained = Math.max(this._mainMinPrice, Math.min(this._mainMaxPrice, rawPrice));
12781
13521
  this._currentPrice = constrained;
12782
13522
  return {
@@ -12875,10 +13615,12 @@ var TradeDragHandler = class _TradeDragHandler {
12875
13615
  this._level = null;
12876
13616
  this._bracketType = null;
12877
13617
  this._currentPrice = 0;
13618
+ this._pixelOffset = 0;
12878
13619
  this._mainMinPrice = -Infinity;
12879
13620
  this._mainMaxPrice = Infinity;
12880
13621
  this._bracketStartPrice = 0;
12881
13622
  this._bracketCurrentPrice = 0;
13623
+ this._bracketPixelOffset = 0;
12882
13624
  this._bracketParentPrice = 0;
12883
13625
  this._bracketParentSide = "buy";
12884
13626
  this._bracketMarketPrice = 0;
@@ -16405,6 +17147,11 @@ var _ChartEngine = class _ChartEngine {
16405
17147
  this._hoverBuyType = "limit";
16406
17148
  this._hoverSellType = "limit";
16407
17149
  this._minLots = 1;
17150
+ this._showQuantityField = false;
17151
+ this._qtyFieldMin = 1;
17152
+ this._qtyFieldMax = 100;
17153
+ this._draftQtyFlyout = null;
17154
+ this._draftQtyPillArea = void 0;
16408
17155
  this._onOrderSubmit = void 0;
16409
17156
  this.scrollToEndBtn = null;
16410
17157
  this.scrollToEndVisible = false;
@@ -16420,6 +17167,8 @@ var _ChartEngine = class _ChartEngine {
16420
17167
  this.showBidAskLines = false;
16421
17168
  this.showActLogo = false;
16422
17169
  this.hideLevelConfirmCancel = false;
17170
+ this.levelClusteringEnabled = true;
17171
+ this.clusterThresholdDistance = 20;
16423
17172
  this.enableThemeToggle = false;
16424
17173
  this.activeIndicators = [];
16425
17174
  this.indicatorResults = /* @__PURE__ */ new Map();
@@ -16485,19 +17234,28 @@ var _ChartEngine = class _ChartEngine {
16485
17234
  this._mobileDrawingFlyout = null;
16486
17235
  // Fullscreen state
16487
17236
  this._isCssFullscreen = false;
17237
+ this.timezone = "UTC";
16488
17238
  this.settingsCanvasColors = {};
16489
17239
  // Streaming
16490
17240
  this.streamAdapter = null;
16491
17241
  this.liveBidAsk = null;
16492
17242
  this.tickClosePriceSource = "bid";
17243
+ /** True while an async data fetch is in-flight; suppresses tick processing to
17244
+ * prevent stale ticks from corrupting newly-loaded bars. */
17245
+ this._suppressTicks = false;
16493
17246
  // Trade / position levels
16494
17247
  this.levels = [];
16495
17248
  this.tradeLevelHitAreas = [];
17249
+ this.offViewportHitAreas = [];
16496
17250
  this.tradeLevelEditAreas = [];
16497
17251
  this.tradeHoverAreas = [];
16498
17252
  this.tradeLevelDragAreas = [];
16499
17253
  this.tradeLevelConfirmAreas = [];
16500
17254
  this.tradeAddBracketAreas = [];
17255
+ this.clusterBadgeAreas = [];
17256
+ this.lastClusters = [];
17257
+ /** Set of cluster IDs that are currently expanded (fanned out). */
17258
+ this.expandedClusters = /* @__PURE__ */ new Set();
16501
17259
  this.hoveredTradeLabel = null;
16502
17260
  this.selectedTradeLabel = null;
16503
17261
  /** Estimated PNL text for draft order SL/TP bracket lines, set by setDraftBracketPnl(). */
@@ -16512,6 +17270,8 @@ var _ChartEngine = class _ChartEngine {
16512
17270
  // Inline drag confirmation — accumulates changes per level until user confirms
16513
17271
  this.pendingLevelDrags = /* @__PURE__ */ new Map();
16514
17272
  // Trade from Charts settings
17273
+ this.tfcEnabled = true;
17274
+ this.tfcActive = true;
16515
17275
  this.tradeDisplayFilter = "all";
16516
17276
  this.positionRenderStyle = "dot";
16517
17277
  this.tradesThresholdForHorizontalLine = 2;
@@ -16522,7 +17282,9 @@ var _ChartEngine = class _ChartEngine {
16522
17282
  this.dataLoader = null;
16523
17283
  this.isFetching = false;
16524
17284
  this.noMoreData = false;
17285
+ this._loadGeneration = 0;
16525
17286
  this.maxCandles = 200;
17287
+ this.prefetchThreshold = 80;
16526
17288
  this.defaultVisibleBars = DEFAULT_VISIBLE_BARS;
16527
17289
  this.defaultMobileVisibleBars = void 0;
16528
17290
  this.mobileBarDivisor = 2;
@@ -16560,7 +17322,12 @@ var _ChartEngine = class _ChartEngine {
16560
17322
  if (this.tradeButton) {
16561
17323
  if (isInChartArea && y >= 0 && y <= priceH && !this._isTradingActive && !hoveringDotLevel) {
16562
17324
  this.tradeButton.setY(y);
16563
- this.tradeButton.show();
17325
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
17326
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
17327
+ this.tradeButton.hide();
17328
+ } else if (this.tfcActive) {
17329
+ this.tradeButton.show();
17330
+ }
16564
17331
  this._tradeButtonClickData = { price, y };
16565
17332
  const allBars = this.dataStore.all;
16566
17333
  const lastClose = allBars.length > 0 ? allBars[allBars.length - 1].close : price;
@@ -16662,7 +17429,7 @@ var _ChartEngine = class _ChartEngine {
16662
17429
  return;
16663
17430
  }
16664
17431
  if (!this.drawingManager.getActiveTool()) {
16665
- if (hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas) || hitTestTradeLevelEdit(x, y, this.tradeLevelEditAreas) || hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas)) {
17432
+ 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)) {
16666
17433
  this.drawCanvas.style.cursor = "pointer";
16667
17434
  this.scheduleRender();
16668
17435
  return;
@@ -16874,6 +17641,25 @@ var _ChartEngine = class _ChartEngine {
16874
17641
  this.scheduleRender();
16875
17642
  return;
16876
17643
  }
17644
+ const clusterHitTouch = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
17645
+ if (clusterHitTouch) {
17646
+ e.preventDefault();
17647
+ this.panHandler.cancel();
17648
+ if (this.expandedClusters.has(clusterHitTouch.clusterId)) {
17649
+ this.expandedClusters.delete(clusterHitTouch.clusterId);
17650
+ } else {
17651
+ this.expandedClusters.add(clusterHitTouch.clusterId);
17652
+ }
17653
+ this.renderTradeLayer();
17654
+ return;
17655
+ }
17656
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
17657
+ if (offVpHit) {
17658
+ e.preventDefault();
17659
+ this.panHandler.cancel();
17660
+ this.scrollToCenterPrice(offVpHit.targetPrice);
17661
+ return;
17662
+ }
16877
17663
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
16878
17664
  if (confirmHit) {
16879
17665
  e.preventDefault();
@@ -16881,6 +17667,15 @@ var _ChartEngine = class _ChartEngine {
16881
17667
  else this.revertPendingChanges(confirmHit.label);
16882
17668
  return;
16883
17669
  }
17670
+ if (this._showQuantityField && this._draftQtyPillArea) {
17671
+ const p = this._draftQtyPillArea;
17672
+ if (x >= p.pillX && x <= p.pillX + p.pillW && y >= p.pillY && y <= p.pillY + p.pillH) {
17673
+ e.preventDefault();
17674
+ e.stopPropagation();
17675
+ this.openDraftQtyFlyout(p);
17676
+ return;
17677
+ }
17678
+ }
16884
17679
  const closeHit = hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas);
16885
17680
  if (closeHit) {
16886
17681
  e.preventDefault();
@@ -16930,6 +17725,10 @@ var _ChartEngine = class _ChartEngine {
16930
17725
  }
16931
17726
  this.drawingManager.selectDrawing(null);
16932
17727
  this.hideMobileDrawingFlyout();
17728
+ if (this.expandedClusters.size > 0) {
17729
+ this.expandedClusters.clear();
17730
+ this.renderTradeLayer();
17731
+ }
16933
17732
  const isInChartArea = x < chartW && y < timeAxisY;
16934
17733
  if (isInChartArea) {
16935
17734
  if (this._mobileCrosshairActive) {
@@ -17079,7 +17878,12 @@ var _ChartEngine = class _ChartEngine {
17079
17878
  if (this._isTradingActive) {
17080
17879
  this.tradeButton.hide();
17081
17880
  } else {
17082
- this.tradeButton.show();
17881
+ const _btnHalfH2 = this.uiConfig.tradeButton.size / 2;
17882
+ if (overlapsOffViewportPill(y, _btnHalfH2, this.offViewportHitAreas)) {
17883
+ this.tradeButton.hide();
17884
+ } else if (this.tfcActive) {
17885
+ this.tradeButton.show();
17886
+ }
17083
17887
  }
17084
17888
  this._tradeButtonClickData = { price, y };
17085
17889
  const _allBars = this.dataStore.all;
@@ -17161,12 +17965,36 @@ var _ChartEngine = class _ChartEngine {
17161
17965
  const rawBarIndex = this.scaleManager.pixelToBarIndex(x, this.viewport, chartW);
17162
17966
  const price = this.scaleManager.pixelToPrice(y, priceRange, priceH);
17163
17967
  const barIndex = Math.round(rawBarIndex);
17968
+ const clusterHit = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
17969
+ if (clusterHit) {
17970
+ if (this.expandedClusters.has(clusterHit.clusterId)) {
17971
+ this.expandedClusters.delete(clusterHit.clusterId);
17972
+ } else {
17973
+ this.expandedClusters.add(clusterHit.clusterId);
17974
+ }
17975
+ this.renderTradeLayer();
17976
+ return;
17977
+ }
17978
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
17979
+ if (offVpHit) {
17980
+ this.scrollToCenterPrice(offVpHit.targetPrice);
17981
+ return;
17982
+ }
17164
17983
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
17165
17984
  if (confirmHit) {
17166
17985
  if (confirmHit.action === "confirm") this.applyPendingChanges(confirmHit.label);
17167
17986
  else this.revertPendingChanges(confirmHit.label);
17168
17987
  return;
17169
17988
  }
17989
+ if (this._showQuantityField && this._draftQtyPillArea) {
17990
+ const p = this._draftQtyPillArea;
17991
+ if (x >= p.pillX && x <= p.pillX + p.pillW && y >= p.pillY && y <= p.pillY + p.pillH) {
17992
+ e.preventDefault();
17993
+ e.stopPropagation();
17994
+ this.openDraftQtyFlyout(p);
17995
+ return;
17996
+ }
17997
+ }
17170
17998
  const closedLevel = hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas);
17171
17999
  if (closedLevel) {
17172
18000
  this._handleTradeLevelClose(closedLevel);
@@ -17245,6 +18073,10 @@ var _ChartEngine = class _ChartEngine {
17245
18073
  this.renderTradeLayer();
17246
18074
  }
17247
18075
  }
18076
+ if (this.expandedClusters.size > 0) {
18077
+ this.expandedClusters.clear();
18078
+ this.renderTradeLayer();
18079
+ }
17248
18080
  const timeAxisY = rect.height - this.timeAxisHeight;
17249
18081
  if (x >= chartW) {
17250
18082
  if (y <= priceH) {
@@ -17360,6 +18192,12 @@ var _ChartEngine = class _ChartEngine {
17360
18192
  this.scheduleRender();
17361
18193
  };
17362
18194
  this.onKeyDown = (e) => {
18195
+ if (e.key === "Escape" && this.expandedClusters.size > 0) {
18196
+ this.expandedClusters.clear();
18197
+ this.renderTradeLayer();
18198
+ e.preventDefault();
18199
+ return;
18200
+ }
17363
18201
  if (e.key === "Escape" && this.pendingLevelDrags.size > 0) {
17364
18202
  this.revertPendingChanges();
17365
18203
  e.preventDefault();
@@ -17379,6 +18217,7 @@ var _ChartEngine = class _ChartEngine {
17379
18217
  this.onTick = (tick) => {
17380
18218
  const all = this.dataStore.all;
17381
18219
  if (all.length === 0) return;
18220
+ if (this._suppressTicks) return;
17382
18221
  this.liveBidAsk = { bid: tick.bid, ask: tick.ask };
17383
18222
  this.setDotTickActive();
17384
18223
  const last = all[all.length - 1];
@@ -17446,7 +18285,9 @@ var _ChartEngine = class _ChartEngine {
17446
18285
  durationTimeframeMap,
17447
18286
  uiConfig,
17448
18287
  labels,
17449
- tickActivityMs = 3e4
18288
+ tickActivityMs = 3e4,
18289
+ hideSymbolAndTick = false,
18290
+ showBottomBar = false
17450
18291
  } = config;
17451
18292
  this.dataLoader = dataLoader ?? null;
17452
18293
  this.drawingToolsEnabled = config.showDrawingTools !== false;
@@ -17455,6 +18296,7 @@ var _ChartEngine = class _ChartEngine {
17455
18296
  this.labels = resolveLabels(labels);
17456
18297
  this.tickActivityMs = tickActivityMs;
17457
18298
  this.maxCandles = config.maxCandles ?? 200;
18299
+ this.prefetchThreshold = config.prefetchThreshold ?? 80;
17458
18300
  this.defaultVisibleBars = config.defaultVisibleBars ?? DEFAULT_VISIBLE_BARS;
17459
18301
  this.defaultMobileVisibleBars = config.defaultMobileVisibleBars;
17460
18302
  this.explicitMobileDivisor = config.mobileBarDivisor !== void 0;
@@ -17494,7 +18336,11 @@ var _ChartEngine = class _ChartEngine {
17494
18336
  this.showBidAskLines = config.showBidAskLines ?? false;
17495
18337
  this.showActLogo = config.showActLogo ?? false;
17496
18338
  this.hideLevelConfirmCancel = config.hideLevelConfirmCancel ?? false;
18339
+ this.levelClusteringEnabled = config.levelClusteringEnabled ?? true;
18340
+ this.clusterThresholdDistance = config.clusterThresholdDistance ?? 20;
17497
18341
  this.enableThemeToggle = config.enableThemeToggle ?? false;
18342
+ this.tfcEnabled = config.tfcEnabled !== false;
18343
+ this.tfcActive = this.tfcEnabled;
17498
18344
  this.showCandleCountdown = config.showCandleCountdown ?? true;
17499
18345
  this.candleCountdownTimeframes = config.candleCountdownTimeframes;
17500
18346
  this._onOrderSubmit = onOrderSubmit;
@@ -17502,6 +18348,11 @@ var _ChartEngine = class _ChartEngine {
17502
18348
  console.warn("[acttrader-charts] ChartConfig.minLots is deprecated. DraftOrderQtyInput has been removed. The value is stored and used as the submitted lot size.");
17503
18349
  this._minLots = config.minLots;
17504
18350
  }
18351
+ if (config.showQuantityField !== void 0) this._showQuantityField = config.showQuantityField;
18352
+ if (config.quantityFieldConfig) {
18353
+ if (config.quantityFieldConfig.minLots !== void 0) this._qtyFieldMin = config.quantityFieldConfig.minLots;
18354
+ if (config.quantityFieldConfig.maxLots !== void 0) this._qtyFieldMax = config.quantityFieldConfig.maxLots;
18355
+ }
17505
18356
  if (config.onLotsChange !== void 0) {
17506
18357
  console.warn("[acttrader-charts] ChartConfig.onLotsChange is deprecated. DraftOrderQtyInput has been removed; this callback will never fire.");
17507
18358
  }
@@ -17510,6 +18361,7 @@ var _ChartEngine = class _ChartEngine {
17510
18361
  }
17511
18362
  this.themeName = theme === "dark" ? "dark" : "light";
17512
18363
  this.themeOverrides = config.themeOverrides;
18364
+ this.timezone = config.timezone === "local" ? Intl.DateTimeFormat().resolvedOptions().timeZone : config.timezone ?? "UTC";
17513
18365
  this.aggregateFrom = config.aggregateFrom;
17514
18366
  const _baseTheme = this.themeName === "dark" ? DARK_THEME : LIGHT_THEME;
17515
18367
  this.theme = deepMergeTheme(_baseTheme, this.themeOverrides?.[this.themeName]);
@@ -17558,6 +18410,11 @@ var _ChartEngine = class _ChartEngine {
17558
18410
  this.drawingToolsEnabled ? () => this.toggleDrawingTools() : null,
17559
18411
  this.drawingToolsEnabled,
17560
18412
  this.settingsEnabled,
18413
+ this.tfcEnabled,
18414
+ this.tfcActive,
18415
+ this.tfcEnabled ? (enabled) => {
18416
+ this.setTfcActive(enabled);
18417
+ } : null,
17561
18418
  this.series,
17562
18419
  this.timeframe,
17563
18420
  null,
@@ -17709,7 +18566,7 @@ var _ChartEngine = class _ChartEngine {
17709
18566
  this.loadingOverlayEl.appendChild(loadingLabel);
17710
18567
  this.canvasWrap.appendChild(this.loadingOverlayEl);
17711
18568
  }
17712
- if (showUI) {
18569
+ if (showUI && !hideSymbolAndTick) {
17713
18570
  this.siEl = document.createElement("div");
17714
18571
  Object.assign(this.siEl.style, {
17715
18572
  position: "absolute",
@@ -17853,6 +18710,11 @@ var _ChartEngine = class _ChartEngine {
17853
18710
  const rect = this._cachedDrawRect ?? this.drawCanvas.getBoundingClientRect();
17854
18711
  const y = e.clientY - rect.top;
17855
18712
  const { priceH } = this.getChartLayout();
18713
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
18714
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
18715
+ this.tradeButton?.hide();
18716
+ return;
18717
+ }
17856
18718
  if (y >= 0 && y <= priceH) {
17857
18719
  this.tradeButton?.setY(y);
17858
18720
  const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
@@ -17867,7 +18729,7 @@ var _ChartEngine = class _ChartEngine {
17867
18729
  }
17868
18730
  middleRow.appendChild(this.canvasWrap);
17869
18731
  this.wrapper.appendChild(middleRow);
17870
- if (showUI) {
18732
+ if (showUI && showBottomBar) {
17871
18733
  const resolvedDurationMap = { ...DURATION_TIMEFRAME_MAP, ...durationTimeframeMap };
17872
18734
  this.bottomBar = new BottomBar(
17873
18735
  (d, tf) => {
@@ -17942,6 +18804,12 @@ var _ChartEngine = class _ChartEngine {
17942
18804
  },
17943
18805
  () => {
17944
18806
  this._isPanning = false;
18807
+ },
18808
+ {
18809
+ enabled: config.momentumScrollEnabled,
18810
+ decay: config.momentumDecay,
18811
+ threshold: config.momentumThreshold,
18812
+ maxVelocity: config.momentumMaxVelocity
17945
18813
  }
17946
18814
  );
17947
18815
  this.zoomHandler = new ZoomHandler(
@@ -18017,9 +18885,31 @@ var _ChartEngine = class _ChartEngine {
18017
18885
  return (this.tradePopover?.isVisible ?? false) || this.draftOrderLabel !== null || (this.tradeDragHandler?.active ?? false) || this.pendingLevelDrags.size > 0;
18018
18886
  }
18019
18887
  // ── Public API ────────────────────────────────────────────────────────────
18888
+ /**
18889
+ * Completely resets the chart to a clean, empty state.
18890
+ *
18891
+ * Cancels any in-flight data fetch, clears all bars, resets the price/time
18892
+ * axes, and discards the live bid/ask price line. The chart will render as
18893
+ * blank until the next `loadData()` call.
18894
+ *
18895
+ * Call this before switching to a new symbol so that no previous symbol's
18896
+ * data, price lines, or stale ticks can bleed into the new chart:
18897
+ * ```
18898
+ * chart.setSymbol('GBPUSD').resetData();
18899
+ * // … fetch new bars, then:
18900
+ * chart.loadData(newBars);
18901
+ * ```
18902
+ */
18903
+ resetData() {
18904
+ ++this._loadGeneration;
18905
+ this.setLoading(false);
18906
+ return this.loadData([]);
18907
+ }
18020
18908
  loadData(bars, fitAll = false) {
18021
18909
  this.isFetching = false;
18022
18910
  this.noMoreData = false;
18911
+ this._suppressTicks = false;
18912
+ this.liveBidAsk = null;
18023
18913
  this.pricePanOffset = 0;
18024
18914
  this.priceScaleFactor = 1;
18025
18915
  this._displayedPriceRange = null;
@@ -18166,6 +19056,8 @@ var _ChartEngine = class _ChartEngine {
18166
19056
  }
18167
19057
  triggerLoad(reason) {
18168
19058
  if (!this.dataLoader) return;
19059
+ const gen = ++this._loadGeneration;
19060
+ this._suppressTicks = true;
18169
19061
  this.isFetching = true;
18170
19062
  this.noMoreData = false;
18171
19063
  const end = /* @__PURE__ */ new Date();
@@ -18177,6 +19069,7 @@ var _ChartEngine = class _ChartEngine {
18177
19069
  const interval = this.timeframeToInterval(this.getBaseInterval());
18178
19070
  this.setLoading(true);
18179
19071
  this.dataLoader({ start, end, interval }).then((bars) => {
19072
+ if (gen !== this._loadGeneration) return;
18180
19073
  this.loadData(this.maybeAggregate(bars), reason === "duration");
18181
19074
  this.setLoading(false);
18182
19075
  this.emitter.emit("dataLoaded", {
@@ -18186,25 +19079,17 @@ var _ChartEngine = class _ChartEngine {
18186
19079
  end
18187
19080
  });
18188
19081
  }).catch(() => {
19082
+ if (gen !== this._loadGeneration) return;
18189
19083
  this.isFetching = false;
18190
19084
  this.setLoading(false);
18191
19085
  });
18192
19086
  }
18193
19087
  checkNeedMoreData() {
18194
19088
  if (!this.dataLoader || this.isFetching || this.noMoreData) return;
18195
- const threshold = Math.max(
18196
- 20,
18197
- Math.round(
18198
- initialVisibleBars(
18199
- this.mobileBarDivisor,
18200
- this.explicitMobileDivisor,
18201
- this.defaultVisibleBars,
18202
- this.defaultMobileVisibleBars
18203
- ) * 0.25
18204
- )
18205
- );
19089
+ const threshold = Math.max(20, this.prefetchThreshold);
18206
19090
  if (this._targetViewport.startIndex > threshold) return;
18207
19091
  if (this.dataStore.length === 0) return;
19092
+ const gen = this._loadGeneration;
18208
19093
  this.isFetching = true;
18209
19094
  const before = this.dataStore.all[0].time;
18210
19095
  const end = new Date(before - 1);
@@ -18216,9 +19101,11 @@ var _ChartEngine = class _ChartEngine {
18216
19101
  const interval = this.timeframeToInterval(this.getBaseInterval());
18217
19102
  this.setLoading(true);
18218
19103
  this.dataLoader({ start, end, interval }).then((bars) => {
19104
+ if (gen !== this._loadGeneration) return;
18219
19105
  this.prependData(this.maybeAggregate(bars));
18220
19106
  this.setLoading(false);
18221
19107
  }).catch(() => {
19108
+ if (gen !== this._loadGeneration) return;
18222
19109
  this.isFetching = false;
18223
19110
  this.setLoading(false);
18224
19111
  });
@@ -18430,6 +19317,7 @@ var _ChartEngine = class _ChartEngine {
18430
19317
  this.leftBar?.setTheme(this.theme);
18431
19318
  this.tradeButton?.setTheme(this.theme, this.priceAxisWidth);
18432
19319
  this.tradePopover?.setTheme(this.theme);
19320
+ this._draftQtyFlyout?.setTheme(this.theme);
18433
19321
  this.indicatorSettingsDialog?.setTheme(this.theme);
18434
19322
  this.chartSettingsDialog?.setTheme(this.theme);
18435
19323
  this.applyScrollToEndBtnStyles();
@@ -18460,11 +19348,21 @@ var _ChartEngine = class _ChartEngine {
18460
19348
  this.leftBar?.setTheme(this.theme);
18461
19349
  this.tradeButton?.setTheme(this.theme, this.priceAxisWidth);
18462
19350
  this.tradePopover?.setTheme(this.theme);
19351
+ this._draftQtyFlyout?.setTheme(this.theme);
18463
19352
  this.indicatorSettingsDialog?.setTheme(this.theme);
18464
19353
  this.chartSettingsDialog?.setTheme(this.theme);
18465
19354
  this.scheduleRender();
18466
19355
  return this;
18467
19356
  }
19357
+ /**
19358
+ * Changes the display timezone for all time-axis and crosshair labels.
19359
+ * Accepts any IANA string (e.g. `"America/New_York"`), `"UTC"`, or `"local"`.
19360
+ */
19361
+ setTimezone(tz) {
19362
+ this.timezone = tz === "local" ? Intl.DateTimeFormat().resolvedOptions().timeZone : tz;
19363
+ this.scheduleRender();
19364
+ return this;
19365
+ }
18468
19366
  setSeries(series) {
18469
19367
  this.series = series;
18470
19368
  this.topBar?.setSeries(series);
@@ -18473,6 +19371,31 @@ var _ChartEngine = class _ChartEngine {
18473
19371
  this.emitStateChange();
18474
19372
  return this;
18475
19373
  }
19374
+ /**
19375
+ * Toggle TFC (Trade from Charts) on or off at runtime.
19376
+ * When disabled, all trade levels, the trade button, and trade popover are hidden.
19377
+ * Only functional when `tfcEnabled` config option is `true` (default).
19378
+ */
19379
+ setTfcActive(enabled) {
19380
+ if (!this.tfcEnabled) return this;
19381
+ this.tfcActive = enabled;
19382
+ this.topBar?.setTfcActive(enabled);
19383
+ if (!enabled) {
19384
+ this.tradeButton?.hide();
19385
+ this.tradePopover?.hide();
19386
+ if (this.draftOrderLabel !== null) {
19387
+ this.removeDraftOrder();
19388
+ }
19389
+ }
19390
+ if (this.chartSettingsDialog) {
19391
+ this.chartSettingsDialog.destroy();
19392
+ this.chartSettingsDialog = null;
19393
+ }
19394
+ this.renderTradeLayer();
19395
+ this.emitter.emit("tfcToggle", { enabled });
19396
+ this.emitStateChange();
19397
+ return this;
19398
+ }
18476
19399
  clearDuration() {
18477
19400
  if (this.duration === void 0) return;
18478
19401
  this.duration = void 0;
@@ -18516,7 +19439,8 @@ var _ChartEngine = class _ChartEngine {
18516
19439
  })),
18517
19440
  positionRenderStyle: this.positionRenderStyle,
18518
19441
  tradeDisplayFilter: this.tradeDisplayFilter,
18519
- canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0
19442
+ canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0,
19443
+ tfcEnabled: this.tfcActive
18520
19444
  };
18521
19445
  }
18522
19446
  /**
@@ -18588,9 +19512,14 @@ var _ChartEngine = class _ChartEngine {
18588
19512
  if (state.canvasColors) {
18589
19513
  this.settingsCanvasColors = state.canvasColors;
18590
19514
  }
19515
+ if (state.tfcEnabled !== void 0 && this.tfcEnabled) {
19516
+ this.tfcActive = state.tfcEnabled;
19517
+ this.topBar?.setTfcActive(state.tfcEnabled);
19518
+ }
18591
19519
  } finally {
18592
19520
  this.isRestoringState = false;
18593
19521
  }
19522
+ this.emitStateChange();
18594
19523
  this.scheduleRender();
18595
19524
  return this;
18596
19525
  }
@@ -18652,6 +19581,7 @@ var _ChartEngine = class _ChartEngine {
18652
19581
  }
18653
19582
  if (this.siStreamDot)
18654
19583
  this.siStreamDot.style.background = this.theme.ui.stream[this.streamStatus];
19584
+ this.liveBidAsk = null;
18655
19585
  return this;
18656
19586
  }
18657
19587
  /**
@@ -18715,6 +19645,38 @@ var _ChartEngine = class _ChartEngine {
18715
19645
  this.tradePopover?.setLots(lots);
18716
19646
  return this;
18717
19647
  }
19648
+ /**
19649
+ * Updates the quantity (lots) shown in the draft order QTY pill and
19650
+ * submitted when the order is confirmed.
19651
+ * @param qty Lot size to display and submit.
19652
+ */
19653
+ setDraftQty(qty) {
19654
+ if (this.draftOrderLabel) {
19655
+ const idx = this.levels.findIndex((l) => l.label === this.draftOrderLabel);
19656
+ if (idx !== -1) {
19657
+ const lvl = this.levels[idx];
19658
+ this.levels = [
19659
+ ...this.levels.slice(0, idx),
19660
+ { ...lvl, lots: qty },
19661
+ ...this.levels.slice(idx + 1)
19662
+ ];
19663
+ this.renderTradeLayer();
19664
+ }
19665
+ }
19666
+ return this;
19667
+ }
19668
+ openDraftQtyFlyout(pill) {
19669
+ if (!this._draftQtyFlyout) {
19670
+ this._draftQtyFlyout = new DraftQtyFlyout(this.theme, (qty) => this.setDraftQty(qty));
19671
+ this.getFloatingRoot().appendChild(this._draftQtyFlyout.el);
19672
+ }
19673
+ const draft = this.levels.find((l) => l.label === this.draftOrderLabel);
19674
+ const currentQty = draft?.lots ?? this._qtyFieldMin;
19675
+ const rect = this.canvasWrap.getBoundingClientRect();
19676
+ const vpX = rect.left + pill.pillX;
19677
+ const vpY = rect.top + pill.pillY + pill.pillH;
19678
+ this._draftQtyFlyout.show(vpX, vpY, currentQty, this._qtyFieldMin, this._qtyFieldMax);
19679
+ }
18718
19680
  /**
18719
19681
  * @deprecated DraftOrderQtyInput has been removed. This method is a no-op
18720
19682
  * kept for backward compatibility. It will be removed in a future version.
@@ -19190,7 +20152,27 @@ var _ChartEngine = class _ChartEngine {
19190
20152
  */
19191
20153
  selectLevel(label) {
19192
20154
  this.selectedTradeLabel = label;
19193
- if (label !== null) this.crosshair = null;
20155
+ if (label !== null) {
20156
+ this.crosshair = null;
20157
+ const cluster = this.lastClusters.find(
20158
+ (c) => c.members.some((m) => m.level.label === label)
20159
+ );
20160
+ if (cluster) this.expandedClusters.add(cluster.id);
20161
+ }
20162
+ this.renderTradeLayer();
20163
+ return this;
20164
+ }
20165
+ /** Enable or disable trade-level fan-out clustering at runtime. */
20166
+ setLevelClusteringEnabled(enabled) {
20167
+ this.levelClusteringEnabled = enabled;
20168
+ this.expandedClusters.clear();
20169
+ this.renderTradeLayer();
20170
+ return this;
20171
+ }
20172
+ /** Update the cluster proximity threshold (in pixels) at runtime. Only effective when levelClusteringEnabled is true. */
20173
+ setClusterThresholdDistance(px) {
20174
+ this.clusterThresholdDistance = px;
20175
+ this.expandedClusters.clear();
19194
20176
  this.renderTradeLayer();
19195
20177
  return this;
19196
20178
  }
@@ -19205,6 +20187,7 @@ var _ChartEngine = class _ChartEngine {
19205
20187
  this.chartSettingsDialog?.destroy();
19206
20188
  this.isinPickerModal?.destroy();
19207
20189
  this.tradePopover?.destroy();
20190
+ this._draftQtyFlyout?.destroy();
19208
20191
  this.leftBar?.destroy();
19209
20192
  this.drawCanvas.removeEventListener("mousemove", this.onMouseMove);
19210
20193
  this.drawCanvas.removeEventListener("mouseleave", this.onMouseLeave);
@@ -19373,7 +20356,7 @@ var _ChartEngine = class _ChartEngine {
19373
20356
  theme: this.themeName
19374
20357
  },
19375
20358
  this.labels.dialogs,
19376
- !!this._onOrderSubmit,
20359
+ !!this._onOrderSubmit && this.tfcActive,
19377
20360
  this.enableThemeToggle
19378
20361
  );
19379
20362
  this.getFloatingRoot().appendChild(this.chartSettingsDialog.el);
@@ -19417,7 +20400,8 @@ var _ChartEngine = class _ChartEngine {
19417
20400
  const modals = [
19418
20401
  this.isinPickerModal?.el,
19419
20402
  this.indicatorSettingsDialog?.el,
19420
- this.chartSettingsDialog?.el
20403
+ this.chartSettingsDialog?.el,
20404
+ this._draftQtyFlyout?.el
19421
20405
  ];
19422
20406
  for (const el of modals) {
19423
20407
  if (el && el.parentElement !== root) root.appendChild(el);
@@ -19484,11 +20468,12 @@ var _ChartEngine = class _ChartEngine {
19484
20468
  const dpr = window.devicePixelRatio || 1;
19485
20469
  const { tradeCtx: ctx, tradeCanvas: canvas, theme, viewport } = this;
19486
20470
  ctx.clearRect(0, 0, canvas.width, canvas.height);
19487
- if (this.levels.length === 0 || this.dataStore.length === 0) {
20471
+ if (!this.tfcActive || this.levels.length === 0 || this.dataStore.length === 0) {
19488
20472
  this.tradeLevelHitAreas = [];
19489
20473
  this.tradeLevelEditAreas = [];
19490
20474
  this.tradeHoverAreas = [];
19491
20475
  this.tradeAddBracketAreas = [];
20476
+ this.offViewportHitAreas = [];
19492
20477
  return;
19493
20478
  }
19494
20479
  const { chartW, priceH } = this.getChartLayout();
@@ -19543,6 +20528,7 @@ var _ChartEngine = class _ChartEngine {
19543
20528
  // Include true pending orders AND entry-order positions (ES/EL)
19544
20529
  processedLevels.filter((l) => l.type === "pending" || isEntryOrder(l))
19545
20530
  ) : processedLevels;
20531
+ const clusterThreshold = this.levelClusteringEnabled ? this.clusterThresholdDistance : 0;
19546
20532
  const result = renderTradeLevels(
19547
20533
  ctx,
19548
20534
  filteredLevels,
@@ -19560,9 +20546,23 @@ var _ChartEngine = class _ChartEngine {
19560
20546
  this.positionRenderStyle,
19561
20547
  this.hideLevelConfirmCancel,
19562
20548
  this.draftOrderLabel,
19563
- this.draftBracketPnl
20549
+ this.draftBracketPnl,
20550
+ this.expandedClusters,
20551
+ clusterThreshold,
20552
+ this._showQuantityField
19564
20553
  );
19565
20554
  ctx.restore();
20555
+ const offVpResult = renderOffViewportIndicators(
20556
+ ctx,
20557
+ filteredLevels,
20558
+ priceRange,
20559
+ theme,
20560
+ chartW,
20561
+ priceH,
20562
+ this.tradeDragHandler?.dragLabel ?? null,
20563
+ this.selectedTradeLabel
20564
+ );
20565
+ this.offViewportHitAreas = offVpResult.hitAreas;
19566
20566
  ctx.restore();
19567
20567
  this.tradeLevelHitAreas = result.hitAreas;
19568
20568
  this.tradeLevelEditAreas = result.editAreas;
@@ -19570,6 +20570,15 @@ var _ChartEngine = class _ChartEngine {
19570
20570
  this.tradeLevelDragAreas = result.dragAreas;
19571
20571
  this.tradeLevelConfirmAreas = result.confirmAreas;
19572
20572
  this.tradeAddBracketAreas = result.addBracketAreas;
20573
+ this.clusterBadgeAreas = result.clusterBadgeAreas;
20574
+ this.lastClusters = result.clusters;
20575
+ this._draftQtyPillArea = result.draftQtyPillArea;
20576
+ if (this.expandedClusters.size > 0) {
20577
+ const currentIds = new Set(result.clusters.map((c) => c.id));
20578
+ for (const id of this.expandedClusters) {
20579
+ if (!currentIds.has(id)) this.expandedClusters.delete(id);
20580
+ }
20581
+ }
19573
20582
  }
19574
20583
  // ── Inline pending-change confirmation ────────────────────────────────────
19575
20584
  /** Returns the effective entry price for a level, preferring any staged main drag. */
@@ -19683,6 +20692,7 @@ var _ChartEngine = class _ChartEngine {
19683
20692
  side,
19684
20693
  price,
19685
20694
  orderType,
20695
+ lots: this._qtyFieldMin,
19686
20696
  data: { _isDraft: true }
19687
20697
  };
19688
20698
  this.levels = [...this.levels, draftLevel];
@@ -19706,6 +20716,8 @@ var _ChartEngine = class _ChartEngine {
19706
20716
  this.selectedTradeLabel = null;
19707
20717
  this.draftOrderLabel = null;
19708
20718
  this.draftBracketPnl = {};
20719
+ this._draftQtyFlyout?.hide();
20720
+ this._draftQtyPillArea = void 0;
19709
20721
  this.renderTradeLayer();
19710
20722
  this.emitter.emit("draftCancelled", { label, isFullscreen: this.isFullscreen });
19711
20723
  }
@@ -19719,7 +20731,7 @@ var _ChartEngine = class _ChartEngine {
19719
20731
  const draft = level;
19720
20732
  this._onOrderSubmit?.({
19721
20733
  price: mainEntry?.newPrice ?? draft.price,
19722
- lots: this._minLots,
20734
+ lots: draft.lots ?? this._qtyFieldMin,
19723
20735
  side: draft.side,
19724
20736
  orderType: draft.orderType,
19725
20737
  ...draft.stopLossPrice !== void 0 ? { stopLoss: draft.stopLossPrice } : {},
@@ -20197,7 +21209,8 @@ var _ChartEngine = class _ChartEngine {
20197
21209
  isMobileDevice() ? 3 : void 0,
20198
21210
  this.uiConfig.timeAxis,
20199
21211
  this.timeframe,
20200
- countdownBarIndex
21212
+ countdownBarIndex,
21213
+ this.timezone
20201
21214
  );
20202
21215
  if (showCountdown) {
20203
21216
  const lastBar2 = this.dataStore.all[this.dataStore.length - 1];
@@ -20230,7 +21243,8 @@ var _ChartEngine = class _ChartEngine {
20230
21243
  subPaneY,
20231
21244
  this.timeframe,
20232
21245
  this.dataStore.getPricePrecision(),
20233
- this.uiConfig.crosshair
21246
+ this.uiConfig.crosshair,
21247
+ this.timezone
20234
21248
  );
20235
21249
  }
20236
21250
  if (this.logoImage) {
@@ -20464,7 +21478,12 @@ var _ChartEngine = class _ChartEngine {
20464
21478
  if (this._isTradingActive) {
20465
21479
  this.tradeButton.hide();
20466
21480
  } else {
20467
- this.tradeButton.show();
21481
+ const _btnHalfH1 = this.uiConfig.tradeButton.size / 2;
21482
+ if (overlapsOffViewportPill(y, _btnHalfH1, this.offViewportHitAreas)) {
21483
+ this.tradeButton.hide();
21484
+ } else if (this.tfcActive) {
21485
+ this.tradeButton.show();
21486
+ }
20468
21487
  }
20469
21488
  this._tradeButtonClickData = { price, y };
20470
21489
  const _allBars = this.dataStore.all;
@@ -20477,6 +21496,9 @@ var _ChartEngine = class _ChartEngine {
20477
21496
  /** Shared helper: initiate a trade level main-line drag from a hit area. */
20478
21497
  _startTradeDragFromHit(dragHit, level, hitY) {
20479
21498
  if (!this.tradeDragHandler) return;
21499
+ const { priceH } = this.getChartLayout();
21500
+ const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
21501
+ const levelPixelY = this.scaleManager.yToPixel(dragHit.price, priceRange, priceH);
20480
21502
  if (dragHit.isBracket) {
20481
21503
  this._pendingBracketOrderLabel = dragHit.bracketOrderLabel ?? null;
20482
21504
  this._isBracketNew = false;
@@ -20489,6 +21511,8 @@ var _ChartEngine = class _ChartEngine {
20489
21511
  level,
20490
21512
  dragHit.bracketType,
20491
21513
  dragHit.price,
21514
+ hitY,
21515
+ levelPixelY,
20492
21516
  marketPrice,
20493
21517
  this._effectiveEntryPrice(level.label, level.price)
20494
21518
  );
@@ -20508,6 +21532,7 @@ var _ChartEngine = class _ChartEngine {
20508
21532
  this.tradeDragHandler.startDrag(
20509
21533
  level,
20510
21534
  hitY,
21535
+ levelPixelY,
20511
21536
  dragHit.price,
20512
21537
  posLevel.stopLossPrice,
20513
21538
  posLevel.takeProfitPrice,
@@ -20522,6 +21547,7 @@ var _ChartEngine = class _ChartEngine {
20522
21547
  this.tradeDragHandler.startDrag(
20523
21548
  level,
20524
21549
  hitY,
21550
+ levelPixelY,
20525
21551
  dragHit.price,
20526
21552
  void 0,
20527
21553
  void 0,
@@ -20551,6 +21577,8 @@ var _ChartEngine = class _ChartEngine {
20551
21577
  level,
20552
21578
  addHit.bracketType,
20553
21579
  initialPrice,
21580
+ hitY,
21581
+ btnCenterY,
20554
21582
  marketPrice,
20555
21583
  this._effectiveEntryPrice(level.label, level.price)
20556
21584
  );
@@ -20637,6 +21665,7 @@ var _ChartEngine = class _ChartEngine {
20637
21665
  * This happens when the system clock was behind feed time during the initial data load. */
20638
21666
  fillTickGap(fromTime, toTime) {
20639
21667
  if (!this.dataLoader) return;
21668
+ if (this.isFetching) return;
20640
21669
  const interval = this.timeframeToInterval(this.getBaseInterval());
20641
21670
  this.dataLoader({ start: new Date(fromTime), end: new Date(toTime), interval }).then((rawBars) => {
20642
21671
  const bars = this.maybeAggregate(rawBars);
@@ -20671,6 +21700,7 @@ var _ChartEngine = class _ChartEngine {
20671
21700
  * tick-reconstructed OHLCV with authoritative exchange data. No-op if no loader. */
20672
21701
  correctCompletedBar(barTime, intervalMs) {
20673
21702
  if (!this.dataLoader) return;
21703
+ if (this.isFetching) return;
20674
21704
  const start = new Date(barTime);
20675
21705
  const end = new Date(barTime + intervalMs * 3);
20676
21706
  const interval = this.timeframeToInterval(this.getBaseInterval());
@@ -20710,6 +21740,7 @@ var _ChartEngine = class _ChartEngine {
20710
21740
  this.scheduleRender();
20711
21741
  }
20712
21742
  scrollToEnd() {
21743
+ this.panHandler.cancel();
20713
21744
  if (this.scrollAnimId !== null) {
20714
21745
  cancelAnimationFrame(this.scrollAnimId);
20715
21746
  this.scrollAnimId = null;
@@ -20741,6 +21772,38 @@ var _ChartEngine = class _ChartEngine {
20741
21772
  };
20742
21773
  this.scrollAnimId = requestAnimationFrame(step);
20743
21774
  }
21775
+ /**
21776
+ * Smooth-scroll the Y axis so `price` lands at the vertical center of the
21777
+ * chart. Animates `pricePanOffset` only — horizontal viewport is preserved.
21778
+ * Used by off-viewport level indicator clicks.
21779
+ */
21780
+ scrollToCenterPrice(price) {
21781
+ if (!isFinite(price) || this.dataStore.length === 0) return;
21782
+ if (this.scrollAnimId !== null) {
21783
+ cancelAnimationFrame(this.scrollAnimId);
21784
+ this.scrollAnimId = null;
21785
+ }
21786
+ const base = this.dataStore.getPriceRange(this.viewport);
21787
+ const naturalMid = (base.min + base.max) / 2;
21788
+ const startPan = this.pricePanOffset;
21789
+ const targetPan = price - naturalMid;
21790
+ if (Math.abs(targetPan - startPan) < 1e-9) return;
21791
+ const DURATION = 350;
21792
+ const t0 = performance.now();
21793
+ const ease = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
21794
+ const step = (now) => {
21795
+ const raw = Math.min((now - t0) / DURATION, 1);
21796
+ const prog = ease(raw);
21797
+ this.pricePanOffset = startPan + (targetPan - startPan) * prog;
21798
+ this.render();
21799
+ if (raw < 1) {
21800
+ this.scrollAnimId = requestAnimationFrame(step);
21801
+ } else {
21802
+ this.scrollAnimId = null;
21803
+ }
21804
+ };
21805
+ this.scrollAnimId = requestAnimationFrame(step);
21806
+ }
20744
21807
  resetPriceAxis() {
20745
21808
  this.priceScaleFactor = 1;
20746
21809
  this.pricePanOffset = 0;
@@ -20748,6 +21811,7 @@ var _ChartEngine = class _ChartEngine {
20748
21811
  this.scheduleRender();
20749
21812
  }
20750
21813
  resetTimeAxis() {
21814
+ this.panHandler.cancel();
20751
21815
  const total = this.dataStore.length;
20752
21816
  if (total === 0) return;
20753
21817
  const newStart = Math.max(