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.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: {
@@ -1429,18 +1462,18 @@ function renderHighLow(ctx, bars, viewport, priceRange, scale, theme, chartWidth
1429
1462
  }
1430
1463
 
1431
1464
  // src/overlays/GridOverlay.ts
1432
- function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines = 6) {
1465
+ function renderGrid(ctx, priceRange, scale, theme, chartWidth, chartHeight, gridLines) {
1433
1466
  ctx.save();
1434
1467
  ctx.globalAlpha = 0.6;
1435
1468
  ctx.strokeStyle = theme.grid;
1436
1469
  ctx.lineWidth = 1;
1437
- const step = (priceRange.max - priceRange.min) / gridLines;
1438
1470
  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);
1471
+ {
1472
+ for (const price of generatePriceTicks(priceRange, chartHeight)) {
1473
+ const y = Math.round(scale.yToPixel(price, priceRange, chartHeight)) + 0.5;
1474
+ ctx.moveTo(0, y);
1475
+ ctx.lineTo(chartWidth, y);
1476
+ }
1444
1477
  }
1445
1478
  ctx.stroke();
1446
1479
  const vertCols = Math.max(1, Math.floor(chartWidth / 80));
@@ -1512,6 +1545,66 @@ function renderCrosshair(ctx, pos, priceRange, viewport, scale, theme, chartWidt
1512
1545
  }
1513
1546
  }
1514
1547
 
1548
+ // src/overlays/TradeLevelCluster.ts
1549
+ function detectClusters(measured, thresholdPx, excludeLabels = /* @__PURE__ */ new Set()) {
1550
+ const eligible = measured.filter((m) => !excludeLabels.has(m.level.label)).sort((a, b) => a.y - b.y);
1551
+ if (eligible.length < 2) return [];
1552
+ const groups = [];
1553
+ let currentGroup = [eligible[0]];
1554
+ let groupStartY = eligible[0].y;
1555
+ for (let i = 1; i < eligible.length; i++) {
1556
+ const m = eligible[i];
1557
+ if (m.y - groupStartY <= thresholdPx) {
1558
+ currentGroup.push(m);
1559
+ } else {
1560
+ groups.push(currentGroup);
1561
+ currentGroup = [m];
1562
+ groupStartY = m.y;
1563
+ }
1564
+ }
1565
+ groups.push(currentGroup);
1566
+ return groups.filter((g) => g.length >= 2).map((members) => {
1567
+ const id = "cluster:" + members.map((m) => m.level.label).sort().join("|");
1568
+ const centerY = members.reduce((sum, m) => sum + m.y, 0) / members.length;
1569
+ const centerPrice = members.reduce((sum, m) => sum + m.price, 0) / members.length;
1570
+ return { id, members, centerY, centerPrice };
1571
+ });
1572
+ }
1573
+ function computeFanOutLayout(cluster, boxH, gapPx = 4) {
1574
+ const n = cluster.members.length;
1575
+ const totalH = n * boxH + (n - 1) * gapPx;
1576
+ const startY = cluster.centerY - totalH / 2;
1577
+ const sorted = [...cluster.members].sort((a, b) => a.y - b.y);
1578
+ const result = /* @__PURE__ */ new Map();
1579
+ for (let i = 0; i < sorted.length; i++) {
1580
+ result.set(sorted[i].level.label, startY + i * (boxH + gapPx));
1581
+ }
1582
+ return result;
1583
+ }
1584
+ function buildClusterBadgeLayout(cluster, chartW, ctx) {
1585
+ const n = cluster.members.length;
1586
+ const types = new Set(cluster.members.map((m) => m.level.type));
1587
+ let noun;
1588
+ if (types.size === 1) {
1589
+ const t = types.values().next().value;
1590
+ noun = t === "position" ? "Position" : t === "pending" ? "Order" : "Trade";
1591
+ } else {
1592
+ noun = "Level";
1593
+ }
1594
+ const text = `${n} ${noun}${n > 1 ? "s" : ""}`;
1595
+ const PAD_X = 12;
1596
+ const textW = ctx.measureText(text).width;
1597
+ const EXPAND_ICON_W = 16;
1598
+ const badgeW = PAD_X + textW + EXPAND_ICON_W + PAD_X;
1599
+ const badgeH = 24;
1600
+ const badgeX = (chartW - badgeW) / 2;
1601
+ const badgeY = cluster.centerY - badgeH / 2;
1602
+ return { badgeX, badgeY, badgeW, badgeH, text };
1603
+ }
1604
+ function hitTestClusterBadge(x, y, areas) {
1605
+ return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
1606
+ }
1607
+
1515
1608
  // src/overlays/TradeLevelOverlay.ts
1516
1609
  var BOX_PAD_X = 8;
1517
1610
  var BOX_PAD_Y = 4;
@@ -1630,7 +1723,7 @@ function isDraggable(level) {
1630
1723
  return !!level.entryPriceEditable;
1631
1724
  return false;
1632
1725
  }
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 = {}) {
1726
+ 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) {
1634
1727
  if (levels.length === 0)
1635
1728
  return {
1636
1729
  hitAreas: [],
@@ -1638,7 +1731,9 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1638
1731
  hoverAreas: [],
1639
1732
  dragAreas: [],
1640
1733
  confirmAreas: [],
1641
- addBracketAreas: []
1734
+ addBracketAreas: [],
1735
+ clusterBadgeAreas: [],
1736
+ clusters: []
1642
1737
  };
1643
1738
  const colors = theme.tradeLevels;
1644
1739
  const boxH = ROW_H + BOX_PAD_Y * 2;
@@ -1669,13 +1764,46 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1669
1764
  const bW = BOX_PAD_X + pnlW + labelW + BOX_PAD_X + confirmW + closeW;
1670
1765
  measured.push({ level, price, y, boxW: bW, boxH });
1671
1766
  }
1767
+ const excludeFromClustering = /* @__PURE__ */ new Set();
1768
+ if (draftOrderLabel) excludeFromClustering.add(draftOrderLabel);
1769
+ const clusters = clusterThresholdPx > 0 ? detectClusters(measured, clusterThresholdPx, excludeFromClustering) : [];
1770
+ const collapsedClusters = clusters.filter((c) => !expandedClusters.has(c.id));
1771
+ const expandedClusterList = clusters.filter((c) => expandedClusters.has(c.id));
1772
+ const collapsedLabels = /* @__PURE__ */ new Set();
1773
+ for (const c of collapsedClusters) {
1774
+ for (const m of c.members) collapsedLabels.add(m.level.label);
1775
+ }
1776
+ const expandedFanLayouts = /* @__PURE__ */ new Map();
1777
+ for (const ec of expandedClusterList) {
1778
+ expandedFanLayouts.set(ec.id, computeFanOutLayout(ec, boxH, 4));
1779
+ }
1780
+ const labelToExpandedCluster = /* @__PURE__ */ new Map();
1781
+ for (const ec of expandedClusterList) {
1782
+ for (const m of ec.members) labelToExpandedCluster.set(m.level.label, ec);
1783
+ }
1672
1784
  measured.sort((a, b) => a.y - b.y);
1673
1785
  const placed = [];
1674
1786
  const layouts = /* @__PURE__ */ new Map();
1675
1787
  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;
1788
+ if (collapsedLabels.has(m.level.label)) continue;
1789
+ const ec = labelToExpandedCluster.get(m.level.label);
1790
+ if (ec) {
1791
+ const fanMap = expandedFanLayouts.get(ec.id);
1792
+ const fanY = fanMap?.get(m.level.label);
1793
+ if (fanY !== void 0) {
1794
+ placed.push({ label: m.level.label, boxY: fanY, boxH: m.boxH });
1795
+ layouts.set(m.level.label, { price: m.price, y: m.y, boxY: fanY, boxW: m.boxW, boxH: m.boxH });
1796
+ continue;
1797
+ }
1798
+ }
1799
+ let boxY = m.y - m.boxH / 2;
1800
+ let attempts = 0;
1801
+ while (attempts < placed.length + 1) {
1802
+ const overlaps = placed.some((p) => rectsOverlapY(boxY, m.boxH, p.boxY, p.boxH));
1803
+ if (!overlaps) break;
1804
+ boxY -= m.boxH + 2;
1805
+ attempts++;
1806
+ }
1679
1807
  placed.push({ label: m.level.label, boxY, boxH: m.boxH });
1680
1808
  layouts.set(m.level.label, { price: m.price, y: m.y, boxY, boxW: m.boxW, boxH: m.boxH });
1681
1809
  }
@@ -1691,9 +1819,10 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1691
1819
  selectedLabel
1692
1820
  );
1693
1821
  const activeLabel = hoveredLabel ?? selectedLabel;
1822
+ const visibleMeasured = measured.filter((m) => !collapsedLabels.has(m.level.label));
1694
1823
  const drawOrder = [
1695
- ...measured.filter((m) => m.level.label !== activeLabel),
1696
- ...measured.filter((m) => m.level.label === activeLabel)
1824
+ ...visibleMeasured.filter((m) => m.level.label !== activeLabel),
1825
+ ...visibleMeasured.filter((m) => m.level.label === activeLabel)
1697
1826
  ];
1698
1827
  const hitAreas = [];
1699
1828
  const editAreas = [];
