acttrader-charts 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -117,6 +117,33 @@ var _ScaleManager = class _ScaleManager {
117
117
  };
118
118
  _ScaleManager.VERTICAL_PADDING_PX = 20;
119
119
  var ScaleManager = _ScaleManager;
120
+ function niceStep(rawStep) {
121
+ if (!isFinite(rawStep) || rawStep <= 0) return 1;
122
+ const exp = Math.floor(Math.log10(rawStep));
123
+ const base = Math.pow(10, exp);
124
+ const frac = rawStep / base;
125
+ const nice = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
126
+ return nice * base;
127
+ }
128
+ function generatePriceTicks(range, chartHeight, targetPxPerLabel = 48) {
129
+ const { min, max } = range;
130
+ if (!isFinite(min) || !isFinite(max) || max <= min) return [min];
131
+ const approxSteps = Math.max(2, Math.floor(chartHeight / targetPxPerLabel));
132
+ const rawStep = (max - min) / approxSteps;
133
+ const step = niceStep(rawStep);
134
+ const first = Math.ceil(min / step) * step;
135
+ const ticks = [];
136
+ const eps = step * 1e-6;
137
+ for (let v = first; v <= max + eps; v += step) {
138
+ ticks.push(v);
139
+ }
140
+ return ticks;
141
+ }
142
+ function precisionForStep(step, minPrecision = 0) {
143
+ if (!isFinite(step) || step <= 0) return minPrecision;
144
+ const exp = Math.floor(Math.log10(step));
145
+ return Math.max(minPrecision, -exp);
146
+ }
120
147
 
121
148
  // src/core/EventEmitter.ts
122
149
  var EventEmitter = class {
@@ -169,6 +196,11 @@ function resolveCssVarsInTheme(theme, container) {
169
196
  container.appendChild(tmp);
170
197
  const resolve = (value) => {
171
198
  if (!value.includes("var(")) return value;
199
+ value = value.replace(
200
+ /rgba\(\s*(var\([^)]+\))\s*,\s*([0-9.]+)\s*\)/g,
201
+ "rgb($1 / $2)"
202
+ );
203
+ tmp.style.color = "";
172
204
  tmp.style.color = value;
173
205
  const resolved = getComputedStyle(tmp).color;
174
206
  return resolved || value;
@@ -535,7 +567,8 @@ var DEFAULT_LABELS = {
535
567
  toggleDrawingTitle: "Toggle Drawing Tools",
536
568
  toggleFullscreenTitle: "Toggle Fullscreen",
537
569
  exitFullscreenTitle: "Exit Fullscreen",
538
- settingsTitle: "Settings"
570
+ settingsTitle: "Settings",
571
+ tfcToggleTitle: "Trade from Charts"
539
572
  },
540
573
  bottomBar: {
541
574
  durations: {
@@ -1430,18 +1463,18 @@ function renderHighLow(ctx, bars, viewport, priceRange, scale, theme, chartWidth
1430
1463
  }
1431
1464
 
1432
1465
  // src/overlays/GridOverlay.ts
1433
- function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines = 6) {
1466
+ function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines) {
1434
1467
  ctx.save();
1435
1468
  ctx.globalAlpha = 0.6;
1436
1469
  ctx.strokeStyle = theme.grid;
1437
1470
  ctx.lineWidth = 1;
1438
- const step = (priceRange.max - priceRange.min) / gridLines;
1439
1471
  ctx.beginPath();
1440
- for (let i = 0; i <= gridLines; i++) {
1441
- const price = priceRange.min + step * i;
1442
- const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1443
- ctx.moveTo(0, y);
1444
- ctx.lineTo(chartWidth, y);
1472
+ {
1473
+ for (const price of generatePriceTicks(priceRange, chartHeight)) {
1474
+ const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1475
+ ctx.moveTo(0, y);
1476
+ ctx.lineTo(chartWidth, y);
1477
+ }
1445
1478
  }
1446
1479
  ctx.stroke();
1447
1480
  const vertCols = Math.max(1, Math.floor(chartWidth / 80));
@@ -1513,6 +1546,66 @@ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidt
1513
1546
  }
1514
1547
  }
1515
1548
 
1549
+ // src/overlays/TradeLevelCluster.ts
1550
+ function detectClusters(measured, thresholdPx, excludeLabels = /* @__PURE__ */ new Set()) {
1551
+ const eligible = measured.filter((m) => !excludeLabels.has(m.level.label)).sort((a, b) => a.y - b.y);
1552
+ if (eligible.length < 2) return [];
1553
+ const groups = [];
1554
+ let currentGroup = [eligible[0]];
1555
+ let groupStartY = eligible[0].y;
1556
+ for (let i = 1; i < eligible.length; i++) {
1557
+ const m = eligible[i];
1558
+ if (m.y - groupStartY <= thresholdPx) {
1559
+ currentGroup.push(m);
1560
+ } else {
1561
+ groups.push(currentGroup);
1562
+ currentGroup = [m];
1563
+ groupStartY = m.y;
1564
+ }
1565
+ }
1566
+ groups.push(currentGroup);
1567
+ return groups.filter((g) => g.length >= 2).map((members) => {
1568
+ const id = "cluster:" + members.map((m) => m.level.label).sort().join("|");
1569
+ const centerY = members.reduce((sum, m) => sum + m.y, 0) / members.length;
1570
+ const centerPrice = members.reduce((sum, m) => sum + m.price, 0) / members.length;
1571
+ return { id, members, centerY, centerPrice };
1572
+ });
1573
+ }
1574
+ function computeFanOutLayout(cluster, boxH, gapPx = 4) {
1575
+ const n = cluster.members.length;
1576
+ const totalH = n * boxH + (n - 1) * gapPx;
1577
+ const startY = cluster.centerY - totalH / 2;
1578
+ const sorted = [...cluster.members].sort((a, b) => a.y - b.y);
1579
+ const result = /* @__PURE__ */ new Map();
1580
+ for (let i = 0; i < sorted.length; i++) {
1581
+ result.set(sorted[i].level.label, startY + i * (boxH + gapPx));
1582
+ }
1583
+ return result;
1584
+ }
1585
+ function buildClusterBadgeLayout(cluster, chartW, ctx) {
1586
+ const n = cluster.members.length;
1587
+ const types = new Set(cluster.members.map((m) => m.level.type));
1588
+ let noun;
1589
+ if (types.size === 1) {
1590
+ const t = types.values().next().value;
1591
+ noun = t === "position" ? "Position" : t === "pending" ? "Order" : "Trade";
1592
+ } else {
1593
+ noun = "Level";
1594
+ }
1595
+ const text = `${n} ${noun}${n > 1 ? "s" : ""}`;
1596
+ const PAD_X = 12;
1597
+ const textW = ctx.measureText(text).width;
1598
+ const EXPAND_ICON_W = 16;
1599
+ const badgeW = PAD_X + textW + EXPAND_ICON_W + PAD_X;
1600
+ const badgeH = 24;
1601
+ const badgeX = (chartW - badgeW) / 2;
1602
+ const badgeY = cluster.centerY - badgeH / 2;
1603
+ return { badgeX, badgeY, badgeW, badgeH, text };
1604
+ }
1605
+ function hitTestClusterBadge(x, y, areas) {
1606
+ return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
1607
+ }
1608
+
1516
1609
  // src/overlays/TradeLevelOverlay.ts
1517
1610
  var BOX_PAD_X = 8;
1518
1611
  var BOX_PAD_Y = 4;
@@ -1631,7 +1724,7 @@ function isDraggable(level) {
1631
1724
  return !!level.entryPriceEditable;
1632
1725
  return false;
1633
1726
  }
1634
- function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH, precision, hoveredLabel = null, draggingLabel = null, dragNewPrice = null, selectedLabel = null, pendingLabels = /* @__PURE__ */ new Set(), positionStyle = "line", hideConfirmCancel = false, draftOrderLabel = null, draftBracketPnl = {}) {
1727
+ 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) {
1635
1728
  if (levels.length === 0)
1636
1729
  return {
1637
1730
  hitAreas: [],
@@ -1639,7 +1732,9 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1639
1732
  hoverAreas: [],
1640
1733
  dragAreas: [],
1641
1734
  confirmAreas: [],
1642
- addBracketAreas: []
1735
+ addBracketAreas: [],
1736
+ clusterBadgeAreas: [],
1737
+ clusters: []
1643
1738
  };
1644
1739
  const colors = theme.tradeLevels;
1645
1740
  const boxH = ROW_H + BOX_PAD_Y * 2;
@@ -1670,13 +1765,46 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1670
1765
  const bW = BOX_PAD_X + pnlW + labelW + BOX_PAD_X + confirmW + closeW;
1671
1766
  measured.push({ level, price, y, boxW: bW, boxH });
1672
1767
  }
1768
+ const excludeFromClustering = /* @__PURE__ */ new Set();
1769
+ if (draftOrderLabel) excludeFromClustering.add(draftOrderLabel);
1770
+ const clusters = clusterThresholdPx > 0 ? detectClusters(measured, clusterThresholdPx, excludeFromClustering) : [];
1771
+ const collapsedClusters = clusters.filter((c) => !expandedClusters.has(c.id));
1772
+ const expandedClusterList = clusters.filter((c) => expandedClusters.has(c.id));
1773
+ const collapsedLabels = /* @__PURE__ */ new Set();
1774
+ for (const c of collapsedClusters) {
1775
+ for (const m of c.members) collapsedLabels.add(m.level.label);
1776
+ }
1777
+ const expandedFanLayouts = /* @__PURE__ */ new Map();
1778
+ for (const ec of expandedClusterList) {
1779
+ expandedFanLayouts.set(ec.id, computeFanOutLayout(ec, boxH, 4));
1780
+ }
1781
+ const labelToExpandedCluster = /* @__PURE__ */ new Map();
1782
+ for (const ec of expandedClusterList) {
1783
+ for (const m of ec.members) labelToExpandedCluster.set(m.level.label, ec);
1784
+ }
1673
1785
  measured.sort((a, b) => a.y - b.y);
1674
1786
  const placed = [];
1675
1787
  const layouts = /* @__PURE__ */ new Map();
1676
1788
  for (const m of measured) {
1677
- const centeredY = m.y - m.boxH / 2;
1678
- const overlaps = placed.some((p) => rectsOverlapY(centeredY, m.boxH, p.boxY, p.boxH));
1679
- const boxY = overlaps ? centeredY - m.boxH - 2 : centeredY;
1789
+ if (collapsedLabels.has(m.level.label)) continue;
1790
+ const ec = labelToExpandedCluster.get(m.level.label);
1791
+ if (ec) {
1792
+ const fanMap = expandedFanLayouts.get(ec.id);
1793
+ const fanY = fanMap?.get(m.level.label);
1794
+ if (fanY !== void 0) {
1795
+ placed.push({ label: m.level.label, boxY: fanY, boxH: m.boxH });
1796
+ layouts.set(m.level.label, { price: m.price, y: m.y, boxY: fanY, boxW: m.boxW, boxH: m.boxH });
1797
+ continue;
1798
+ }
1799
+ }
1800
+ let boxY = m.y - m.boxH / 2;
1801
+ let attempts = 0;
1802
+ while (attempts < placed.length + 1) {
1803
+ const overlaps = placed.some((p) => rectsOverlapY(boxY, m.boxH, p.boxY, p.boxH));
1804
+ if (!overlaps) break;
1805
+ boxY -= m.boxH + 2;
1806
+ attempts++;
1807
+ }
1680
1808
  placed.push({ label: m.level.label, boxY, boxH: m.boxH });
1681
1809
  layouts.set(m.level.label, { price: m.price, y: m.y, boxY, boxW: m.boxW, boxH: m.boxH });
1682
1810
  }
@@ -1692,9 +1820,10 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1692
1820
  selectedLabel
1693
1821
  );
1694
1822
  const activeLabel = hoveredLabel ?? selectedLabel;
1823
+ const visibleMeasured = measured.filter((m) => !collapsedLabels.has(m.level.label));
1695
1824
  const drawOrder = [
1696
- ...measured.filter((m) => m.level.label !== activeLabel),
1697
- ...measured.filter((m) => m.level.label === activeLabel)
1825
+ ...visibleMeasured.filter((m) => m.level.label !== activeLabel),
1826
+ ...visibleMeasured.filter((m) => m.level.label === activeLabel)
1698
1827
  ];
1699
1828
  const hitAreas = [];
1700
1829
  const editAreas = [];
@@ -1702,6 +1831,10 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1702
1831
  const dragAreas = [];
1703
1832
  const confirmAreas = [];
1704
1833
  const addBracketAreas = [];
1834
+ const clusterBadgeAreas = [];
1835
+ for (const cluster of collapsedClusters) {
1836
+ drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas);
1837
+ }
1705
1838
  for (const { level } of drawOrder) {
1706
1839
  const layout = layouts.get(level.label);
1707
1840
  if (!layout) continue;
@@ -1732,9 +1865,134 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1732
1865
  hideConfirmCancel,
1733
1866
  level.label === draftOrderLabel ? draftBracketPnl : void 0
1734
1867
  );