@@ -1701,6 +1830,10 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1701
1830
  const dragAreas = [];
1702
1831
  const confirmAreas = [];
1703
1832
  const addBracketAreas = [];
1833
+ const clusterBadgeAreas = [];
1834
+ for (const cluster of collapsedClusters) {
1835
+ drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas);
1836
+ }
1704
1837
  for (const { level } of drawOrder) {
1705
1838
  const layout = layouts.get(level.label);
1706
1839
  if (!layout) continue;
@@ -1731,9 +1864,134 @@ function renderTradeLevels(ctx, levels, scale, priceRange, theme, chartW, priceH
1731
1864
  hideConfirmCancel,
1732
1865
  level.label === draftOrderLabel ? draftBracketPnl : void 0
1733
1866
  );
1867
+ const ec = labelToExpandedCluster.get(level.label);
1868
+ if (ec && layout.boxY !== layout.y - boxH / 2) {
1869
+ drawFanConnector(ctx, layout.boxY + boxH / 2, layout.y, chartW, theme);
1870
+ }
1871
+ }
1872
+ if (selectedLabel !== null && !layouts.has(selectedLabel)) {
1873
+ const offLevel = levels.find((l) => l.label === selectedLabel);
1874
+ if (offLevel) {
1875
+ const bh = offLevel;
1876
+ const slDragKey = `${offLevel.label}:stopLoss`;
1877
+ const tpDragKey = `${offLevel.label}:takeProfit`;
1878
+ const fallbackX = chartW * BRACKET_BASE_RATIO;
1879
+ if (bh.stopLossPrice !== void 0) {
1880
+ drawBracketLine(
1881
+ ctx,
1882
+ bh,
1883
+ "stopLoss",
1884
+ bh.stopLossPrice,
1885
+ scale,
1886
+ priceRange,
1887
+ priceH,
1888
+ bracketLineXMap.get(slDragKey) ?? fallbackX,
1889
+ chartW,
1890
+ theme,
1891
+ precision,
1892
+ draggingLabel,
1893
+ dragNewPrice,
1894
+ hitAreas,
1895
+ dragAreas,
1896
+ hideConfirmCancel,
1897
+ bh._slOrderLabel
1898
+ );
1899
+ }
1900
+ if (bh.takeProfitPrice !== void 0) {
1901
+ drawBracketLine(
1902
+ ctx,
1903
+ bh,
1904
+ "takeProfit",
1905
+ bh.takeProfitPrice,
1906
+ scale,
1907
+ priceRange,
1908
+ priceH,
1909
+ bracketLineXMap.get(tpDragKey) ?? fallbackX,
1910
+ chartW,
1911
+ theme,
1912
+ precision,
1913
+ draggingLabel,
1914
+ dragNewPrice,
1915
+ hitAreas,
1916
+ dragAreas,
1917
+ hideConfirmCancel,
1918
+ bh._tpOrderLabel
1919
+ );
1920
+ }
1921
+ }
1734
1922
  }
1735
1923
  ctx.restore();
1736
- return { hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas };
1924
+ return { hitAreas, editAreas, hoverAreas, dragAreas, confirmAreas, addBracketAreas, clusterBadgeAreas, clusters };
1925
+ }
1926
+ var BADGE_FONT = "bold 12px 'Inter', system-ui, sans-serif";
1927
+ var BADGE_PAD_X = 12;
1928
+ var BADGE_H = 24;
1929
+ var BADGE_RADIUS = 12;
1930
+ function drawClusterBadge(ctx, cluster, chartW, theme, clusterBadgeAreas) {
1931
+ const mono = monoColor(theme);
1932
+ const isDark = theme.background !== "#ffffff";
1933
+ ctx.save();
1934
+ ctx.strokeStyle = mono;
1935
+ ctx.globalAlpha = 0.12;
1936
+ ctx.lineWidth = 1;
1937
+ ctx.setLineDash([4, 4]);
1938
+ for (const m of cluster.members) {
1939
+ ctx.beginPath();
1940
+ ctx.moveTo(0, m.y);
1941
+ ctx.lineTo(chartW, m.y);
1942
+ ctx.stroke();
1943
+ }
1944
+ ctx.setLineDash([]);
1945
+ ctx.restore();
1946
+ ctx.save();
1947
+ ctx.font = BADGE_FONT;
1948
+ const layout = buildClusterBadgeLayout(cluster, chartW, ctx);
1949
+ const { badgeX, badgeY, badgeW, text } = layout;
1950
+ const expandIcon = " \u25B8";
1951
+ const bgColor = isDark ? "rgba(40,40,40,0.92)" : "rgba(240,240,240,0.92)";
1952
+ ctx.fillStyle = bgColor;
1953
+ ctx.beginPath();
1954
+ ctx.roundRect(badgeX, badgeY, badgeW, BADGE_H, BADGE_RADIUS);
1955
+ ctx.fill();
1956
+ ctx.strokeStyle = mono;
1957
+ ctx.globalAlpha = 0.3;
1958
+ ctx.lineWidth = 1;
1959
+ ctx.stroke();
1960
+ ctx.globalAlpha = 1;
1961
+ ctx.fillStyle = mono;
1962
+ ctx.globalAlpha = 0.85;
1963
+ ctx.textBaseline = "middle";
1964
+ ctx.textAlign = "left";
1965
+ ctx.fillText(text + expandIcon, badgeX + BADGE_PAD_X, badgeY + BADGE_H / 2);
1966
+ ctx.globalAlpha = 1;
1967
+ ctx.restore();
1968
+ clusterBadgeAreas.push({
1969
+ clusterId: cluster.id,
1970
+ x: badgeX,
1971
+ y: badgeY,
1972
+ w: badgeW,
1973
+ h: BADGE_H
1974
+ });
1975
+ }
1976
+ function drawFanConnector(ctx, fanCenterY, realY, chartW, theme) {
1977
+ if (Math.abs(fanCenterY - realY) < 2) return;
1978
+ const mono = monoColor(theme);
1979
+ ctx.save();
1980
+ ctx.strokeStyle = mono;
1981
+ ctx.globalAlpha = 0.25;
1982
+ ctx.lineWidth = 1;
1983
+ ctx.setLineDash([2, 3]);
1984
+ const connectorX = chartW - 8;
1985
+ ctx.beginPath();
1986
+ ctx.moveTo(connectorX, fanCenterY);
1987
+ ctx.lineTo(connectorX, realY);
1988
+ ctx.stroke();
1989
+ ctx.setLineDash([]);
1990
+ ctx.beginPath();
1991
+ ctx.moveTo(connectorX - 4, realY);
1992
+ ctx.lineTo(connectorX + 4, realY);
1993
+ ctx.stroke();
1994
+ ctx.restore();
1737
1995
  }