1868
+ const ec = labelToExpandedCluster.get(level.label);
1869
+ if (ec && layout.boxY !== layout.y - boxH / 2) {
1870
+ drawFanConnector(ctx, layout.boxY + boxH / 2, layout.y, chartW, theme);
1871
+ }
1872
+ }
1873
+ if (selectedLabel !== null && !layouts.has(selectedLabel)) {
1874
+ const offLevel = levels.find((l) => l.label === selectedLabel);
1875
+ if (offLevel) {
1876
+ const bh = offLevel;
1877
+ const slDragKey = `${offLevel.label}:stopLoss`;
1878
+ const tpDragKey = `${offLevel.label}:takeProfit`;
1879
+ const fallbackX = chartW * BRACKET_BASE_RATIO;
1880
+ if (bh.stopLossPrice !== void 0) {
1881
+ drawBracketLine(
1882
+ ctx,
1883
+ bh,
1884
+ "stopLoss",
1885
+ bh.stopLossPrice,
1886
+ scale,
1887
+ priceRange,
1888
+ priceH,
1889
+ bracketLineXMap.get(slDragKey) ?? fallbackX,
1890
+ chartW,
1891
+ theme,
1892
+ precision,
1893
+ draggingLabel,
1894
+ dragNewPrice,
1895
+ hitAreas,
1896
+ dragAreas,
1897
+ hideConfirmCancel,
1898
+ bh._slOrderLabel
1899
+ );
1900
+ }
1901
+ if (bh.takeProfitPrice !== void 0) {
1902
+ drawBracketLine(
1903
+ ctx,
1904
+ bh,
1905
+ "takeProfit",
1906
+ bh.takeProfitPrice,
1907
+ scale,
1908
+ priceRange,
1909
+ priceH,
1910
+ bracketLineXMap.get(tpDragKey) ?? fallbackX,
1911
+ chartW,
1912
+ theme,
1913
+ precision,
1914
+ draggingLabel,
1915
+ dragNewPrice,
1916
+ hitAreas,
1917
+ dragAreas,
1918
+ hideConfirmCancel,
1919
+ bh._tpOrderLabel
1920
+ );
1921
+ }
1922
+ }
1735
1923
  }
1736
1924
  ctx.restore();
1737
- return { hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas };
1925
+ return { hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas, clusterBadgeAreas, clusters };
1926
+ }
1927
+ var BADGE_FONT = "bold 12px 'Inter', system-ui, sans-serif";
1928
+ var BADGE_PAD_X = 12;
1929
+ var BADGE_H = 24;
1930
+ var BADGE_RADIUS = 12;
1931
+ function drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas) {
1932
+ const mono = monoColor(theme);
1933
+ const isDark = theme.background !== "#ffffff";
1934
+ ctx.save();
1935
+ ctx.strokeStyle = mono;
1936
+ ctx.globalAlpha = 0.12;
1937
+ ctx.lineWidth = 1;
1938
+ ctx.setLineDash([4, 4]);
1939
+ for (const m of cluster.members) {
1940
+ ctx.beginPath();
1941
+ ctx.moveTo(0, m.y);
1942
+ ctx.lineTo(chartW, m.y);
1943
+ ctx.stroke();
1944
+ }
1945
+ ctx.setLineDash([]);
1946
+ ctx.restore();
1947
+ ctx.save();
1948
+ ctx.font = BADGE_FONT;
1949
+ const layout = buildClusterBadgeLayout(cluster, chartW, ctx);
1950
+ const { badgeX, badgeY, badgeW, text } = layout;
1951
+ const expandIcon = " \u25B8";
1952
+ const bgColor = isDark ? "rgba(40,40,40,0.92)" : "rgba(240,240,240,0.92)";
1953
+ ctx.fillStyle = bgColor;
1954
+ ctx.beginPath();
1955
+ ctx.roundRect(badgeX, badgeY, badgeW, BADGE_H, BADGE_RADIUS);
1956
+ ctx.fill();
1957
+ ctx.strokeStyle = mono;
1958
+ ctx.globalAlpha = 0.3;
1959
+ ctx.lineWidth = 1;
1960
+ ctx.stroke();
1961
+ ctx.globalAlpha = 1;
1962
+ ctx.fillStyle = mono;
1963
+ ctx.globalAlpha = 0.85;
1964
+ ctx.textBaseline = "middle";
1965
+ ctx.textAlign = "left";
1966
+ ctx.fillText(text + expandIcon, badgeX + BADGE_PAD_X, badgeY + BADGE_H / 2);
1967
+ ctx.globalAlpha = 1;
1968
+ ctx.restore();
1969
+ clusterBadgeAreas.push({
1970
+ clusterId: cluster.id,
1971
+ x: badgeX,
1972
+ y: badgeY,
1973
+ w: badgeW,
1974
+ h: BADGE_H
1975
+ });
1976
+ }
1977
+ function drawFanConnector(ctx, fanCenterY, realY, chartW, theme) {
1978
+ if (Math.abs(fanCenterY - realY) < 2) return;
1979
+ const mono = monoColor(theme);
1980
+ ctx.save();
1981
+ ctx.strokeStyle = mono;
1982
+ ctx.globalAlpha = 0.25;
1983
+ ctx.lineWidth = 1;
1984
+ ctx.setLineDash([2, 3]);
1985
+ const connectorX = chartW - 8;
1986
+ ctx.beginPath();
1987
+ ctx.moveTo(connectorX, fanCenterY);
1988
+ ctx.lineTo(connectorX, realY);
1989
+ ctx.stroke();
1990
+ ctx.setLineDash([]);
1991
+ ctx.beginPath();
1992
+ ctx.moveTo(connectorX - 4, realY);
1993
+ ctx.lineTo(connectorX + 4, realY);
1994
+ ctx.stroke();
1995
+ ctx.restore();
1738
1996
  }
1739
1997
  function drawDotHoverBox(ctx, level, y, dotX, dotR, theme, precision) {
1740
1998
  const pl = level;
@@ -2365,6 +2623,152 @@ function hitTestTradeLevelAddBracket(x, y, areas) {
2365
2623
  return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
2366
2624
  }
2367
2625
 
2626
+ // src/overlays/OffViewportLevelIndicator.ts
2627
+ function collectOffViewportMarkers(levels, priceRange, draggingLabel) {
2628
+ const above = [];
2629
+ const below = [];
2630
+ const { min, max } = priceRange;
2631
+ for (const level of levels) {
2632
+ if (level.label === draggingLabel) continue;
2633
+ const isDraftMarket = level.type === "pending" && level.orderType === "market" && level.data?._isDraft === true;
2634
+ if (isDraftMarket) continue;
2635
+ if (isFinite(level.price)) {
2636
+ if (level.price > max) above.push({ price: level.price, label: level.label, kind: "entry" });
2637
+ else if (level.price < min) below.push({ price: level.price, label: level.label, kind: "entry" });
2638
+ }
2639
+ const slPrice = level.stopLossPrice;
2640
+ const tpPrice = level.takeProfitPrice;
2641
+ if (slPrice !== void 0 && isFinite(slPrice)) {
2642
+ if (slPrice > max) above.push({ price: slPrice, label: level.label, kind: "sl" });
2643
+ else if (slPrice < min) below.push({ price: slPrice, label: level.label, kind: "sl" });
2644
+ }
2645
+ if (tpPrice !== void 0 && isFinite(tpPrice)) {
2646
+ if (tpPrice > max) above.push({ price: tpPrice, label: level.label, kind: "tp" });
2647
+ else if (tpPrice < min) below.push({ price: tpPrice, label: level.label, kind: "tp" });
2648
+ }
2649
+ }
2650
+ return { above, below };
2651
+ }
2652
+ function entryCount(markers) {
2653
+ let n = 0;
2654
+ for (const m of markers) if (m.kind === "entry") n++;
2655
+ return n;
2656
+ }
2657
+ function nearestEdgeMarker(markers, edge) {
2658
+ let best = markers[0];
2659
+ let bestDist = Math.abs(best.price - edge);
2660
+ for (let i = 1; i < markers.length; i++) {
2661
+ const d = Math.abs(markers[i].price - edge);
2662
+ if (d < bestDist) {
2663
+ best = markers[i];
2664
+ bestDist = d;
2665
+ }
2666
+ }
2667
+ return best;
2668
+ }
2669
+ var PILL_FONT = "11px 'Inter', system-ui, sans-serif";
2670
+ var PILL_PAD_X = 8;
2671
+ var PILL_RIGHT_MARGIN = 8;
2672
+ var PILL_V_MARGIN = 10;
2673
+ var PILL_H = 20;
2674
+ var PILL_RADIUS = 10;
2675
+ var ARROW_UP = "\u25B2";
2676
+ var ARROW_DOWN = "\u25BC";
2677
+ function renderOffViewportIndicators(ctx, levels, priceRange, theme, chartWidth, chartHeight, draggingLabel, selectedLabel = null) {
2678
+ const hitAreas = [];
2679
+ const PADDING_PX = 20;
2680
+ const usable = chartHeight - 2 * PADDING_PX;
2681
+ const span = priceRange.max - priceRange.min;
2682
+ const padInPrice = usable > 0 ? span / usable * PADDING_PX : 0;
2683
+ const visualRange = {
2684
+ min: priceRange.min - padInPrice,
2685
+ max: priceRange.max + padInPrice
2686
+ };
2687
+ const { above, below } = collectOffViewportMarkers(levels, visualRange, draggingLabel);
2688
+ if (above.length === 0 && below.length === 0) return { hitAreas };
2689
+ ctx.save();
2690
+ ctx.font = PILL_FONT;
2691
+ ctx.textBaseline = "middle";
2692
+ ctx.textAlign = "center";
2693
+ const PILL_GAP = 4;
2694
+ const renderPill = (direction, text, y, target) => {
2695
+ const textW = ctx.measureText(text).width;
2696
+ const w = Math.ceil(textW) + PILL_PAD_X * 2;
2697
+ const x = chartWidth - PILL_RIGHT_MARGIN - w;
2698
+ ctx.fillStyle = theme.tooltip.background;
2699
+ ctx.strokeStyle = theme.tooltip.border;
2700
+ ctx.lineWidth = 1;
2701
+ roundedRect(ctx, x, y, w, PILL_H, PILL_RADIUS);
2702
+ ctx.fill();
2703
+ ctx.stroke();
2704
+ ctx.fillStyle = theme.tooltip.text;
2705
+ ctx.fillText(text, x + w / 2, y + PILL_H / 2 + 0.5);
2706
+ hitAreas.push({
2707
+ direction,
2708
+ x,
2709
+ y,
2710
+ w,
2711
+ h: PILL_H,
2712
+ targetPrice: target.price,
2713
+ targetLabel: target.label,
2714
+ targetKind: target.kind
2715
+ });
2716
+ return w;
2717
+ };
2718
+ const drawColumn = (direction, markers, baseY) => {
2719
+ if (markers.length === 0) return;
2720
+ const arrow = direction === "up" ? ARROW_UP : ARROW_DOWN;
2721
+ const edge = direction === "up" ? priceRange.max : priceRange.min;
2722
+ const stackDir = direction === "up" ? 1 : -1;
2723
+ let nextY = baseY;
2724
+ const entries = entryCount(markers);
2725
+ if (entries > 0) {
2726
+ const target = nearestEdgeMarker(
2727
+ markers.filter((m) => m.kind === "entry"),
2728
+ edge
2729
+ );
2730
+ renderPill(direction, `${arrow} ${entries}`, nextY, target);
2731
+ nextY += stackDir * (PILL_H + PILL_GAP);
2732
+ }
2733
+ if (selectedLabel !== null) {
2734
+ const bracketMarkers = markers.filter(
2735
+ (m) => m.kind !== "entry" && m.label === selectedLabel
2736
+ );
2737
+ for (const bm of bracketMarkers) {
2738
+ const kindLabel = bm.kind.toUpperCase();
2739
+ renderPill(direction, `${arrow} ${kindLabel}`, nextY, bm);
2740
+ nextY += stackDir * (PILL_H + PILL_GAP);
2741
+ }
2742
+ }
2743
+ };
2744
+ drawColumn("up", above, PILL_V_MARGIN);
2745
+ drawColumn("down", below, chartHeight - PILL_V_MARGIN - PILL_H);
2746
+ ctx.restore();
2747
+ return { hitAreas };
2748
+ }
2749
+ function hitTestOffViewportIndicator(x, y, hitAreas) {
2750
+ return hitAreas.find((h) => x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h) ?? null;
2751
+ }
2752
+ function overlapsOffViewportPill(centerY, halfH, hitAreas) {
2753
+ const top = centerY - halfH;
2754
+ const bottom = centerY + halfH;
2755
+ return hitAreas.some((h) => top < h.y + h.h && bottom > h.y);
2756
+ }
2757
+ function roundedRect(ctx, x, y, w, h, r) {
2758
+ const rr = Math.min(r, w / 2, h / 2);
2759
+ ctx.beginPath();
2760
+ ctx.moveTo(x + rr, y);
2761
+ ctx.lineTo(x + w - rr, y);
2762
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
2763
+ ctx.lineTo(x + w, y + h - rr);
2764
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
2765
+ ctx.lineTo(x + rr, y + h);
2766
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
2767
+ ctx.lineTo(x, y + rr);
2768
+ ctx.quadraticCurveTo(x, y, x + rr, y);
2769
+ ctx.closePath();
2770
+ }
2771
+
2368
2772
  // src/axis/TimeAxis.ts
2369
2773
  var COUNTDOWN_LABEL_HEIGHT = 18;
2370
2774
  var COUNTDOWN_LABEL_PADDING_X = 6;
@@ -2479,10 +2883,20 @@ function renderCandleCountdown(ctx, lastBarTime, intervalMs, lastBarIndex, viewp
2479
2883
  }
2480
2884
 
2481
2885
  // src/axis/PriceAxis.ts
2482
- function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines = 6, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
2886
+ function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
2483
2887
  ctx.fillStyle = theme.axisText;
2484
2888
  ctx.font = `${cfg.fontSize} ${cfg.fontFamily}`;
2485
2889
  ctx.textAlign = "left";
2890
+ if (gridLines === void 0) {
2891
+ const ticks = generatePriceTicks(priceRange, chartHeight);
2892
+ const step2 = ticks.length >= 2 ? ticks[1] - ticks[0] : priceRange.max - priceRange.min;
2893
+ const precision = precisionForStep(step2, pricePrecision);
2894
+ for (const price of ticks) {
2895
+ const y = scale.yToPixel(price, priceRange, chartHeight);
2896
+ ctx.fillText(price.toFixed(precision), chartWidth + 4, y + 4);
2897
+ }
2898
+ return;
2899
+ }
2486
2900
  const step = (priceRange.max - priceRange.min) / gridLines;
2487
2901
  for (let i = 0; i <= gridLines; i++) {
2488
2902
  const price = priceRange.min + step * i;
@@ -3097,6 +3511,8 @@ var ICON_TOOL = {
3097
3511
  14
3098
3512
  )
3099
3513
  };
3514
+ 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>`;
3515
+ var ICON_TFC_OFF = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="1.5,12.5 5,9 7,11 14,4" stroke="#26a69a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><polyline points="10,4 14,4 14,8" stroke="#26a69a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><polyline points="1.5,3.5 5,7 7,5 14,12" stroke="#ef5350" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><polyline points="10,12 14,12 14,8" stroke="#ef5350" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"/><line x1="3" y1="3" x2="13" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
3100
3516
  var iconScrollToEnd = (c, size = 16) => `<svg viewBox="0 0 16 16" width="${size}" height="${size}" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 4L6 8L1 12Z" fill="${c}"/><path d="M6 4L11 8L6 12Z" fill="${c}"/><line x1="13.5" y1="3.5" x2="13.5" y2="12.5" stroke="${c}" stroke-width="2" stroke-linecap="round"/></svg>`;
3101
3517
  var iconResetView = (c, size = 16) => `<svg viewBox="0 0 16 16" width="${size}" height="${size}" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 2A6 6 0 1 1 2 8" stroke="${c}" stroke-width="1.6" stroke-linecap="round"/><path d="M4 10L2 8L1 10" stroke="${c}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
3102
3518
  var ICON_TAB = {
@@ -3132,7 +3548,7 @@ function injectFlyoutScrollbarStyles() {
3132
3548
  document.head.appendChild(style);
3133
3549
  }
3134
3550
  var TopBar = class {
3135
- constructor(onSeriesChange, onTimeframeChange, onAddIndicator, onDurationChange, onSettingsClick, onFullscreenToggle, onDrawingToolsToggle, showDrawingToolsBtn, showSettingsBtn, initialSeries, initialTf, initialDuration, durationMap, theme, cfg = DEFAULT_TOP_BAR_CONFIG, labels = DEFAULT_LABELS.topBar) {
3551
+ constructor(onSeriesChange, onTimeframeChange, onAddIndicator, onDurationChange, onSettingsClick, onFullscreenToggle, onDrawingToolsToggle, showDrawingToolsBtn, showSettingsBtn, showTfcBtn, initialTfcActive, onTfcToggle, initialSeries, initialTf, initialDuration, durationMap, theme, cfg = DEFAULT_TOP_BAR_CONFIG, labels = DEFAULT_LABELS.topBar) {
3136
3552
  this.onSeriesChange = onSeriesChange;
3137
3553
  this.onTimeframeChange = onTimeframeChange;
3138
3554
  this.onAddIndicator = onAddIndicator;
@@ -3140,11 +3556,14 @@ var TopBar = class {
3140
3556
  this.onSettingsClick = onSettingsClick;
3141
3557
  this.onFullscreenToggle = onFullscreenToggle;
3142
3558
  this.onDrawingToolsToggle = onDrawingToolsToggle;
3559
+ this.onTfcToggle = onTfcToggle;
3143
3560
  this.settingsBtn = null;
3144
3561
  this.isFullscreen = false;
3145
3562
  this.durationBtns = /* @__PURE__ */ new Map();
3146
3563
  this.isMobileLayout = false;
3147
3564
  this.drawingBtn = null;
3565
+ this.tfcBtn = null;
3566
+ this.tfcActive = true;
3148
3567
  this.openDropdown = null;
3149
3568
  this.indicatorOptions = [];
3150
3569
  this.activeIndicators = /* @__PURE__ */ new Set();
@@ -3152,6 +3571,7 @@ var TopBar = class {
3152
3571
  this.theme = theme;
3153
3572
  this.cfg = cfg;
3154
3573
  this.labels = labels;
3574
+ this.tfcActive = initialTfcActive;
3155
3575
  this.currentSeries = initialSeries;
3156
3576
  this.currentTf = initialTf;
3157
3577
  this.activeDuration = initialDuration;
@@ -3228,6 +3648,21 @@ var TopBar = class {
3228
3648
  this.addBtnHover(this.settingsBtn);
3229
3649
  this.rightGroup.appendChild(this.settingsBtn);
3230
3650
  }
3651
+ if (showTfcBtn) {
3652
+ this.tfcBtn = document.createElement("button");
3653
+ this.tfcBtn.title = this.labels.tfcToggleTitle;
3654
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3655
+ this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3656
+ this.tfcBtn.addEventListener("click", (e) => {
3657
+ e.stopPropagation();
3658
+ this.tfcActive = !this.tfcActive;
3659
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3660
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, this.tfcActive);
3661
+ this.onTfcToggle?.(this.tfcActive);
3662
+ });
3663
+ this.addBtnHover(this.tfcBtn);
3664
+ this.rightGroup.appendChild(this.tfcBtn);
3665
+ }
3231
3666
  this.el.append(this.leftGroup, this.rightGroup);
3232
3667
  this.docClickHandler = (e) => {
3233
3668
  if (!this.el.contains(e.target) && !this.flyout.contains(e.target)) {
@@ -3257,6 +3692,15 @@ var TopBar = class {
3257
3692
  if (!this.drawingBtn) return;
3258
3693
  this.applyIconBtnStyles(this.drawingBtn, this.theme, active);
3259
3694
  }
3695
+ setTfcActive(active) {
3696
+ if (!this.tfcBtn) return;
3697
+ this.tfcActive = active;
3698
+ this.tfcBtn.innerHTML = active ? ICON_TFC : ICON_TFC_OFF;
3699
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, active);
3700
+ if (this.isMobileLayout) {
3701
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + 2);
3702
+ }
3703
+ }
3260
3704
  setTheme(theme) {
3261
3705
  this.theme = theme;
3262
3706
  this.closeDropdown();
@@ -3265,6 +3709,7 @@ var TopBar = class {
3265
3709
  if (this.drawingBtn) this.applyIconBtnStyles(this.drawingBtn, theme, false);
3266
3710
  this.applyIconBtnStyles(this.fullscreenBtn, theme, this.isFullscreen);
3267
3711
  if (this.settingsBtn) this.applyIconBtnStyles(this.settingsBtn, theme, false);
3712
+ if (this.tfcBtn) this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3268
3713
  this.applySeriesBtnStyles(this.seriesBtn, theme, false);
3269
3714
  this.applyDropBtnStyles(this.tfBtn, theme, false);
3270
3715
  this.applyDropBtnStyles(this.indBtn, theme, false);
@@ -3305,7 +3750,7 @@ var TopBar = class {
3305
3750
  this.durationSep.style.display = btnDisplay;
3306
3751
  this.durationDropBtn.style.display = isMobile ? "" : "none";
3307
3752
  const containerSize = isMobile ? this.cfg.mobileIconBtnSize : this.cfg.iconBtnSize;
3308
- for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn]) {
3753
+ for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn, this.tfcBtn]) {
3309
3754
  if (!btn) continue;
3310
3755
  btn.style.width = containerSize;
3311
3756
  btn.style.height = containerSize;
@@ -3315,6 +3760,9 @@ var TopBar = class {
3315
3760
  if (this.settingsBtn) {
3316
3761
  this.resizeSvg(this.settingsBtn, this.getSvgSize(this.settingsBtn) + delta);
3317
3762
  }
3763
+ if (this.tfcBtn) {
3764
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + delta);
3765
+ }
3318
3766
  if (this.drawingBtn) {
3319
3767
  const drawIconSize = isMobile ? parseInt(this.cfg.mobileDrawBtnIconSize, 10) : parseInt(this.cfg.drawBtnIconFontSize, 10);
3320
3768
  this.resizeSvg(this.drawingBtn, drawIconSize);
@@ -8143,7 +8591,7 @@ function pointToSegmentDist19(px, py, x1, y1, x2, y2) {
8143
8591
  const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
8144
8592
  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
8145
8593
  }
8146
- function roundedRect(ctx, x, y, w, h, r) {
8594
+ function roundedRect2(ctx, x, y, w, h, r) {
8147
8595
  ctx.beginPath();
8148
8596
  ctx.moveTo(x + r, y);
8149
8597
  ctx.lineTo(x + w - r, y);
@@ -8194,7 +8642,7 @@ var Callout = class {
8194
8642
  ctx.beginPath();
8195
8643
  ctx.arc(tipX, tipY, 3, 0, Math.PI * 2);
8196
8644
  ctx.fill();
8197
- roundedRect(ctx, bx, by, boxW, BOX_H, RADIUS);
8645
+ roundedRect2(ctx, bx, by, boxW, BOX_H, RADIUS);
8198
8646
  ctx.fillStyle = "#37474f";
8199
8647
  ctx.fill();
8200
8648
  ctx.strokeStyle = "#546e7a";
@@ -8246,7 +8694,7 @@ var Callout = class {
8246
8694
  // src/drawings/PriceLabel.ts
8247
8695
  var HIT_TOLERANCE42 = 6;
8248
8696
  var BADGE_W = 60;
8249
- var BADGE_H = 18;
8697
+ var BADGE_H2 = 18;
8250
8698
  var ARROW_W = 7;
8251
8699
  var PriceLabel = class {
8252
8700
  constructor(point) {
@@ -8263,21 +8711,21 @@ var PriceLabel = class {
8263
8711
  ctx.fillStyle = "#26a69a";
8264
8712
  ctx.beginPath();
8265
8713
  ctx.moveTo(x, y);
8266
- ctx.lineTo(x + ARROW_W, y - BADGE_H / 2);
8267
- ctx.lineTo(x + ARROW_W, y + BADGE_H / 2);
8714
+ ctx.lineTo(x + ARROW_W, y - BADGE_H2 / 2);
8715
+ ctx.lineTo(x + ARROW_W, y + BADGE_H2 / 2);
8268
8716
  ctx.closePath();
8269
8717
  ctx.fill();
8270
8718
  const bx = x + ARROW_W;
8271
- const by = y - BADGE_H / 2;
8719
+ const by = y - BADGE_H2 / 2;
8272
8720
  ctx.fillStyle = "#26a69a";
8273
8721
  ctx.beginPath();
8274
- ctx.roundRect(bx, by, BADGE_W, BADGE_H, BADGE_H / 2);
8722
+ ctx.roundRect(bx, by, BADGE_W, BADGE_H2, BADGE_H2 / 2);
8275
8723
  ctx.fill();
8276
8724
  if (isSelected) {
8277
8725
  ctx.strokeStyle = "#80cbc4";
8278
8726
  ctx.lineWidth = 1.5;
8279
8727
  ctx.beginPath();
8280
- ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H + 4, BADGE_H / 2 + 2);
8728
+ ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H2 + 4, BADGE_H2 / 2 + 2);
8281
8729
  ctx.stroke();
8282
8730
  }
8283
8731
  ctx.font = "11px 'Inter', system-ui, sans-serif";
@@ -8291,8 +8739,8 @@ var PriceLabel = class {
8291
8739
  const ax = scale.xToPixel(this.points[0].barIndex, viewport, chartW);
8292
8740
  const ay = scale.yToPixel(this.points[0].price, priceRange, chartH);
8293
8741
  const bx = ax + ARROW_W;
8294
- const by = ay - BADGE_H / 2;
8295
- return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H + HIT_TOLERANCE42;
8742
+ const by = ay - BADGE_H2 / 2;
8743
+ return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H2 + HIT_TOLERANCE42;
8296
8744
  }
8297
8745
  getHandle() {
8298
8746
  return null;
@@ -8412,7 +8860,7 @@ var Flag = class {
8412
8860
 
8413
8861
  // src/drawings/PriceNote.ts
8414
8862
  var HIT_TOLERANCE44 = 6;
8415
- var BADGE_H2 = 16;
8863
+ var BADGE_H3 = 16;
8416
8864
  var BADGE_PADDING_X = 6;
8417
8865
  var TICK_HALF = 10;
8418
8866
  var PriceNote = class {
@@ -8438,10 +8886,10 @@ var PriceNote = class {
8438
8886
  const tw = ctx.measureText(label).width;
8439
8887
  const badgeW = tw + BADGE_PADDING_X * 2;
8440
8888
  const bx = x + TICK_HALF + 2;
8441
- const by = y - BADGE_H2 / 2;
8889
+ const by = y - BADGE_H3 / 2;
8442
8890
  ctx.fillStyle = "#ffa726";
8443
8891
  ctx.beginPath();
8444
- ctx.roundRect(bx, by, badgeW, BADGE_H2, 3);
8892
+ ctx.roundRect(bx, by, badgeW, BADGE_H3, 3);
8445
8893
  ctx.fill();
8446
8894
  ctx.fillStyle = "#ffffff";
8447
8895
  ctx.textBaseline = "middle";
@@ -8461,7 +8909,7 @@ var PriceNote = class {
8461
8909
 
8462
8910
  // src/drawings/AnchoredNote.ts
8463
8911
  var HIT_TOLERANCE45 = 6;
8464
- var BADGE_H3 = 20;
8912
+ var BADGE_H4 = 20;
8465
8913
  var PAD_H2 = 10;
8466
8914
  function pointToSegmentDist21(px, py, x1, y1, x2, y2) {
8467
8915
  const dx = x2 - x1, dy = y2 - y1;
@@ -8494,7 +8942,7 @@ var AnchoredNote = class {
8494
8942
  ctx.lineWidth = isSelected ? 1.5 : 1;
8495
8943
  const badgeW = this.getBadgeW(ctx);
8496
8944
  const bx = x2 - badgeW / 2;
8497
- const by = y2 - BADGE_H3 / 2;
8945
+ const by = y2 - BADGE_H4 / 2;
8498
8946
  ctx.strokeStyle = "#42a5f5";
8499
8947
  ctx.setLineDash([4, 3]);
8500
8948
  ctx.beginPath();
@@ -8508,12 +8956,12 @@ var AnchoredNote = class {
8508
8956
  ctx.fill();
8509
8957
  ctx.fillStyle = "#37474f";
8510
8958
  ctx.beginPath();
8511
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
8959
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8512
8960
  ctx.fill();
8513
8961
  ctx.strokeStyle = "#42a5f5";
8514
8962
  ctx.lineWidth = isSelected ? 1.5 : 1;
8515
8963
  ctx.beginPath();
8516
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
8964
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8517
8965
  ctx.stroke();
8518
8966
  const label = this.text || "Note";
8519
8967
  const isPlaceholder = !this.text;
@@ -8532,8 +8980,8 @@ var AnchoredNote = class {
8532
8980
  const y2 = scale.yToPixel(p2.price, priceRange, chartH);
8533
8981
  const approxBadgeW = Math.max(50, (this.text?.length ?? 4) * 7 + 20);
8534
8982
  const bx = x2 - approxBadgeW / 2;
8535
- const by = y2 - BADGE_H3 / 2;
8536
- if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H3 + HIT_TOLERANCE45) {
8983
+ const by = y2 - BADGE_H4 / 2;
8984
+ if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H4 + HIT_TOLERANCE45) {
8537
8985
  return true;
8538
8986
  }
8539
8987
  if (pointToSegmentDist21(x, y, x1, y1, x2, y2) <= HIT_TOLERANCE45) {
@@ -12694,9 +13142,14 @@ var TradeDragHandler = class _TradeDragHandler {
12694
13142
  this._isDraggingBracket = false;
12695
13143
  // Main line drag state
12696
13144
  this._level = null;
12697
- this._startY = 0;
12698
13145
  this._startPrice = 0;
12699
13146
  this._currentPrice = 0;
13147
+ // Pixel offset between click Y and the level's rendered Y at drag start.
13148
+ // Why: the drag hit-strip is 8 px tall; without this, the cursor-to-price
13149
+ // mapping would snap the level to wherever the cursor is instead of
13150
+ // preserving the grab point. Also immunises the drag from any priceRange
13151
+ // animation (lerp) that happens between drag-start and first mousemove.
13152
+ this._pixelOffset = 0;
12700
13153
  // Entry-price constraint bounds (applied when SL/TP exist on the level)
12701
13154
  this._mainMinPrice = -Infinity;
12702
13155
  this._mainMaxPrice = Infinity;
@@ -12704,6 +13157,7 @@ var TradeDragHandler = class _TradeDragHandler {
12704
13157
  this._bracketType = null;
12705
13158
  this._bracketStartPrice = 0;
12706
13159
  this._bracketCurrentPrice = 0;
13160
+ this._bracketPixelOffset = 0;
12707
13161
  // Bracket constraint state (populated on startBracketDrag)
12708
13162
  this._bracketParentPrice = 0;
12709
13163
  this._bracketParentSide = "buy";
@@ -12715,11 +13169,11 @@ var TradeDragHandler = class _TradeDragHandler {
12715
13169
  * Pass `slPrice` / `tpPrice` / `side` to constrain the drag between SL and TP
12716
13170
  * (used for ES/EL entry orders where the entry price must stay between brackets).
12717
13171
  */
12718
- startDrag(level, startY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
13172
+ startDrag(level, clickY, levelPixelY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
12719
13173
  this._active = true;
12720
13174
  this._isDraggingBracket = false;
12721
13175
  this._level = level;
12722
- this._startY = startY;
13176
+ this._pixelOffset = clickY - levelPixelY;
12723
13177
  this._startPrice = startPrice;
12724
13178
  this._currentPrice = startPrice;
12725
13179
  this._bracketType = null;
@@ -12751,13 +13205,14 @@ var TradeDragHandler = class _TradeDragHandler {
12751
13205
  * @param bracketType 'stopLoss' | 'takeProfit'
12752
13206
  * @param startPrice Current bracket price
12753
13207
  */
12754
- startBracketDrag(parentLevel, bracketType, startPrice, currentMarketPrice = 0, parentPriceOverride) {
13208
+ startBracketDrag(parentLevel, bracketType, startPrice, clickY, bracketPixelY, currentMarketPrice = 0, parentPriceOverride) {
12755
13209
  this._active = true;
12756
13210
  this._isDraggingBracket = true;
12757
13211
  this._level = parentLevel;
12758
13212
  this._bracketType = bracketType;
12759
13213
  this._bracketStartPrice = startPrice;
12760
13214
  this._bracketCurrentPrice = startPrice;
13215
+ this._bracketPixelOffset = clickY - bracketPixelY;
12761
13216
  this._bracketParentPrice = parentPriceOverride ?? parentLevel.price;
12762
13217
  this._bracketParentSide = parentLevel.side ?? "buy";
12763
13218
  this._bracketMarketPrice = currentMarketPrice;
@@ -12768,9 +13223,10 @@ var TradeDragHandler = class _TradeDragHandler {
12768
13223
  */
12769
13224
  onMouseMove(currentY, scale, priceRange, priceH) {
12770
13225
  if (!this._active || !this._level) return null;
12771
- const rawPrice = scale.pixelToPrice(currentY, priceRange, priceH);
12772
13226
  if (this._isDraggingBracket && this._bracketType !== null) {
12773
- const clampedPrice = this._clampBracketPrice(rawPrice, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
13227
+ const effectiveY2 = currentY - this._bracketPixelOffset;
13228
+ const rawPrice2 = scale.pixelToPrice(effectiveY2, priceRange, priceH);
13229
+ const clampedPrice = this._clampBracketPrice(rawPrice2, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
12774
13230
  this._bracketCurrentPrice = clampedPrice;
12775
13231
  return {
12776
13232
  newPrice: clampedPrice,
@@ -12778,6 +13234,8 @@ var TradeDragHandler = class _TradeDragHandler {
12778
13234
  isBracket: true
12779
13235
  };
12780
13236
  }
13237
+ const effectiveY = currentY - this._pixelOffset;
13238
+ const rawPrice = scale.pixelToPrice(effectiveY, priceRange, priceH);
12781
13239
  const constrained = Math.max(this._mainMinPrice, Math.min(this._mainMaxPrice, rawPrice));
12782
13240
  this._currentPrice = constrained;
12783
13241
  return {
@@ -12876,10 +13334,12 @@ var TradeDragHandler = class _TradeDragHandler {
12876
13334
  this._level = null;
12877
13335
  this._bracketType = null;
12878
13336
  this._currentPrice = 0;
13337
+ this._pixelOffset = 0;
12879
13338
  this._mainMinPrice = -Infinity;
12880
13339
  this._mainMaxPrice = Infinity;
12881
13340
  this._bracketStartPrice = 0;
12882
13341
  this._bracketCurrentPrice = 0;
13342
+ this._bracketPixelOffset = 0;
12883
13343
  this._bracketParentPrice = 0;
12884
13344
  this._bracketParentSide = "buy";
12885
13345
  this._bracketMarketPrice = 0;
@@ -13912,6 +14372,8 @@ var _ChartEngine = class _ChartEngine {
13912
14372
  this.showBidAskLines = false;
13913
14373
  this.showActLogo = false;
13914
14374
  this.hideLevelConfirmCancel = false;
14375
+ this.levelClusteringEnabled = true;
14376
+ this.clusterThresholdDistance = 20;
13915
14377
  this.enableThemeToggle = false;
13916
14378
  this.activeIndicators = [];
13917
14379
  this.indicatorResults = /* @__PURE__ */ new Map();
@@ -13985,11 +14447,16 @@ var _ChartEngine = class _ChartEngine {
13985
14447
  // Trade / position levels
13986
14448
  this.levels = [];
13987
14449
  this.tradeLevelHitAreas = [];
14450
+ this.offViewportHitAreas = [];
13988
14451
  this.tradeLevelEditAreas = [];
13989
14452
  this.tradeHoverAreas = [];
13990
14453
  this.tradeLevelDragAreas = [];
13991
14454
  this.tradeLevelConfirmAreas = [];
13992
14455
  this.tradeAddBracketAreas = [];
14456
+ this.clusterBadgeAreas = [];
14457
+ this.lastClusters = [];
14458
+ /** Set of cluster IDs that are currently expanded (fanned out). */
14459
+ this.expandedClusters = /* @__PURE__ */ new Set();
13993
14460
  this.hoveredTradeLabel = null;
13994
14461
  this.selectedTradeLabel = null;
13995
14462
  /** Estimated PNL text for draft order SL/TP bracket lines, set by setDraftBracketPnl(). */
@@ -14004,6 +14471,8 @@ var _ChartEngine = class _ChartEngine {
14004
14471
  // Inline drag confirmation — accumulates changes per level until user confirms
14005
14472
  this.pendingLevelDrags = /* @__PURE__ */ new Map();
14006
14473
  // Trade from Charts settings
14474
+ this.tfcEnabled = true;
14475
+ this.tfcActive = true;
14007
14476
  this.tradeDisplayFilter = "all";
14008
14477
  this.positionRenderStyle = "dot";
14009
14478
  this.tradesThresholdForHorizontalLine = 2;
@@ -14014,7 +14483,9 @@ var _ChartEngine = class _ChartEngine {
14014
14483
  this.dataLoader = null;
14015
14484
  this.isFetching = false;
14016
14485
  this.noMoreData = false;
14486
+ this._loadGeneration = 0;
14017
14487
  this.maxCandles = 200;
14488
+ this.prefetchThreshold = 80;
14018
14489
  this.defaultVisibleBars = DEFAULT_VISIBLE_BARS;
14019
14490
  this.defaultMobileVisibleBars = void 0;
14020
14491
  this.mobileBarDivisor = 2;
@@ -14052,7 +14523,12 @@ var _ChartEngine = class _ChartEngine {
14052
14523
  if (this.tradeButton) {
14053
14524
  if (isInChartArea && y >= 0 && y <= priceH && !this._isTradingActive && !hoveringDotLevel) {
14054
14525
  this.tradeButton.setY(y);
14055
- this.tradeButton.show();
14526
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
14527
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
14528
+ this.tradeButton.hide();
14529
+ } else if (this.tfcActive) {
14530
+ this.tradeButton.show();
14531
+ }
14056
14532
  this._tradeButtonClickData = { price, y };
14057
14533
  const allBars = this.dataStore.all;
14058
14534
  const lastClose = allBars.length > 0 ? allBars[allBars.length - 1].close : price;
@@ -14154,7 +14630,7 @@ var _ChartEngine = class _ChartEngine {
14154
14630
  return;
14155
14631
  }
14156
14632
  if (!this.drawingManager.getActiveTool()) {
14157
- if (hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas) || hitTestTradeLevelEdit(x, y, this.tradeLevelEditAreas) || hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas)) {
14633
+ if (hitTestClusterBadge(x, y, this.clusterBadgeAreas) || hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas) || hitTestTradeLevelEdit(x, y, this.tradeLevelEditAreas) || hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas) || hitTestOffViewportIndicator(x, y, this.offViewportHitAreas)) {
14158
14634
  this.drawCanvas.style.cursor = "pointer";
14159
14635
  this.scheduleRender();
14160
14636
  return;
@@ -14366,6 +14842,25 @@ var _ChartEngine = class _ChartEngine {
14366
14842
  this.scheduleRender();
14367
14843
  return;
14368
14844
  }
14845
+ const clusterHitTouch = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
14846
+ if (clusterHitTouch) {
14847
+ e.preventDefault();
14848
+ this.panHandler.cancel();
14849
+ if (this.expandedClusters.has(clusterHitTouch.clusterId)) {
14850
+ this.expandedClusters.delete(clusterHitTouch.clusterId);
14851
+ } else {
14852
+ this.expandedClusters.add(clusterHitTouch.clusterId);
14853
+ }
14854
+ this.renderTradeLayer();
14855
+ return;
14856
+ }
14857
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
14858
+ if (offVpHit) {
14859
+ e.preventDefault();
14860
+ this.panHandler.cancel();
14861
+ this.scrollToCenterPrice(offVpHit.targetPrice);
14862
+ return;
14863
+ }
14369
14864
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
14370
14865
  if (confirmHit) {
14371
14866
  e.preventDefault();
@@ -14422,6 +14917,10 @@ var _ChartEngine = class _ChartEngine {
14422
14917
  }
14423
14918
  this.drawingManager.selectDrawing(null);
14424
14919
  this.hideMobileDrawingFlyout();
14920
+ if (this.expandedClusters.size > 0) {
14921
+ this.expandedClusters.clear();
14922
+ this.renderTradeLayer();
14923
+ }
14425
14924
  const isInChartArea = x < chartW && y < timeAxisY;
14426
14925
  if (isInChartArea) {
14427
14926
  if (this._mobileCrosshairActive) {
@@ -14571,7 +15070,12 @@ var _ChartEngine = class _ChartEngine {
14571
15070
  if (this._isTradingActive) {
14572
15071
  this.tradeButton.hide();
14573
15072
  } else {
14574
- this.tradeButton.show();
15073
+ const _btnHalfH2 = this.uiConfig.tradeButton.size / 2;
15074
+ if (overlapsOffViewportPill(y, _btnHalfH2, this.offViewportHitAreas)) {
15075
+ this.tradeButton.hide();
15076
+ } else if (this.tfcActive) {
15077
+ this.tradeButton.show();
15078
+ }
14575
15079
  }
14576
15080
  this._tradeButtonClickData = { price, y };
14577
15081
  const _allBars = this.dataStore.all;
@@ -14653,6 +15157,21 @@ var _ChartEngine = class _ChartEngine {
14653
15157
  const rawBarIndex = this.scaleManager.pixelToBarIndex(x, this.viewport, chartW);
14654
15158
  const price = this.scaleManager.pixelToPrice(y, priceRange, priceH);
14655
15159
  const barIndex = Math.round(rawBarIndex);
15160
+ const clusterHit = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
15161
+ if (clusterHit) {
15162
+ if (this.expandedClusters.has(clusterHit.clusterId)) {
15163
+ this.expandedClusters.delete(clusterHit.clusterId);
15164
+ } else {
15165
+ this.expandedClusters.add(clusterHit.clusterId);
15166
+ }
15167
+ this.renderTradeLayer();
15168
+ return;
15169
+ }
15170
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
15171
+ if (offVpHit) {
15172
+ this.scrollToCenterPrice(offVpHit.targetPrice);
15173
+ return;
15174
+ }
14656
15175
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
14657
15176
  if (confirmHit) {
14658
15177
  if (confirmHit.action === "confirm") this.applyPendingChanges(confirmHit.label);
@@ -14737,6 +15256,10 @@ var _ChartEngine = class _ChartEngine {
14737
15256
  this.renderTradeLayer();
14738
15257
  }
14739
15258
  }
15259
+ if (this.expandedClusters.size > 0) {
15260
+ this.expandedClusters.clear();
15261
+ this.renderTradeLayer();
15262
+ }
14740
15263
  const timeAxisY = rect.height - this.timeAxisHeight;
14741
15264
  if (x >= chartW) {
14742
15265
  if (y <= priceH) {
@@ -14852,6 +15375,12 @@ var _ChartEngine = class _ChartEngine {
14852
15375
  this.scheduleRender();
14853
15376
  };
14854
15377
  this.onKeyDown = (e) => {
15378
+ if (e.key === "Escape" && this.expandedClusters.size > 0) {
15379
+ this.expandedClusters.clear();
15380
+ this.renderTradeLayer();
15381
+ e.preventDefault();
15382
+ return;
15383
+ }
14855
15384
  if (e.key === "Escape" && this.pendingLevelDrags.size > 0) {
14856
15385
  this.revertPendingChanges();
14857
15386
  e.preventDefault();
@@ -14938,7 +15467,9 @@ var _ChartEngine = class _ChartEngine {
14938
15467
  durationTimeframeMap,
14939
15468
  uiConfig,
14940
15469
  labels,
14941
- tickActivityMs = 3e4
15470
+ tickActivityMs = 3e4,
15471
+ hideSymbolAndTick = false,
15472
+ showBottomBar = false
14942
15473
  } = config;
14943
15474
  this.dataLoader = dataLoader ?? null;
14944
15475
  this.drawingToolsEnabled = config.showDrawingTools !== false;
@@ -14947,6 +15478,7 @@ var _ChartEngine = class _ChartEngine {
14947
15478
  this.labels = resolveLabels(labels);
14948
15479
  this.tickActivityMs = tickActivityMs;
14949
15480
  this.maxCandles = config.maxCandles ?? 200;
15481
+ this.prefetchThreshold = config.prefetchThreshold ?? 80;
14950
15482
  this.defaultVisibleBars = config.defaultVisibleBars ?? DEFAULT_VISIBLE_BARS;
14951
15483
  this.defaultMobileVisibleBars = config.defaultMobileVisibleBars;
14952
15484
  this.explicitMobileDivisor = config.mobileBarDivisor !== void 0;
@@ -14986,7 +15518,11 @@ var _ChartEngine = class _ChartEngine {
14986
15518
  this.showBidAskLines = config.showBidAskLines ?? false;
14987
15519
  this.showActLogo = config.showActLogo ?? false;
14988
15520
  this.hideLevelConfirmCancel = config.hideLevelConfirmCancel ?? false;
15521
+ this.levelClusteringEnabled = config.levelClusteringEnabled ?? true;
15522
+ this.clusterThresholdDistance = config.clusterThresholdDistance ?? 20;
14989
15523
  this.enableThemeToggle = config.enableThemeToggle ?? false;
15524
+ this.tfcEnabled = config.tfcEnabled !== false;
15525
+ this.tfcActive = this.tfcEnabled;
14990
15526
  this.showCandleCountdown = config.showCandleCountdown ?? true;
14991
15527
  this.candleCountdownTimeframes = config.candleCountdownTimeframes;
14992
15528
  this._onOrderSubmit = onOrderSubmit;
@@ -15050,6 +15586,11 @@ var _ChartEngine = class _ChartEngine {
15050
15586
  this.drawingToolsEnabled ? () => this.toggleDrawingTools() : null,
15051
15587
  this.drawingToolsEnabled,
15052
15588
  this.settingsEnabled,
15589
+ this.tfcEnabled,
15590
+ this.tfcActive,
15591
+ this.tfcEnabled ? (enabled) => {
15592
+ this.setTfcActive(enabled);
15593
+ } : null,
15053
15594
  this.series,
15054
15595
  this.timeframe,
15055
15596
  null,
@@ -15201,7 +15742,7 @@ var _ChartEngine = class _ChartEngine {
15201
15742
  this.loadingOverlayEl.appendChild(loadingLabel);
15202
15743
  this.canvasWrap.appendChild(this.loadingOverlayEl);
15203
15744
  }
15204
- if (showUI) {
15745
+ if (showUI && !hideSymbolAndTick) {
15205
15746
  this.siEl = document.createElement("div");
15206
15747
  Object.assign(this.siEl.style, {
15207
15748
  position: "absolute",
@@ -15345,6 +15886,11 @@ var _ChartEngine = class _ChartEngine {
15345
15886
  const rect = this._cachedDrawRect ?? this.drawCanvas.getBoundingClientRect();
15346
15887
  const y = e.clientY - rect.top;
15347
15888
  const { priceH } = this.getChartLayout();
15889
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
15890
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
15891
+ this.tradeButton?.hide();
15892
+ return;
15893
+ }
15348
15894
  if (y >= 0 && y <= priceH) {
15349
15895
  this.tradeButton?.setY(y);
15350
15896
  const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
@@ -15359,7 +15905,7 @@ var _ChartEngine = class _ChartEngine {
15359
15905
  }
15360
15906
  middleRow.appendChild(this.canvasWrap);
15361
15907
  this.wrapper.appendChild(middleRow);
15362
- if (showUI) {
15908
+ if (showUI && showBottomBar) {
15363
15909
  const resolvedDurationMap = { ...DURATION_TIMEFRAME_MAP, ...durationTimeframeMap };
15364
15910
  this.bottomBar = new BottomBar(
15365
15911
  (d, tf) => {
@@ -15658,6 +16204,7 @@ var _ChartEngine = class _ChartEngine {
15658
16204
  }
15659
16205
  triggerLoad(reason) {
15660
16206
  if (!this.dataLoader) return;
16207
+ const gen = ++this._loadGeneration;
15661
16208
  this.isFetching = true;
15662
16209
  this.noMoreData = false;
15663
16210
  const end = /* @__PURE__ */ new Date();
@@ -15669,6 +16216,7 @@ var _ChartEngine = class _ChartEngine {
15669
16216
  const interval = this.timeframeToInterval(this.getBaseInterval());
15670
16217
  this.setLoading(true);
15671
16218
  this.dataLoader({ start, end, interval }).then((bars) => {
16219
+ if (gen !== this._loadGeneration) return;
15672
16220
  this.loadData(this.maybeAggregate(bars), reason === "duration");
15673
16221
  this.setLoading(false);
15674
16222
  this.emitter.emit("dataLoaded", {
@@ -15678,25 +16226,17 @@ var _ChartEngine = class _ChartEngine {
15678
16226
  end
15679
16227
  });
15680
16228
  }).catch(() => {
16229
+ if (gen !== this._loadGeneration) return;
15681
16230
  this.isFetching = false;
15682
16231
  this.setLoading(false);
15683
16232
  });
15684
16233
  }
15685
16234
  checkNeedMoreData() {
15686
16235
  if (!this.dataLoader || this.isFetching || this.noMoreData) return;
15687
- const threshold = Math.max(
15688
- 20,
15689
- Math.round(
15690
- initialVisibleBars(
15691
- this.mobileBarDivisor,
15692
- this.explicitMobileDivisor,
15693
- this.defaultVisibleBars,
15694
- this.defaultMobileVisibleBars
15695
- ) * 0.25
15696
- )
15697
- );
16236
+ const threshold = Math.max(20, this.prefetchThreshold);
15698
16237
  if (this._targetViewport.startIndex > threshold) return;
15699
16238
  if (this.dataStore.length === 0) return;
16239
+ const gen = this._loadGeneration;
15700
16240
  this.isFetching = true;
15701
16241
  const before = this.dataStore.all[0].time;
15702
16242
  const end = new Date(before - 1);
@@ -15708,9 +16248,11 @@ var _ChartEngine = class _ChartEngine {
15708
16248
  const interval = this.timeframeToInterval(this.getBaseInterval());
15709
16249
  this.setLoading(true);
15710
16250
  this.dataLoader({ start, end, interval }).then((bars) => {
16251
+ if (gen !== this._loadGeneration) return;
15711
16252
  this.prependData(this.maybeAggregate(bars));
15712
16253
  this.setLoading(false);
15713
16254
  }).catch(() => {
16255
+ if (gen !== this._loadGeneration) return;
15714
16256
  this.isFetching = false;
15715
16257
  this.setLoading(false);
15716
16258
  });
@@ -15965,6 +16507,31 @@ var _ChartEngine = class _ChartEngine {
15965
16507
  this.emitStateChange();
15966
16508
  return this;
15967
16509
  }
16510
+ /**
16511
+ * Toggle TFC (Trade from Charts) on or off at runtime.
16512
+ * When disabled, all trade levels, the trade button, and trade popover are hidden.
16513
+ * Only functional when `tfcEnabled` config option is `true` (default).
16514
+ */
16515
+ setTfcActive(enabled) {
16516
+ if (!this.tfcEnabled) return this;
16517
+ this.tfcActive = enabled;
16518
+ this.topBar?.setTfcActive(enabled);
16519
+ if (!enabled) {
16520
+ this.tradeButton?.hide();
16521
+ this.tradePopover?.hide();
16522
+ if (this.draftOrderLabel !== null) {
16523
+ this.removeDraftOrder();
16524
+ }
16525
+ }
16526
+ if (this.chartSettingsDialog) {
16527
+ this.chartSettingsDialog.destroy();
16528
+ this.chartSettingsDialog = null;
16529
+ }
16530
+ this.renderTradeLayer();
16531
+ this.emitter.emit("tfcToggle", { enabled });
16532
+ this.emitStateChange();
16533
+ return this;
16534
+ }
15968
16535
  clearDuration() {
15969
16536
  if (this.duration === void 0) return;
15970
16537
  this.duration = void 0;
@@ -16008,7 +16575,8 @@ var _ChartEngine = class _ChartEngine {
16008
16575
  })),
16009
16576
  positionRenderStyle: this.positionRenderStyle,
16010
16577
  tradeDisplayFilter: this.tradeDisplayFilter,
16011
- canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0
16578
+ canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0,
16579
+ tfcEnabled: this.tfcActive
16012
16580
  };
16013
16581
  }
16014
16582
  /**
@@ -16080,9 +16648,14 @@ var _ChartEngine = class _ChartEngine {
16080
16648
  if (state.canvasColors) {
16081
16649
  this.settingsCanvasColors = state.canvasColors;
16082
16650
  }
16651
+ if (state.tfcEnabled !== void 0 && this.tfcEnabled) {
16652
+ this.tfcActive = state.tfcEnabled;
16653
+ this.topBar?.setTfcActive(state.tfcEnabled);
16654
+ }
16083
16655
  } finally {
16084
16656
  this.isRestoringState = false;
16085
16657
  }
16658
+ this.emitStateChange();
16086
16659
  this.scheduleRender();
16087
16660
  return this;
16088
16661
  }
@@ -16682,7 +17255,27 @@ var _ChartEngine = class _ChartEngine {
16682
17255
  */
16683
17256
  selectLevel(label) {
16684
17257
  this.selectedTradeLabel = label;
16685
- if (label !== null) this.crosshair = null;
17258
+ if (label !== null) {
17259
+ this.crosshair = null;
17260
+ const cluster = this.lastClusters.find(
17261
+ (c) => c.members.some((m) => m.level.label === label)
17262
+ );
17263
+ if (cluster) this.expandedClusters.add(cluster.id);
17264
+ }
17265
+ this.renderTradeLayer();
17266
+ return this;
17267
+ }
17268
+ /** Enable or disable trade-level fan-out clustering at runtime. */
17269
+ setLevelClusteringEnabled(enabled) {
17270
+ this.levelClusteringEnabled = enabled;
17271
+ this.expandedClusters.clear();
17272
+ this.renderTradeLayer();
17273
+ return this;
17274
+ }
17275
+ /** Update the cluster proximity threshold (in pixels) at runtime. Only effective when levelClusteringEnabled is true. */
17276
+ setClusterThresholdDistance(px) {
17277
+ this.clusterThresholdDistance = px;
17278
+ this.expandedClusters.clear();
16686
17279
  this.renderTradeLayer();
16687
17280
  return this;
16688
17281
  }
@@ -16865,7 +17458,7 @@ var _ChartEngine = class _ChartEngine {
16865
17458
  theme: this.themeName
16866
17459
  },
16867
17460
  this.labels.dialogs,
16868
- !!this._onOrderSubmit,
17461
+ !!this._onOrderSubmit && this.tfcActive,
16869
17462
  this.enableThemeToggle
16870
17463
  );
16871
17464
  this.getFloatingRoot().appendChild(this.chartSettingsDialog.el);
@@ -16976,11 +17569,12 @@ var _ChartEngine = class _ChartEngine {
16976
17569
  const dpr = window.devicePixelRatio || 1;
16977
17570
  const { tradeCtx: ctx, tradeCanvas: canvas, theme, viewport } = this;
16978
17571
  ctx.clearRect(0, 0, canvas.width, canvas.height);
16979
- if (this.levels.length === 0 || this.dataStore.length === 0) {
17572
+ if (!this.tfcActive || this.levels.length === 0 || this.dataStore.length === 0) {
16980
17573
  this.tradeLevelHitAreas = [];
16981
17574
  this.tradeLevelEditAreas = [];
16982
17575
  this.tradeHoverAreas = [];
16983
17576
  this.tradeAddBracketAreas = [];
17577
+ this.offViewportHitAreas = [];
16984
17578
  return;
16985
17579
  }
16986
17580
  const { chartW, priceH } = this.getChartLayout();
@@ -17035,6 +17629,7 @@ var _ChartEngine = class _ChartEngine {
17035
17629
  // Include true pending orders AND entry-order positions (ES/EL)
17036
17630
  processedLevels.filter((l) => l.type === "pending" || isEntryOrder(l))
17037
17631
  ) : processedLevels;
17632
+ const clusterThreshold = this.levelClusteringEnabled ? this.clusterThresholdDistance : 0;
17038
17633
  const result = renderTradeLevels(
17039
17634
  ctx,
17040
17635
  filteredLevels,
@@ -17052,9 +17647,22 @@ var _ChartEngine = class _ChartEngine {
17052
17647
  this.positionRenderStyle,
17053
17648
  this.hideLevelConfirmCancel,
17054
17649
  this.draftOrderLabel,
17055
- this.draftBracketPnl
17650
+ this.draftBracketPnl,
17651
+ this.expandedClusters,
17652
+ clusterThreshold
17056
17653
  );
17057
17654
  ctx.restore();
17655
+ const offVpResult = renderOffViewportIndicators(
17656
+ ctx,
17657
+ filteredLevels,
17658
+ priceRange,
17659
+ theme,
17660
+ chartW,
17661
+ priceH,
17662
+ this.tradeDragHandler?.dragLabel ?? null,
17663
+ this.selectedTradeLabel
17664
+ );
17665
+ this.offViewportHitAreas = offVpResult.hitAreas;
17058
17666
  ctx.restore();
17059
17667
  this.tradeLevelHitAreas = result.hitAreas;
17060
17668
  this.tradeLevelEditAreas = result.editAreas;
@@ -17062,6 +17670,14 @@ var _ChartEngine = class _ChartEngine {
17062
17670
  this.tradeLevelDragAreas = result.dragAreas;
17063
17671
  this.tradeLevelConfirmAreas = result.confirmAreas;
17064
17672
  this.tradeAddBracketAreas = result.addBracketAreas;
17673
+ this.clusterBadgeAreas = result.clusterBadgeAreas;
17674
+ this.lastClusters = result.clusters;
17675
+ if (this.expandedClusters.size > 0) {
17676
+ const currentIds = new Set(result.clusters.map((c) => c.id));
17677
+ for (const id of this.expandedClusters) {
17678
+ if (!currentIds.has(id)) this.expandedClusters.delete(id);
17679
+ }
17680
+ }
17065
17681
  }
17066
17682
  // ── Inline pending-change confirmation ────────────────────────────────────
17067
17683
  /** Returns the effective entry price for a level, preferring any staged main drag. */
@@ -17956,7 +18572,12 @@ var _ChartEngine = class _ChartEngine {
17956
18572
  if (this._isTradingActive) {
17957
18573
  this.tradeButton.hide();
17958
18574
  } else {
17959
- this.tradeButton.show();
18575
+ const _btnHalfH1 = this.uiConfig.tradeButton.size / 2;
18576
+ if (overlapsOffViewportPill(y, _btnHalfH1, this.offViewportHitAreas)) {
18577
+ this.tradeButton.hide();
18578
+ } else if (this.tfcActive) {
18579
+ this.tradeButton.show();
18580
+ }
17960
18581
  }
17961
18582
  this._tradeButtonClickData = { price, y };
17962
18583
  const _allBars = this.dataStore.all;
@@ -17969,6 +18590,9 @@ var _ChartEngine = class _ChartEngine {
17969
18590
  /** Shared helper: initiate a trade level main-line drag from a hit area. */
17970
18591
  _startTradeDragFromHit(dragHit, level, hitY) {
17971
18592
  if (!this.tradeDragHandler) return;
18593
+ const { priceH } = this.getChartLayout();
18594
+ const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
18595
+ const levelPixelY = this.scaleManager.yToPixel(dragHit.price, priceRange, priceH);
17972
18596
  if (dragHit.isBracket) {
17973
18597
  this._pendingBracketOrderLabel = dragHit.bracketOrderLabel ?? null;
17974
18598
  this._isBracketNew = false;
@@ -17981,6 +18605,8 @@ var _ChartEngine = class _ChartEngine {
17981
18605
  level,
17982
18606
  dragHit.bracketType,
17983
18607
  dragHit.price,
18608
+ hitY,
18609
+ levelPixelY,
17984
18610
  marketPrice,
17985
18611
  this._effectiveEntryPrice(level.label, level.price)
17986
18612
  );
@@ -18000,6 +18626,7 @@ var _ChartEngine = class _ChartEngine {
18000
18626
  this.tradeDragHandler.startDrag(
18001
18627
  level,
18002
18628
  hitY,
18629
+ levelPixelY,
18003
18630
  dragHit.price,
18004
18631
  posLevel.stopLossPrice,
18005
18632
  posLevel.takeProfitPrice,
@@ -18014,6 +18641,7 @@ var _ChartEngine = class _ChartEngine {
18014
18641
  this.tradeDragHandler.startDrag(
18015
18642
  level,
18016
18643
  hitY,
18644
+ levelPixelY,
18017
18645
  dragHit.price,
18018
18646
  void 0,
18019
18647
  void 0,
@@ -18043,6 +18671,8 @@ var _ChartEngine = class _ChartEngine {
18043
18671
  level,
18044
18672
  addHit.bracketType,
18045
18673
  initialPrice,
18674
+ hitY,
18675
+ btnCenterY,
18046
18676
  marketPrice,
18047
18677
  this._effectiveEntryPrice(level.label, level.price)
18048
18678
  );
@@ -18233,6 +18863,38 @@ var _ChartEngine = class _ChartEngine {
18233
18863
  };
18234
18864
  this.scrollAnimId = requestAnimationFrame(step);
18235
18865
  }
18866
+ /**
18867
+ * Smooth-scroll the Y axis so `price` lands at the vertical center of the
18868
+ * chart. Animates `pricePanOffset` only — horizontal viewport is preserved.
18869
+ * Used by off-viewport level indicator clicks.
18870
+ */
18871
+ scrollToCenterPrice(price) {
18872
+ if (!isFinite(price) || this.dataStore.length === 0) return;
18873
+ if (this.scrollAnimId !== null) {
18874
+ cancelAnimationFrame(this.scrollAnimId);
18875
+ this.scrollAnimId = null;
18876
+ }
18877
+ const base = this.dataStore.getPriceRange(this.viewport);
18878
+ const naturalMid = (base.min + base.max) / 2;
18879
+ const startPan = this.pricePanOffset;
18880
+ const targetPan = price - naturalMid;
18881
+ if (Math.abs(targetPan - startPan) < 1e-9) return;
18882
+ const DURATION = 350;
18883
+ const t0 = performance.now();
18884
+ const ease = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
18885
+ const step = (now) => {
18886
+ const raw = Math.min((now - t0) / DURATION, 1);
18887
+ const prog = ease(raw);
18888
+ this.pricePanOffset = startPan + (targetPan - startPan) * prog;
18889
+ this.render();
18890
+ if (raw < 1) {
18891
+ this.scrollAnimId = requestAnimationFrame(step);
18892
+ } else {
18893
+ this.scrollAnimId = null;
18894
+ }
18895
+ };
18896
+ this.scrollAnimId = requestAnimationFrame(step);
18897
+ }
18236
18898
  resetPriceAxis() {
18237
18899
  this.priceScaleFactor = 1;
18238
18900
  this.pricePanOffset = 0;