1738
1996
  function drawDotHoverBox(ctx, level, y, dotX, dotR, theme, precision) {
1739
1997
  const pl = level;
@@ -2364,6 +2622,152 @@ function hitTestTradeLevelAddBracket(x, y, areas) {
2364
2622
  return areas.find((a) => x >= a.x && x <= a.x + a.w && y >= a.y && y <= a.y + a.h) ?? null;
2365
2623
  }
2366
2624
 
2625
+ // src/overlays/OffViewportLevelIndicator.ts
2626
+ function collectOffViewportMarkers(levels, priceRange, draggingLabel) {
2627
+ const above = [];
2628
+ const below = [];
2629
+ const { min, max } = priceRange;
2630
+ for (const level of levels) {
2631
+ if (level.label === draggingLabel) continue;
2632
+ const isDraftMarket = level.type === "pending" && level.orderType === "market" && level.data?._isDraft === true;
2633
+ if (isDraftMarket) continue;
2634
+ if (isFinite(level.price)) {
2635
+ if (level.price > max) above.push({ price: level.price, label: level.label, kind: "entry" });
2636
+ else if (level.price < min) below.push({ price: level.price, label: level.label, kind: "entry" });
2637
+ }
2638
+ const slPrice = level.stopLossPrice;
2639
+ const tpPrice = level.takeProfitPrice;
2640
+ if (slPrice !== void 0 && isFinite(slPrice)) {
2641
+ if (slPrice > max) above.push({ price: slPrice, label: level.label, kind: "sl" });
2642
+ else if (slPrice < min) below.push({ price: slPrice, label: level.label, kind: "sl" });
2643
+ }
2644
+ if (tpPrice !== void 0 && isFinite(tpPrice)) {
2645
+ if (tpPrice > max) above.push({ price: tpPrice, label: level.label, kind: "tp" });
2646
+ else if (tpPrice < min) below.push({ price: tpPrice, label: level.label, kind: "tp" });
2647
+ }
2648
+ }
2649
+ return { above, below };
2650
+ }
2651
+ function entryCount(markers) {
2652
+ let n = 0;
2653
+ for (const m of markers) if (m.kind === "entry") n++;
2654
+ return n;
2655
+ }
2656
+ function nearestEdgeMarker(markers, edge) {
2657
+ let best = markers[0];
2658
+ let bestDist = Math.abs(best.price - edge);
2659
+ for (let i = 1; i < markers.length; i++) {
2660
+ const d = Math.abs(markers[i].price - edge);
2661
+ if (d < bestDist) {
2662
+ best = markers[i];
2663
+ bestDist = d;
2664
+ }
2665
+ }
2666
+ return best;
2667
+ }
2668
+ var PILL_FONT = "11px 'Inter', system-ui, sans-serif";
2669
+ var PILL_PAD_X = 8;
2670
+ var PILL_RIGHT_MARGIN = 8;
2671
+ var PILL_V_MARGIN = 10;
2672
+ var PILL_H = 20;
2673
+ var PILL_RADIUS = 10;
2674
+ var ARROW_UP = "\u25B2";
2675
+ var ARROW_DOWN = "\u25BC";
2676
+ function renderOffViewportIndicators(ctx, levels, priceRange, theme, chartWidth, chartHeight, draggingLabel, selectedLabel = null) {
2677
+ const hitAreas = [];
2678
+ const PADDING_PX = 20;
2679
+ const usable = chartHeight - 2 * PADDING_PX;
2680
+ const span = priceRange.max - priceRange.min;
2681
+ const padInPrice = usable > 0 ? span / usable * PADDING_PX : 0;
2682
+ const visualRange = {
2683
+ min: priceRange.min - padInPrice,
2684
+ max: priceRange.max + padInPrice
2685
+ };
2686
+ const { above, below } = collectOffViewportMarkers(levels, visualRange, draggingLabel);
2687
+ if (above.length === 0 && below.length === 0) return { hitAreas };
2688
+ ctx.save();
2689
+ ctx.font = PILL_FONT;
2690
+ ctx.textBaseline = "middle";
2691
+ ctx.textAlign = "center";
2692
+ const PILL_GAP = 4;
2693
+ const renderPill = (direction, text, y, target) => {
2694
+ const textW = ctx.measureText(text).width;
2695
+ const w = Math.ceil(textW) + PILL_PAD_X * 2;
2696
+ const x = chartWidth - PILL_RIGHT_MARGIN - w;
2697
+ ctx.fillStyle = theme.tooltip.background;
2698
+ ctx.strokeStyle = theme.tooltip.border;
2699
+ ctx.lineWidth = 1;
2700
+ roundedRect(ctx, x, y, w, PILL_H, PILL_RADIUS);
2701
+ ctx.fill();
2702
+ ctx.stroke();
2703
+ ctx.fillStyle = theme.tooltip.text;
2704
+ ctx.fillText(text, x + w / 2, y + PILL_H / 2 + 0.5);
2705
+ hitAreas.push({
2706
+ direction,
2707
+ x,
2708
+ y,
2709
+ w,
2710
+ h: PILL_H,
2711
+ targetPrice: target.price,
2712
+ targetLabel: target.label,
2713
+ targetKind: target.kind
2714
+ });
2715
+ return w;
2716
+ };
2717
+ const drawColumn = (direction, markers, baseY) => {
2718
+ if (markers.length === 0) return;
2719
+ const arrow = direction === "up" ? ARROW_UP : ARROW_DOWN;
2720
+ const edge = direction === "up" ? priceRange.max : priceRange.min;
2721
+ const stackDir = direction === "up" ? 1 : -1;
2722
+ let nextY = baseY;
2723
+ const entries = entryCount(markers);
2724
+ if (entries > 0) {
2725
+ const target = nearestEdgeMarker(
2726
+ markers.filter((m) => m.kind === "entry"),
2727
+ edge
2728
+ );
2729
+ renderPill(direction, `${arrow} ${entries}`, nextY, target);
2730
+ nextY += stackDir * (PILL_H + PILL_GAP);
2731
+ }
2732
+ if (selectedLabel !== null) {
2733
+ const bracketMarkers = markers.filter(
2734
+ (m) => m.kind !== "entry" && m.label === selectedLabel
2735
+ );
2736
+ for (const bm of bracketMarkers) {
2737
+ const kindLabel = bm.kind.toUpperCase();
2738
+ renderPill(direction, `${arrow} ${kindLabel}`, nextY, bm);
2739
+ nextY += stackDir * (PILL_H + PILL_GAP);
2740
+ }
2741
+ }
2742
+ };
2743
+ drawColumn("up", above, PILL_V_MARGIN);
2744
+ drawColumn("down", below, chartHeight - PILL_V_MARGIN - PILL_H);
2745
+ ctx.restore();
2746
+ return { hitAreas };
2747
+ }
2748
+ function hitTestOffViewportIndicator(x, y, hitAreas) {
2749
+ return hitAreas.find((h) => x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h) ?? null;
2750
+ }
2751
+ function overlapsOffViewportPill(centerY, halfH, hitAreas) {
2752
+ const top = centerY - halfH;
2753
+ const bottom = centerY + halfH;
2754
+ return hitAreas.some((h) => top < h.y + h.h && bottom > h.y);
2755
+ }
2756
+ function roundedRect(ctx, x, y, w, h, r) {
2757
+ const rr = Math.min(r, w / 2, h / 2);
2758
+ ctx.beginPath();
2759
+ ctx.moveTo(x + rr, y);
2760
+ ctx.lineTo(x + w - rr, y);
2761
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
2762
+ ctx.lineTo(x + w, y + h - rr);
2763
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
2764
+ ctx.lineTo(x + rr, y + h);
2765
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
2766
+ ctx.lineTo(x, y + rr);
2767
+ ctx.quadraticCurveTo(x, y, x + rr, y);
2768
+ ctx.closePath();
2769
+ }
2770
+
2367
2771
  // src/axis/TimeAxis.ts
2368
2772
  var COUNTDOWN_LABEL_HEIGHT = 18;
2369
2773
  var COUNTDOWN_LABEL_PADDING_X = 6;
@@ -2478,10 +2882,20 @@ function renderCandleCountdown(ctx, lastBarTime, intervalMs, lastBarIndex, viewp
2478
2882
  }
2479
2883
 
2480
2884
  // src/axis/PriceAxis.ts
2481
- function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines = 6, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
2885
+ function renderPriceAxis(ctx, priceRange, scale, theme, chartWidth, chartHeight, axisWidth, gridLines, pricePrecision = 2, cfg = DEFAULT_PRICE_AXIS_CONFIG) {
2482
2886
  ctx.fillStyle = theme.axisText;
2483
2887
  ctx.font = `${cfg.fontSize} ${cfg.fontFamily}`;
2484
2888
  ctx.textAlign = "left";
2889
+ if (gridLines === void 0) {
2890
+ const ticks = generatePriceTicks(priceRange, chartHeight);
2891
+ const step2 = ticks.length >= 2 ? ticks[1] - ticks[0] : priceRange.max - priceRange.min;
2892
+ const precision = precisionForStep(step2, pricePrecision);
2893
+ for (const price of ticks) {
2894
+ const y = scale.yToPixel(price, priceRange, chartHeight);
2895
+ ctx.fillText(price.toFixed(precision), chartWidth + 4, y + 4);
2896
+ }
2897
+ return;
2898
+ }
2485
2899
  const step = (priceRange.max - priceRange.min) / gridLines;
2486
2900
  for (let i = 0; i <= gridLines; i++) {
2487
2901
  const price = priceRange.min + step * i;
@@ -3096,6 +3510,8 @@ var ICON_TOOL = {
3096
3510
  14
3097
3511
  )
3098
3512
  };
3513
+ 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>`;
3514
+ 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
3515
  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
3516
  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
3517
  var ICON_TAB = {
@@ -3131,7 +3547,7 @@ function injectFlyoutScrollbarStyles() {
3131
3547
  document.head.appendChild(style);
3132
3548
  }
3133
3549
  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) {
3550
+ 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
3551
  this.onSeriesChange = onSeriesChange;
3136
3552
  this.onTimeframeChange = onTimeframeChange;
3137
3553
  this.onAddIndicator = onAddIndicator;
@@ -3139,11 +3555,14 @@ var TopBar = class {
3139
3555
  this.onSettingsClick = onSettingsClick;
3140
3556
  this.onFullscreenToggle = onFullscreenToggle;
3141
3557
  this.onDrawingToolsToggle = onDrawingToolsToggle;
3558
+ this.onTfcToggle = onTfcToggle;
3142
3559
  this.settingsBtn = null;
3143
3560
  this.isFullscreen = false;
3144
3561
  this.durationBtns = /* @__PURE__ */ new Map();
3145
3562
  this.isMobileLayout = false;
3146
3563
  this.drawingBtn = null;
3564
+ this.tfcBtn = null;
3565
+ this.tfcActive = true;
3147
3566
  this.openDropdown = null;
3148
3567
  this.indicatorOptions = [];
3149
3568
  this.activeIndicators = /* @__PURE__ */ new Set();
@@ -3151,6 +3570,7 @@ var TopBar = class {
3151
3570
  this.theme = theme;
3152
3571
  this.cfg = cfg;
3153
3572
  this.labels = labels;
3573
+ this.tfcActive = initialTfcActive;
3154
3574
  this.currentSeries = initialSeries;
3155
3575
  this.currentTf = initialTf;
3156
3576
  this.activeDuration = initialDuration;
@@ -3227,6 +3647,21 @@ var TopBar = class {
3227
3647
  this.addBtnHover(this.settingsBtn);
3228
3648
  this.rightGroup.appendChild(this.settingsBtn);
3229
3649
  }
3650
+ if (showTfcBtn) {
3651
+ this.tfcBtn = document.createElement("button");
3652
+ this.tfcBtn.title = this.labels.tfcToggleTitle;
3653
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3654
+ this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3655
+ this.tfcBtn.addEventListener("click", (e) => {
3656
+ e.stopPropagation();
3657
+ this.tfcActive = !this.tfcActive;
3658
+ this.tfcBtn.innerHTML = this.tfcActive ? ICON_TFC : ICON_TFC_OFF;
3659
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, this.tfcActive);
3660
+ this.onTfcToggle?.(this.tfcActive);
3661
+ });
3662
+ this.addBtnHover(this.tfcBtn);
3663
+ this.rightGroup.appendChild(this.tfcBtn);
3664
+ }
3230
3665
  this.el.append(this.leftGroup, this.rightGroup);
3231
3666
  this.docClickHandler = (e) => {
3232
3667
  if (!this.el.contains(e.target) && !this.flyout.contains(e.target)) {
@@ -3256,6 +3691,15 @@ var TopBar = class {
3256
3691
  if (!this.drawingBtn) return;
3257
3692
  this.applyIconBtnStyles(this.drawingBtn, this.theme, active);
3258
3693
  }
3694
+ setTfcActive(active) {
3695
+ if (!this.tfcBtn) return;
3696
+ this.tfcActive = active;
3697
+ this.tfcBtn.innerHTML = active ? ICON_TFC : ICON_TFC_OFF;
3698
+ this.applyIconBtnStyles(this.tfcBtn, this.theme, active);
3699
+ if (this.isMobileLayout) {
3700
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + 2);
3701
+ }
3702
+ }
3259
3703
  setTheme(theme) {
3260
3704
  this.theme = theme;
3261
3705
  this.closeDropdown();
@@ -3264,6 +3708,7 @@ var TopBar = class {
3264
3708
  if (this.drawingBtn) this.applyIconBtnStyles(this.drawingBtn, theme, false);
3265
3709
  this.applyIconBtnStyles(this.fullscreenBtn, theme, this.isFullscreen);
3266
3710
  if (this.settingsBtn) this.applyIconBtnStyles(this.settingsBtn, theme, false);
3711
+ if (this.tfcBtn) this.applyIconBtnStyles(this.tfcBtn, theme, this.tfcActive);
3267
3712
  this.applySeriesBtnStyles(this.seriesBtn, theme, false);
3268
3713
  this.applyDropBtnStyles(this.tfBtn, theme, false);
3269
3714
  this.applyDropBtnStyles(this.indBtn, theme, false);
@@ -3304,7 +3749,7 @@ var TopBar = class {
3304
3749
  this.durationSep.style.display = btnDisplay;
3305
3750
  this.durationDropBtn.style.display = isMobile ? "" : "none";
3306
3751
  const containerSize = isMobile ? this.cfg.mobileIconBtnSize : this.cfg.iconBtnSize;
3307
- for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn]) {
3752
+ for (const btn of [this.fullscreenBtn, this.settingsBtn, this.drawingBtn, this.tfcBtn]) {
3308
3753
  if (!btn) continue;
3309
3754
  btn.style.width = containerSize;
3310
3755
  btn.style.height = containerSize;
@@ -3314,6 +3759,9 @@ var TopBar = class {
3314
3759
  if (this.settingsBtn) {
3315
3760
  this.resizeSvg(this.settingsBtn, this.getSvgSize(this.settingsBtn) + delta);
3316
3761
  }
3762
+ if (this.tfcBtn) {
3763
+ this.resizeSvg(this.tfcBtn, this.getSvgSize(this.tfcBtn) + delta);
3764
+ }
3317
3765
  if (this.drawingBtn) {
3318
3766
  const drawIconSize = isMobile ? parseInt(this.cfg.mobileDrawBtnIconSize, 10) : parseInt(this.cfg.drawBtnIconFontSize, 10);
3319
3767
  this.resizeSvg(this.drawingBtn, drawIconSize);
@@ -8142,7 +8590,7 @@ function pointToSegmentDist19(px, py, x1, y1, x2, y2) {
8142
8590
  const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
8143
8591
  return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
8144
8592
  }
8145
- function roundedRect(ctx, x, y, w, h, r) {
8593
+ function roundedRect2(ctx, x, y, w, h, r) {
8146
8594
  ctx.beginPath();
8147
8595
  ctx.moveTo(x + r, y);
8148
8596
  ctx.lineTo(x + w - r, y);
@@ -8193,7 +8641,7 @@ var Callout = class {
8193
8641
  ctx.beginPath();
8194
8642
  ctx.arc(tipX, tipY, 3, 0, Math.PI * 2);
8195
8643
  ctx.fill();
8196
- roundedRect(ctx, bx, by, boxW, BOX_H, RADIUS);
8644
+ roundedRect2(ctx, bx, by, boxW, BOX_H, RADIUS);
8197
8645
  ctx.fillStyle = "#37474f";
8198
8646
  ctx.fill();
8199
8647
  ctx.strokeStyle = "#546e7a";
@@ -8245,7 +8693,7 @@ var Callout = class {
8245
8693
  // src/drawings/PriceLabel.ts
8246
8694
  var HIT_TOLERANCE42 = 6;
8247
8695
  var BADGE_W = 60;
8248
- var BADGE_H = 18;
8696
+ var BADGE_H2 = 18;
8249
8697
  var ARROW_W = 7;
8250
8698
  var PriceLabel = class {
8251
8699
  constructor(point) {
@@ -8262,21 +8710,21 @@ var PriceLabel = class {
8262
8710
  ctx.fillStyle = "#26a69a";
8263
8711
  ctx.beginPath();
8264
8712
  ctx.moveTo(x, y);
8265
- ctx.lineTo(x + ARROW_W, y - BADGE_H / 2);
8266
- ctx.lineTo(x + ARROW_W, y + BADGE_H / 2);
8713
+ ctx.lineTo(x + ARROW_W, y - BADGE_H2 / 2);
8714
+ ctx.lineTo(x + ARROW_W, y + BADGE_H2 / 2);
8267
8715
  ctx.closePath();
8268
8716
  ctx.fill();
8269
8717
  const bx = x + ARROW_W;
8270
- const by = y - BADGE_H / 2;
8718
+ const by = y - BADGE_H2 / 2;
8271
8719
  ctx.fillStyle = "#26a69a";
8272
8720
  ctx.beginPath();
8273
- ctx.roundRect(bx, by, BADGE_W, BADGE_H, BADGE_H / 2);
8721
+ ctx.roundRect(bx, by, BADGE_W, BADGE_H2, BADGE_H2 / 2);
8274
8722
  ctx.fill();
8275
8723
  if (isSelected) {
8276
8724
  ctx.strokeStyle = "#80cbc4";
8277
8725
  ctx.lineWidth = 1.5;
8278
8726
  ctx.beginPath();
8279
- ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H + 4, BADGE_H / 2 + 2);
8727
+ ctx.roundRect(bx - 2, by - 2, BADGE_W + 4, BADGE_H2 + 4, BADGE_H2 / 2 + 2);
8280
8728
  ctx.stroke();
8281
8729
  }
8282
8730
  ctx.font = "11px 'Inter', system-ui, sans-serif";
@@ -8290,8 +8738,8 @@ var PriceLabel = class {
8290
8738
  const ax = scale.xToPixel(this.points[0].barIndex, viewport, chartW);
8291
8739
  const ay = scale.yToPixel(this.points[0].price, priceRange, chartH);
8292
8740
  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;
8741
+ const by = ay - BADGE_H2 / 2;
8742
+ return x >= ax - HIT_TOLERANCE42 && x <= bx + BADGE_W + HIT_TOLERANCE42 && y >= by - HIT_TOLERANCE42 && y <= by + BADGE_H2 + HIT_TOLERANCE42;
8295
8743
  }
8296
8744
  getHandle() {
8297
8745
  return null;
@@ -8411,7 +8859,7 @@ var Flag = class {
8411
8859
 
8412
8860
  // src/drawings/PriceNote.ts
8413
8861
  var HIT_TOLERANCE44 = 6;
8414
- var BADGE_H2 = 16;
8862
+ var BADGE_H3 = 16;
8415
8863
  var BADGE_PADDING_X = 6;
8416
8864
  var TICK_HALF = 10;
8417
8865
  var PriceNote = class {
@@ -8437,10 +8885,10 @@ var PriceNote = class {
8437
8885
  const tw = ctx.measureText(label).width;
8438
8886
  const badgeW = tw + BADGE_PADDING_X * 2;
8439
8887
  const bx = x + TICK_HALF + 2;
8440
- const by = y - BADGE_H2 / 2;
8888
+ const by = y - BADGE_H3 / 2;
8441
8889
  ctx.fillStyle = "#ffa726";
8442
8890
  ctx.beginPath();
8443
- ctx.roundRect(bx, by, badgeW, BADGE_H2, 3);
8891
+ ctx.roundRect(bx, by, badgeW, BADGE_H3, 3);
8444
8892
  ctx.fill();
8445
8893
  ctx.fillStyle = "#ffffff";
8446
8894
  ctx.textBaseline = "middle";
@@ -8460,7 +8908,7 @@ var PriceNote = class {
8460
8908
 
8461
8909
  // src/drawings/AnchoredNote.ts
8462
8910
  var HIT_TOLERANCE45 = 6;
8463
- var BADGE_H3 = 20;
8911
+ var BADGE_H4 = 20;
8464
8912
  var PAD_H2 = 10;
8465
8913
  function pointToSegmentDist21(px, py, x1, y1, x2, y2) {
8466
8914
  const dx = x2 - x1, dy = y2 - y1;
@@ -8493,7 +8941,7 @@ var AnchoredNote = class {
8493
8941
  ctx.lineWidth = isSelected ? 1.5 : 1;
8494
8942
  const badgeW = this.getBadgeW(ctx);
8495
8943
  const bx = x2 - badgeW / 2;
8496
- const by = y2 - BADGE_H3 / 2;
8944
+ const by = y2 - BADGE_H4 / 2;
8497
8945
  ctx.strokeStyle = "#42a5f5";
8498
8946
  ctx.setLineDash([4, 3]);
8499
8947
  ctx.beginPath();
@@ -8507,12 +8955,12 @@ var AnchoredNote = class {
8507
8955
  ctx.fill();
8508
8956
  ctx.fillStyle = "#37474f";
8509
8957
  ctx.beginPath();
8510
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
8958
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8511
8959
  ctx.fill();
8512
8960
  ctx.strokeStyle = "#42a5f5";
8513
8961
  ctx.lineWidth = isSelected ? 1.5 : 1;
8514
8962
  ctx.beginPath();
8515
- ctx.roundRect(bx, by, badgeW, BADGE_H3, 4);
8963
+ ctx.roundRect(bx, by, badgeW, BADGE_H4, 4);
8516
8964
  ctx.stroke();
8517
8965
  const label = this.text || "Note";
8518
8966
  const isPlaceholder = !this.text;
@@ -8531,8 +8979,8 @@ var AnchoredNote = class {
8531
8979
  const y2 = scale.yToPixel(p2.price, priceRange, chartH);
8532
8980
  const approxBadgeW = Math.max(50, (this.text?.length ?? 4) * 7 + 20);
8533
8981
  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) {
8982
+ const by = y2 - BADGE_H4 / 2;
8983
+ if (x >= bx - HIT_TOLERANCE45 && x <= bx + approxBadgeW + HIT_TOLERANCE45 && y >= by - HIT_TOLERANCE45 && y <= by + BADGE_H4 + HIT_TOLERANCE45) {
8536
8984
  return true;
8537
8985
  }
8538
8986
  if (pointToSegmentDist21(x, y, x1, y1, x2, y2) <= HIT_TOLERANCE45) {
@@ -12693,9 +13141,14 @@ var TradeDragHandler = class _TradeDragHandler {
12693
13141
  this._isDraggingBracket = false;
12694
13142
  // Main line drag state
12695
13143
  this._level = null;
12696
- this._startY = 0;
12697
13144
  this._startPrice = 0;
12698
13145
  this._currentPrice = 0;
13146
+ // Pixel offset between click Y and the level's rendered Y at drag start.
13147
+ // Why: the drag hit-strip is 8 px tall; without this, the cursor-to-price
13148
+ // mapping would snap the level to wherever the cursor is instead of
13149
+ // preserving the grab point. Also immunises the drag from any priceRange
13150
+ // animation (lerp) that happens between drag-start and first mousemove.
13151
+ this._pixelOffset = 0;
12699
13152
  // Entry-price constraint bounds (applied when SL/TP exist on the level)
12700
13153
  this._mainMinPrice = -Infinity;
12701
13154
  this._mainMaxPrice = Infinity;
@@ -12703,6 +13156,7 @@ var TradeDragHandler = class _TradeDragHandler {
12703
13156
  this._bracketType = null;
12704
13157
  this._bracketStartPrice = 0;
12705
13158
  this._bracketCurrentPrice = 0;
13159
+ this._bracketPixelOffset = 0;
12706
13160
  // Bracket constraint state (populated on startBracketDrag)
12707
13161
  this._bracketParentPrice = 0;
12708
13162
  this._bracketParentSide = "buy";
@@ -12714,11 +13168,11 @@ var TradeDragHandler = class _TradeDragHandler {
12714
13168
  * Pass `slPrice` / `tpPrice` / `side` to constrain the drag between SL and TP
12715
13169
  * (used for ES/EL entry orders where the entry price must stay between brackets).
12716
13170
  */
12717
- startDrag(level, startY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
13171
+ startDrag(level, clickY, levelPixelY, startPrice, slPrice, tpPrice, side, marketPrice, orderType) {
12718
13172
  this._active = true;
12719
13173
  this._isDraggingBracket = false;
12720
13174
  this._level = level;
12721
- this._startY = startY;
13175
+ this._pixelOffset = clickY - levelPixelY;
12722
13176
  this._startPrice = startPrice;
12723
13177
  this._currentPrice = startPrice;
12724
13178
  this._bracketType = null;
@@ -12750,13 +13204,14 @@ var TradeDragHandler = class _TradeDragHandler {
12750
13204
  * @param bracketType 'stopLoss' | 'takeProfit'
12751
13205
  * @param startPrice Current bracket price
12752
13206
  */
12753
- startBracketDrag(parentLevel, bracketType, startPrice, currentMarketPrice = 0, parentPriceOverride) {
13207
+ startBracketDrag(parentLevel, bracketType, startPrice, clickY, bracketPixelY, currentMarketPrice = 0, parentPriceOverride) {
12754
13208
  this._active = true;
12755
13209
  this._isDraggingBracket = true;
12756
13210
  this._level = parentLevel;
12757
13211
  this._bracketType = bracketType;
12758
13212
  this._bracketStartPrice = startPrice;
12759
13213
  this._bracketCurrentPrice = startPrice;
13214
+ this._bracketPixelOffset = clickY - bracketPixelY;
12760
13215
  this._bracketParentPrice = parentPriceOverride ?? parentLevel.price;
12761
13216
  this._bracketParentSide = parentLevel.side ?? "buy";
12762
13217
  this._bracketMarketPrice = currentMarketPrice;
@@ -12767,9 +13222,10 @@ var TradeDragHandler = class _TradeDragHandler {
12767
13222
  */
12768
13223
  onMouseMove(currentY, scale, priceRange, priceH) {
12769
13224
  if (!this._active || !this._level) return null;
12770
- const rawPrice = scale.pixelToPrice(currentY, priceRange, priceH);
12771
13225
  if (this._isDraggingBracket && this._bracketType !== null) {
12772
- const clampedPrice = this._clampBracketPrice(rawPrice, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
13226
+ const effectiveY2 = currentY - this._bracketPixelOffset;
13227
+ const rawPrice2 = scale.pixelToPrice(effectiveY2, priceRange, priceH);
13228
+ const clampedPrice = this._clampBracketPrice(rawPrice2, this._bracketType, this._bracketParentPrice, this._bracketParentSide, this._bracketMarketPrice);
12773
13229
  this._bracketCurrentPrice = clampedPrice;
12774
13230
  return {
12775
13231
  newPrice: clampedPrice,
@@ -12777,6 +13233,8 @@ var TradeDragHandler = class _TradeDragHandler {
12777
13233
  isBracket: true
12778
13234
  };
12779
13235
  }
13236
+ const effectiveY = currentY - this._pixelOffset;
13237
+ const rawPrice = scale.pixelToPrice(effectiveY, priceRange, priceH);
12780
13238
  const constrained = Math.max(this._mainMinPrice, Math.min(this._mainMaxPrice, rawPrice));
12781
13239
  this._currentPrice = constrained;
12782
13240
  return {
@@ -12875,10 +13333,12 @@ var TradeDragHandler = class _TradeDragHandler {
12875
13333
  this._level = null;
12876
13334
  this._bracketType = null;
12877
13335
  this._currentPrice = 0;
13336
+ this._pixelOffset = 0;
12878
13337
  this._mainMinPrice = -Infinity;
12879
13338
  this._mainMaxPrice = Infinity;
12880
13339
  this._bracketStartPrice = 0;
12881
13340
  this._bracketCurrentPrice = 0;
13341
+ this._bracketPixelOffset = 0;
12882
13342
  this._bracketParentPrice = 0;
12883
13343
  this._bracketParentSide = "buy";
12884
13344
  this._bracketMarketPrice = 0;
@@ -16420,6 +16880,8 @@ var _ChartEngine = class _ChartEngine {
16420
16880
  this.showBidAskLines = false;
16421
16881
  this.showActLogo = false;
16422
16882
  this.hideLevelConfirmCancel = false;
16883
+ this.levelClusteringEnabled = true;
16884
+ this.clusterThresholdDistance = 20;
16423
16885
  this.enableThemeToggle = false;
16424
16886
  this.activeIndicators = [];
16425
16887
  this.indicatorResults = /* @__PURE__ */ new Map();
@@ -16493,11 +16955,16 @@ var _ChartEngine = class _ChartEngine {
16493
16955
  // Trade / position levels
16494
16956
  this.levels = [];
16495
16957
  this.tradeLevelHitAreas = [];
16958
+ this.offViewportHitAreas = [];
16496
16959
  this.tradeLevelEditAreas = [];
16497
16960
  this.tradeHoverAreas = [];
16498
16961
  this.tradeLevelDragAreas = [];
16499
16962
  this.tradeLevelConfirmAreas = [];
16500
16963
  this.tradeAddBracketAreas = [];
16964
+ this.clusterBadgeAreas = [];
16965
+ this.lastClusters = [];
16966
+ /** Set of cluster IDs that are currently expanded (fanned out). */
16967
+ this.expandedClusters = /* @__PURE__ */ new Set();
16501
16968
  this.hoveredTradeLabel = null;
16502
16969
  this.selectedTradeLabel = null;
16503
16970
  /** Estimated PNL text for draft order SL/TP bracket lines, set by setDraftBracketPnl(). */
@@ -16512,6 +16979,8 @@ var _ChartEngine = class _ChartEngine {
16512
16979
  // Inline drag confirmation — accumulates changes per level until user confirms
16513
16980
  this.pendingLevelDrags = /* @__PURE__ */ new Map();
16514
16981
  // Trade from Charts settings
16982
+ this.tfcEnabled = true;
16983
+ this.tfcActive = true;
16515
16984
  this.tradeDisplayFilter = "all";
16516
16985
  this.positionRenderStyle = "dot";
16517
16986
  this.tradesThresholdForHorizontalLine = 2;
@@ -16522,7 +16991,9 @@ var _ChartEngine = class _ChartEngine {
16522
16991
  this.dataLoader = null;
16523
16992
  this.isFetching = false;
16524
16993
  this.noMoreData = false;
16994
+ this._loadGeneration = 0;
16525
16995
  this.maxCandles = 200;
16996
+ this.prefetchThreshold = 80;
16526
16997
  this.defaultVisibleBars = DEFAULT_VISIBLE_BARS;
16527
16998
  this.defaultMobileVisibleBars = void 0;
16528
16999
  this.mobileBarDivisor = 2;
@@ -16560,7 +17031,12 @@ var _ChartEngine = class _ChartEngine {
16560
17031
  if (this.tradeButton) {
16561
17032
  if (isInChartArea && y >= 0 && y <= priceH && !this._isTradingActive && !hoveringDotLevel) {
16562
17033
  this.tradeButton.setY(y);
16563
- this.tradeButton.show();
17034
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
17035
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
17036
+ this.tradeButton.hide();
17037
+ } else if (this.tfcActive) {
17038
+ this.tradeButton.show();
17039
+ }
16564
17040
  this._tradeButtonClickData = { price, y };
16565
17041
  const allBars = this.dataStore.all;
16566
17042
  const lastClose = allBars.length > 0 ? allBars[allBars.length - 1].close : price;
@@ -16662,7 +17138,7 @@ var _ChartEngine = class _ChartEngine {
16662
17138
  return;
16663
17139
  }
16664
17140
  if (!this.drawingManager.getActiveTool()) {
16665
- if (hitTestTradeLevelClose(x, y, this.tradeLevelHitAreas) || hitTestTradeLevelEdit(x, y, this.tradeLevelEditAreas) || hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas)) {
17141
+ 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
17142
  this.drawCanvas.style.cursor = "pointer";
16667
17143
  this.scheduleRender();
16668
17144
  return;
@@ -16874,6 +17350,25 @@ var _ChartEngine = class _ChartEngine {
16874
17350
  this.scheduleRender();
16875
17351
  return;
16876
17352
  }
17353
+ const clusterHitTouch = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
17354
+ if (clusterHitTouch) {
17355
+ e.preventDefault();
17356
+ this.panHandler.cancel();
17357
+ if (this.expandedClusters.has(clusterHitTouch.clusterId)) {
17358
+ this.expandedClusters.delete(clusterHitTouch.clusterId);
17359
+ } else {
17360
+ this.expandedClusters.add(clusterHitTouch.clusterId);
17361
+ }
17362
+ this.renderTradeLayer();
17363
+ return;
17364
+ }
17365
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
17366
+ if (offVpHit) {
17367
+ e.preventDefault();
17368
+ this.panHandler.cancel();
17369
+ this.scrollToCenterPrice(offVpHit.targetPrice);
17370
+ return;
17371
+ }
16877
17372
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
16878
17373
  if (confirmHit) {
16879
17374
  e.preventDefault();
@@ -16930,6 +17425,10 @@ var _ChartEngine = class _ChartEngine {
16930
17425
  }
16931
17426
  this.drawingManager.selectDrawing(null);
16932
17427
  this.hideMobileDrawingFlyout();
17428
+ if (this.expandedClusters.size > 0) {
17429
+ this.expandedClusters.clear();
17430
+ this.renderTradeLayer();
17431
+ }
16933
17432
  const isInChartArea = x < chartW && y < timeAxisY;
16934
17433
  if (isInChartArea) {
16935
17434
  if (this._mobileCrosshairActive) {
@@ -17079,7 +17578,12 @@ var _ChartEngine = class _ChartEngine {
17079
17578
  if (this._isTradingActive) {
17080
17579
  this.tradeButton.hide();
17081
17580
  } else {
17082
- this.tradeButton.show();
17581
+ const _btnHalfH2 = this.uiConfig.tradeButton.size / 2;
17582
+ if (overlapsOffViewportPill(y, _btnHalfH2, this.offViewportHitAreas)) {
17583
+ this.tradeButton.hide();
17584
+ } else if (this.tfcActive) {
17585
+ this.tradeButton.show();
17586
+ }
17083
17587
  }
17084
17588
  this._tradeButtonClickData = { price, y };
17085
17589
  const _allBars = this.dataStore.all;
@@ -17161,6 +17665,21 @@ var _ChartEngine = class _ChartEngine {
17161
17665
  const rawBarIndex = this.scaleManager.pixelToBarIndex(x, this.viewport, chartW);
17162
17666
  const price = this.scaleManager.pixelToPrice(y, priceRange, priceH);
17163
17667
  const barIndex = Math.round(rawBarIndex);
17668
+ const clusterHit = hitTestClusterBadge(x, y, this.clusterBadgeAreas);
17669
+ if (clusterHit) {
17670
+ if (this.expandedClusters.has(clusterHit.clusterId)) {
17671
+ this.expandedClusters.delete(clusterHit.clusterId);
17672
+ } else {
17673
+ this.expandedClusters.add(clusterHit.clusterId);
17674
+ }
17675
+ this.renderTradeLayer();
17676
+ return;
17677
+ }
17678
+ const offVpHit = hitTestOffViewportIndicator(x, y, this.offViewportHitAreas);
17679
+ if (offVpHit) {
17680
+ this.scrollToCenterPrice(offVpHit.targetPrice);
17681
+ return;
17682
+ }
17164
17683
  const confirmHit = hitTestTradeLevelConfirm(x, y, this.tradeLevelConfirmAreas);
17165
17684
  if (confirmHit) {
17166
17685
  if (confirmHit.action === "confirm") this.applyPendingChanges(confirmHit.label);
@@ -17245,6 +17764,10 @@ var _ChartEngine = class _ChartEngine {
17245
17764
  this.renderTradeLayer();
17246
17765
  }
17247
17766
  }
17767
+ if (this.expandedClusters.size > 0) {
17768
+ this.expandedClusters.clear();
17769
+ this.renderTradeLayer();
17770
+ }
17248
17771
  const timeAxisY = rect.height - this.timeAxisHeight;
17249
17772
  if (x >= chartW) {
17250
17773
  if (y <= priceH) {
@@ -17360,6 +17883,12 @@ var _ChartEngine = class _ChartEngine {
17360
17883
  this.scheduleRender();
17361
17884
  };
17362
17885
  this.onKeyDown = (e) => {
17886
+ if (e.key === "Escape" && this.expandedClusters.size > 0) {
17887
+ this.expandedClusters.clear();
17888
+ this.renderTradeLayer();
17889
+ e.preventDefault();
17890
+ return;
17891
+ }
17363
17892
  if (e.key === "Escape" && this.pendingLevelDrags.size > 0) {
17364
17893
  this.revertPendingChanges();
17365
17894
  e.preventDefault();
@@ -17446,7 +17975,9 @@ var _ChartEngine = class _ChartEngine {
17446
17975
  durationTimeframeMap,
17447
17976
  uiConfig,
17448
17977
  labels,
17449
- tickActivityMs = 3e4
17978
+ tickActivityMs = 3e4,
17979
+ hideSymbolAndTick = false,
17980
+ showBottomBar = false
17450
17981
  } = config;
17451
17982
  this.dataLoader = dataLoader ?? null;
17452
17983
  this.drawingToolsEnabled = config.showDrawingTools !== false;
@@ -17455,6 +17986,7 @@ var _ChartEngine = class _ChartEngine {
17455
17986
  this.labels = resolveLabels(labels);
17456
17987
  this.tickActivityMs = tickActivityMs;
17457
17988
  this.maxCandles = config.maxCandles ?? 200;
17989
+ this.prefetchThreshold = config.prefetchThreshold ?? 80;
17458
17990
  this.defaultVisibleBars = config.defaultVisibleBars ?? DEFAULT_VISIBLE_BARS;
17459
17991
  this.defaultMobileVisibleBars = config.defaultMobileVisibleBars;
17460
17992
  this.explicitMobileDivisor = config.mobileBarDivisor !== void 0;
@@ -17494,7 +18026,11 @@ var _ChartEngine = class _ChartEngine {
17494
18026
  this.showBidAskLines = config.showBidAskLines ?? false;
17495
18027
  this.showActLogo = config.showActLogo ?? false;
17496
18028
  this.hideLevelConfirmCancel = config.hideLevelConfirmCancel ?? false;
18029
+ this.levelClusteringEnabled = config.levelClusteringEnabled ?? true;
18030
+ this.clusterThresholdDistance = config.clusterThresholdDistance ?? 20;
17497
18031
  this.enableThemeToggle = config.enableThemeToggle ?? false;
18032
+ this.tfcEnabled = config.tfcEnabled !== false;
18033
+ this.tfcActive = this.tfcEnabled;
17498
18034
  this.showCandleCountdown = config.showCandleCountdown ?? true;
17499
18035
  this.candleCountdownTimeframes = config.candleCountdownTimeframes;
17500
18036
  this._onOrderSubmit = onOrderSubmit;
@@ -17558,6 +18094,11 @@ var _ChartEngine = class _ChartEngine {
17558
18094
  this.drawingToolsEnabled ? () => this.toggleDrawingTools() : null,
17559
18095
  this.drawingToolsEnabled,
17560
18096
  this.settingsEnabled,
18097
+ this.tfcEnabled,
18098
+ this.tfcActive,
18099
+ this.tfcEnabled ? (enabled) => {
18100
+ this.setTfcActive(enabled);
18101
+ } : null,
17561
18102
  this.series,
17562
18103
  this.timeframe,
17563
18104
  null,
@@ -17709,7 +18250,7 @@ var _ChartEngine = class _ChartEngine {
17709
18250
  this.loadingOverlayEl.appendChild(loadingLabel);
17710
18251
  this.canvasWrap.appendChild(this.loadingOverlayEl);
17711
18252
  }
17712
- if (showUI) {
18253
+ if (showUI && !hideSymbolAndTick) {
17713
18254
  this.siEl = document.createElement("div");
17714
18255
  Object.assign(this.siEl.style, {
17715
18256
  position: "absolute",
@@ -17853,6 +18394,11 @@ var _ChartEngine = class _ChartEngine {
17853
18394
  const rect = this._cachedDrawRect ?? this.drawCanvas.getBoundingClientRect();
17854
18395
  const y = e.clientY - rect.top;
17855
18396
  const { priceH } = this.getChartLayout();
18397
+ const btnHalfH = this.uiConfig.tradeButton.size / 2;
18398
+ if (overlapsOffViewportPill(y, btnHalfH, this.offViewportHitAreas)) {
18399
+ this.tradeButton?.hide();
18400
+ return;
18401
+ }
17856
18402
  if (y >= 0 && y <= priceH) {
17857
18403
  this.tradeButton?.setY(y);
17858
18404
  const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
@@ -17867,7 +18413,7 @@ var _ChartEngine = class _ChartEngine {
17867
18413
  }
17868
18414
  middleRow.appendChild(this.canvasWrap);
17869
18415
  this.wrapper.appendChild(middleRow);
17870
- if (showUI) {
18416
+ if (showUI && showBottomBar) {
17871
18417
  const resolvedDurationMap = { ...DURATION_TIMEFRAME_MAP, ...durationTimeframeMap };
17872
18418
  this.bottomBar = new BottomBar(
17873
18419
  (d, tf) => {
@@ -18166,6 +18712,7 @@ var _ChartEngine = class _ChartEngine {
18166
18712
  }
18167
18713
  triggerLoad(reason) {
18168
18714
  if (!this.dataLoader) return;
18715
+ const gen = ++this._loadGeneration;
18169
18716
  this.isFetching = true;
18170
18717
  this.noMoreData = false;
18171
18718
  const end = /* @__PURE__ */ new Date();
@@ -18177,6 +18724,7 @@ var _ChartEngine = class _ChartEngine {
18177
18724
  const interval = this.timeframeToInterval(this.getBaseInterval());
18178
18725
  this.setLoading(true);
18179
18726
  this.dataLoader({ start, end, interval }).then((bars) => {
18727
+ if (gen !== this._loadGeneration) return;
18180
18728
  this.loadData(this.maybeAggregate(bars), reason === "duration");
18181
18729
  this.setLoading(false);
18182
18730
  this.emitter.emit("dataLoaded", {
@@ -18186,25 +18734,17 @@ var _ChartEngine = class _ChartEngine {
18186
18734
  end
18187
18735
  });
18188
18736
  }).catch(() => {
18737
+ if (gen !== this._loadGeneration) return;
18189
18738
  this.isFetching = false;
18190
18739
  this.setLoading(false);
18191
18740
  });
18192
18741
  }
18193
18742
  checkNeedMoreData() {
18194
18743
  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
- );
18744
+ const threshold = Math.max(20, this.prefetchThreshold);
18206
18745
  if (this._targetViewport.startIndex > threshold) return;
18207
18746
  if (this.dataStore.length === 0) return;
18747
+ const gen = this._loadGeneration;
18208
18748
  this.isFetching = true;
18209
18749
  const before = this.dataStore.all[0].time;
18210
18750
  const end = new Date(before - 1);
@@ -18216,9 +18756,11 @@ var _ChartEngine = class _ChartEngine {
18216
18756
  const interval = this.timeframeToInterval(this.getBaseInterval());
18217
18757
  this.setLoading(true);
18218
18758
  this.dataLoader({ start, end, interval }).then((bars) => {
18759
+ if (gen !== this._loadGeneration) return;
18219
18760
  this.prependData(this.maybeAggregate(bars));
18220
18761
  this.setLoading(false);
18221
18762
  }).catch(() => {
18763
+ if (gen !== this._loadGeneration) return;
18222
18764
  this.isFetching = false;
18223
18765
  this.setLoading(false);
18224
18766
  });
@@ -18473,6 +19015,31 @@ var _ChartEngine = class _ChartEngine {
18473
19015
  this.emitStateChange();
18474
19016
  return this;
18475
19017
  }
19018
+ /**
19019
+ * Toggle TFC (Trade from Charts) on or off at runtime.
19020
+ * When disabled, all trade levels, the trade button, and trade popover are hidden.
19021
+ * Only functional when `tfcEnabled` config option is `true` (default).
19022
+ */
19023
+ setTfcActive(enabled) {
19024
+ if (!this.tfcEnabled) return this;
19025
+ this.tfcActive = enabled;
19026
+ this.topBar?.setTfcActive(enabled);
19027
+ if (!enabled) {
19028
+ this.tradeButton?.hide();
19029
+ this.tradePopover?.hide();
19030
+ if (this.draftOrderLabel !== null) {
19031
+ this.removeDraftOrder();
19032
+ }
19033
+ }
19034
+ if (this.chartSettingsDialog) {
19035
+ this.chartSettingsDialog.destroy();
19036
+ this.chartSettingsDialog = null;
19037
+ }
19038
+ this.renderTradeLayer();
19039
+ this.emitter.emit("tfcToggle", { enabled });
19040
+ this.emitStateChange();
19041
+ return this;
19042
+ }
18476
19043
  clearDuration() {
18477
19044
  if (this.duration === void 0) return;
18478
19045
  this.duration = void 0;
@@ -18516,7 +19083,8 @@ var _ChartEngine = class _ChartEngine {
18516
19083
  })),
18517
19084
  positionRenderStyle: this.positionRenderStyle,
18518
19085
  tradeDisplayFilter: this.tradeDisplayFilter,
18519
- canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0
19086
+ canvasColors: Object.keys(this.settingsCanvasColors).length > 0 ? { ...this.settingsCanvasColors } : void 0,
19087
+ tfcEnabled: this.tfcActive
18520
19088
  };
18521
19089
  }
18522
19090
  /**
@@ -18588,9 +19156,14 @@ var _ChartEngine = class _ChartEngine {
18588
19156
  if (state.canvasColors) {
18589
19157
  this.settingsCanvasColors = state.canvasColors;
18590
19158
  }
19159
+ if (state.tfcEnabled !== void 0 && this.tfcEnabled) {
19160
+ this.tfcActive = state.tfcEnabled;
19161
+ this.topBar?.setTfcActive(state.tfcEnabled);
19162
+ }
18591
19163
  } finally {
18592
19164
  this.isRestoringState = false;
18593
19165
  }
19166
+ this.emitStateChange();
18594
19167
  this.scheduleRender();
18595
19168
  return this;
18596
19169
  }
@@ -19190,7 +19763,27 @@ var _ChartEngine = class _ChartEngine {
19190
19763
  */
19191
19764
  selectLevel(label) {
19192
19765
  this.selectedTradeLabel = label;
19193
- if (label !== null) this.crosshair = null;
19766
+ if (label !== null) {
19767
+ this.crosshair = null;
19768
+ const cluster = this.lastClusters.find(
19769
+ (c) => c.members.some((m) => m.level.label === label)
19770
+ );
19771
+ if (cluster) this.expandedClusters.add(cluster.id);
19772
+ }
19773
+ this.renderTradeLayer();
19774
+ return this;
19775
+ }
19776
+ /** Enable or disable trade-level fan-out clustering at runtime. */
19777
+ setLevelClusteringEnabled(enabled) {
19778
+ this.levelClusteringEnabled = enabled;
19779
+ this.expandedClusters.clear();
19780
+ this.renderTradeLayer();
19781
+ return this;
19782
+ }
19783
+ /** Update the cluster proximity threshold (in pixels) at runtime. Only effective when levelClusteringEnabled is true. */
19784
+ setClusterThresholdDistance(px) {
19785
+ this.clusterThresholdDistance = px;
19786
+ this.expandedClusters.clear();
19194
19787
  this.renderTradeLayer();
19195
19788
  return this;
19196
19789
  }
@@ -19373,7 +19966,7 @@ var _ChartEngine = class _ChartEngine {
19373
19966
  theme: this.themeName
19374
19967
  },
19375
19968
  this.labels.dialogs,
19376
- !!this._onOrderSubmit,
19969
+ !!this._onOrderSubmit && this.tfcActive,
19377
19970
  this.enableThemeToggle
19378
19971
  );
19379
19972
  this.getFloatingRoot().appendChild(this.chartSettingsDialog.el);
@@ -19484,11 +20077,12 @@ var _ChartEngine = class _ChartEngine {
19484
20077
  const dpr = window.devicePixelRatio || 1;
19485
20078
  const { tradeCtx: ctx, tradeCanvas: canvas, theme, viewport } = this;
19486
20079
  ctx.clearRect(0, 0, canvas.width, canvas.height);
19487
- if (this.levels.length === 0 || this.dataStore.length === 0) {
20080
+ if (!this.tfcActive || this.levels.length === 0 || this.dataStore.length === 0) {
19488
20081
  this.tradeLevelHitAreas = [];
19489
20082
  this.tradeLevelEditAreas = [];
19490
20083
  this.tradeHoverAreas = [];
19491
20084
  this.tradeAddBracketAreas = [];
20085
+ this.offViewportHitAreas = [];
19492
20086
  return;
19493
20087
  }
19494
20088
  const { chartW, priceH } = this.getChartLayout();
@@ -19543,6 +20137,7 @@ var _ChartEngine = class _ChartEngine {
19543
20137
  // Include true pending orders AND entry-order positions (ES/EL)
19544
20138
  processedLevels.filter((l) => l.type === "pending" || isEntryOrder(l))
19545
20139
  ) : processedLevels;
20140
+ const clusterThreshold = this.levelClusteringEnabled ? this.clusterThresholdDistance : 0;
19546
20141
  const result = renderTradeLevels(
19547
20142
  ctx,
19548
20143
  filteredLevels,
@@ -19560,9 +20155,22 @@ var _ChartEngine = class _ChartEngine {
19560
20155
  this.positionRenderStyle,
19561
20156
  this.hideLevelConfirmCancel,
19562
20157
  this.draftOrderLabel,
19563
- this.draftBracketPnl
20158
+ this.draftBracketPnl,
20159
+ this.expandedClusters,
20160
+ clusterThreshold
19564
20161
  );
19565
20162
  ctx.restore();
20163
+ const offVpResult = renderOffViewportIndicators(
20164
+ ctx,
20165
+ filteredLevels,
20166
+ priceRange,
20167
+ theme,
20168
+ chartW,
20169
+ priceH,
20170
+ this.tradeDragHandler?.dragLabel ?? null,
20171
+ this.selectedTradeLabel
20172
+ );
20173
+ this.offViewportHitAreas = offVpResult.hitAreas;
19566
20174
  ctx.restore();
19567
20175
  this.tradeLevelHitAreas = result.hitAreas;
19568
20176
  this.tradeLevelEditAreas = result.editAreas;
@@ -19570,6 +20178,14 @@ var _ChartEngine = class _ChartEngine {
19570
20178
  this.tradeLevelDragAreas = result.dragAreas;
19571
20179
  this.tradeLevelConfirmAreas = result.confirmAreas;
19572
20180
  this.tradeAddBracketAreas = result.addBracketAreas;
20181
+ this.clusterBadgeAreas = result.clusterBadgeAreas;
20182
+ this.lastClusters = result.clusters;
20183
+ if (this.expandedClusters.size > 0) {
20184
+ const currentIds = new Set(result.clusters.map((c) => c.id));
20185
+ for (const id of this.expandedClusters) {
20186
+ if (!currentIds.has(id)) this.expandedClusters.delete(id);
20187
+ }
20188
+ }
19573
20189
  }
19574
20190
  // ── Inline pending-change confirmation ────────────────────────────────────
19575
20191
  /** Returns the effective entry price for a level, preferring any staged main drag. */
@@ -20464,7 +21080,12 @@ var _ChartEngine = class _ChartEngine {
20464
21080
  if (this._isTradingActive) {
20465
21081
  this.tradeButton.hide();
20466
21082
  } else {
20467
- this.tradeButton.show();
21083
+ const _btnHalfH1 = this.uiConfig.tradeButton.size / 2;
21084
+ if (overlapsOffViewportPill(y, _btnHalfH1, this.offViewportHitAreas)) {
21085
+ this.tradeButton.hide();
21086
+ } else if (this.tfcActive) {
21087
+ this.tradeButton.show();
21088
+ }
20468
21089
  }
20469
21090
  this._tradeButtonClickData = { price, y };
20470
21091
  const _allBars = this.dataStore.all;
@@ -20477,6 +21098,9 @@ var _ChartEngine = class _ChartEngine {
20477
21098
  /** Shared helper: initiate a trade level main-line drag from a hit area. */
20478
21099
  _startTradeDragFromHit(dragHit, level, hitY) {
20479
21100
  if (!this.tradeDragHandler) return;
21101
+ const { priceH } = this.getChartLayout();
21102
+ const priceRange = this._displayedPriceRange ?? this.getScaledPriceRange(this.viewport);
21103
+ const levelPixelY = this.scaleManager.yToPixel(dragHit.price, priceRange, priceH);
20480
21104
  if (dragHit.isBracket) {
20481
21105
  this._pendingBracketOrderLabel = dragHit.bracketOrderLabel ?? null;
20482
21106
  this._isBracketNew = false;
@@ -20489,6 +21113,8 @@ var _ChartEngine = class _ChartEngine {
20489
21113
  level,
20490
21114
  dragHit.bracketType,
20491
21115
  dragHit.price,
21116
+ hitY,
21117
+ levelPixelY,
20492
21118
  marketPrice,
20493
21119
  this._effectiveEntryPrice(level.label, level.price)
20494
21120
  );
@@ -20508,6 +21134,7 @@ var _ChartEngine = class _ChartEngine {
20508
21134
  this.tradeDragHandler.startDrag(
20509
21135
  level,
20510
21136
  hitY,
21137
+ levelPixelY,
20511
21138
  dragHit.price,
20512
21139
  posLevel.stopLossPrice,
20513
21140
  posLevel.takeProfitPrice,
@@ -20522,6 +21149,7 @@ var _ChartEngine = class _ChartEngine {
20522
21149
  this.tradeDragHandler.startDrag(
20523
21150
  level,
20524
21151
  hitY,
21152
+ levelPixelY,
20525
21153
  dragHit.price,
20526
21154
  void 0,
20527
21155
  void 0,
@@ -20551,6 +21179,8 @@ var _ChartEngine = class _ChartEngine {
20551
21179
  level,
20552
21180
  addHit.bracketType,
20553
21181
  initialPrice,
21182
+ hitY,
21183
+ btnCenterY,
20554
21184
  marketPrice,
20555
21185
  this._effectiveEntryPrice(level.label, level.price)
20556
21186
  );
@@ -20741,6 +21371,38 @@ var _ChartEngine = class _ChartEngine {
20741
21371
  };
20742
21372
  this.scrollAnimId = requestAnimationFrame(step);
20743
21373
  }
21374
+ /**
21375
+ * Smooth-scroll the Y axis so `price` lands at the vertical center of the
21376
+ * chart. Animates `pricePanOffset` only — horizontal viewport is preserved.
21377
+ * Used by off-viewport level indicator clicks.
21378
+ */
21379
+ scrollToCenterPrice(price) {
21380
+ if (!isFinite(price) || this.dataStore.length === 0) return;
21381
+ if (this.scrollAnimId !== null) {
21382
+ cancelAnimationFrame(this.scrollAnimId);
21383
+ this.scrollAnimId = null;
21384
+ }
21385
+ const base = this.dataStore.getPriceRange(this.viewport);
21386
+ const naturalMid = (base.min + base.max) / 2;
21387
+ const startPan = this.pricePanOffset;
21388
+ const targetPan = price - naturalMid;
21389
+ if (Math.abs(targetPan - startPan) < 1e-9) return;
21390
+ const DURATION = 350;
21391
+ const t0 = performance.now();
21392
+ const ease = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
21393
+ const step = (now) => {
21394
+ const raw = Math.min((now - t0) / DURATION, 1);
21395
+ const prog = ease(raw);
21396
+ this.pricePanOffset = startPan + (targetPan - startPan) * prog;
21397
+ this.render();
21398
+ if (raw < 1) {
21399
+ this.scrollAnimId = requestAnimationFrame(step);
21400
+ } else {
21401
+ this.scrollAnimId = null;
21402
+ }
21403
+ };
21404
+ this.scrollAnimId = requestAnimationFrame(step);
21405
+ }
20744
21406
  resetPriceAxis() {
20745
21407
  this.priceScaleFactor = 1;
20746
21408
  this.pricePanOffset = 0;