hyperprop-charting-library 0.1.48 → 0.1.50

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
@@ -74,6 +74,9 @@ var DEFAULT_LABELS_OPTIONS = {
74
74
  askPrice: Number.NaN,
75
75
  showIndicatorNames: false,
76
76
  showIndicatorValues: false,
77
+ indicatorLegendPosition: "top-left",
78
+ indicatorLegendOffsetX: 10,
79
+ indicatorLegendOffsetY: 10,
77
80
  showCountdownToBarClose: false,
78
81
  noOverlapping: true,
79
82
  backgroundColor: "#0b1220",
@@ -507,7 +510,7 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
507
510
  }
508
511
  ctx.restore();
509
512
  };
510
- var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines) => {
513
+ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines, options = {}) => {
511
514
  const visible = [];
512
515
  for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
513
516
  const value = values[index];
@@ -515,7 +518,7 @@ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride,
515
518
  visible.push(value);
516
519
  }
517
520
  }
518
- if (visible.length === 0) return;
521
+ if (visible.length === 0) return void 0;
519
522
  const minValue = minOverride ?? Math.min(...visible);
520
523
  const maxValue = maxOverride ?? Math.max(...visible);
521
524
  const range = maxValue - minValue || 1;
@@ -564,6 +567,51 @@ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride,
564
567
  }
565
568
  }
566
569
  ctx.restore();
570
+ let latestValue = null;
571
+ for (let index = values.length - 1; index >= 0; index -= 1) {
572
+ const value = values[index];
573
+ if (Number.isFinite(value ?? Number.NaN)) {
574
+ latestValue = value;
575
+ break;
576
+ }
577
+ }
578
+ const decimals = options.decimals ?? 2;
579
+ const formatValue = (value) => value.toFixed(decimals);
580
+ const axisTicks = options.axisTicks ?? guideLines;
581
+ const paneInfo = {
582
+ ...options.title ? { title: options.title } : {},
583
+ axis: {
584
+ min: minValue,
585
+ max: maxValue,
586
+ ...axisTicks ? { ticks: axisTicks } : {},
587
+ decimals
588
+ }
589
+ };
590
+ if (guideLines) {
591
+ paneInfo.guideLines = guideLines.map((value) => ({ value, label: formatValue(value), style: "dotted" }));
592
+ }
593
+ if (latestValue !== null) {
594
+ paneInfo.legendValues = [
595
+ {
596
+ ...options.legendLabel ? { label: options.legendLabel } : {},
597
+ value: latestValue,
598
+ text: formatValue(latestValue),
599
+ color
600
+ }
601
+ ];
602
+ }
603
+ if (options.valueLabel !== false && latestValue !== null) {
604
+ paneInfo.valueLabels = [
605
+ {
606
+ value: latestValue,
607
+ text: formatValue(latestValue),
608
+ color,
609
+ backgroundColor: color,
610
+ textColor: "#0f172a"
611
+ }
612
+ ];
613
+ }
614
+ return paneInfo;
567
615
  };
568
616
  var getPercentileValue = (values, percentile) => {
569
617
  if (values.length === 0) {
@@ -736,7 +784,10 @@ var BUILTIN_STDDEV_INDICATOR = {
736
784
  renderContext.data,
737
785
  () => computeStdDevSeries(renderContext.data, length, inputs.source ?? "close")
738
786
  );
739
- drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2);
787
+ return drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2, void 0, void 0, void 0, {
788
+ title: `StdDev ${length}`,
789
+ decimals: 2
790
+ });
740
791
  }
741
792
  };
742
793
  var BUILTIN_ATR_INDICATOR = {
@@ -748,7 +799,10 @@ var BUILTIN_ATR_INDICATOR = {
748
799
  draw: (ctx, renderContext, inputs) => {
749
800
  const length = clampIndicatorLength(inputs.length, 14);
750
801
  const values = withCachedSeries(`atr|${length}`, renderContext.data, () => computeAtrSeries(renderContext.data, length));
751
- drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2);
802
+ return drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2, void 0, void 0, void 0, {
803
+ title: `ATR ${length}`,
804
+ decimals: 2
805
+ });
752
806
  }
753
807
  };
754
808
  var BUILTIN_RSI_INDICATOR = {
@@ -760,7 +814,7 @@ var BUILTIN_RSI_INDICATOR = {
760
814
  draw: (ctx, renderContext, inputs) => {
761
815
  const length = clampIndicatorLength(inputs.length, 14);
762
816
  const values = withCachedSeries(`rsi|${length}`, renderContext.data, () => computeRsiSeries(renderContext.data, length));
763
- drawSeparateSeries(
817
+ return drawSeparateSeries(
764
818
  ctx,
765
819
  renderContext,
766
820
  values,
@@ -768,7 +822,12 @@ var BUILTIN_RSI_INDICATOR = {
768
822
  Number(inputs.width) || 2,
769
823
  0,
770
824
  100,
771
- [30, 50, 70]
825
+ [30, 50, 70],
826
+ {
827
+ title: `RSI ${length}`,
828
+ axisTicks: [0, 30, 50, 70, 100],
829
+ decimals: 2
830
+ }
772
831
  );
773
832
  }
774
833
  };
@@ -867,6 +926,9 @@ function createChart(element, options = {}) {
867
926
  let crosshairPoint = null;
868
927
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
869
928
  let doubleClickAction = mergedOptions.doubleClickAction;
929
+ let noOverlappingLineLabels = DEFAULT_LABELS_OPTIONS.noOverlapping;
930
+ let rightAxisLabelSlots = [];
931
+ let plotLabelSlots = [];
870
932
  let smoothedTickerPrice = null;
871
933
  let tickerPriceTarget = null;
872
934
  let smoothedTickerVolume = null;
@@ -963,6 +1025,33 @@ function createChart(element, options = {}) {
963
1025
  const clamp = (value, min, max) => {
964
1026
  return Math.min(max, Math.max(min, value));
965
1027
  };
1028
+ const resetLabelSlots = (enabled) => {
1029
+ noOverlappingLineLabels = enabled;
1030
+ rightAxisLabelSlots = [];
1031
+ plotLabelSlots = [];
1032
+ };
1033
+ const placeLabelSlot = (targetY, height2, minY, maxY, slots, gap = 2) => {
1034
+ const safeMaxY = Math.max(minY, maxY);
1035
+ const clampedTarget = clamp(targetY, minY, safeMaxY);
1036
+ if (!noOverlappingLineLabels || height2 <= 0) {
1037
+ return clampedTarget;
1038
+ }
1039
+ const overlaps = (y) => slots.some((slot) => y < slot.y + slot.height + gap && y + height2 + gap > slot.y);
1040
+ const candidates = [clampedTarget];
1041
+ for (const slot of slots) {
1042
+ candidates.push(slot.y - height2 - gap, slot.y + slot.height + gap);
1043
+ }
1044
+ const placedY = candidates.map((candidate) => clamp(candidate, minY, safeMaxY)).sort((a, b) => Math.abs(a - clampedTarget) - Math.abs(b - clampedTarget)).find((candidate) => !overlaps(candidate)) ?? clampedTarget;
1045
+ slots.push({ y: placedY, height: height2 });
1046
+ slots.sort((a, b) => a.y - b.y);
1047
+ return placedY;
1048
+ };
1049
+ const placeRightAxisLabel = (targetY, height2, minY, maxY) => {
1050
+ return placeLabelSlot(targetY, height2, minY, maxY, rightAxisLabelSlots);
1051
+ };
1052
+ const placePlotLabel = (targetY, height2, minY, maxY) => {
1053
+ return placeLabelSlot(targetY, height2, minY, maxY, plotLabelSlots);
1054
+ };
966
1055
  const dashPatterns = {
967
1056
  dotted: mergedOptions.dashPatterns.dotted ?? DEFAULT_DASH_PATTERNS.dotted,
968
1057
  dashed: mergedOptions.dashPatterns.dashed ?? DEFAULT_DASH_PATTERNS.dashed,
@@ -1302,6 +1391,11 @@ function createChart(element, options = {}) {
1302
1391
  const pad = (value) => String(value).padStart(2, "0");
1303
1392
  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`;
1304
1393
  };
1394
+ const getRightAxisLabelX = (chartRight) => chartRight + 1;
1395
+ const getRightAxisLabelWidth = (chartRight, contentWidth) => {
1396
+ const scaleWidth = Math.max(0, Math.floor(width - getRightAxisLabelX(chartRight)));
1397
+ return Math.max(contentWidth, scaleWidth);
1398
+ };
1305
1399
  const drawPriceLine = (line, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
1306
1400
  const mergedLine = { ...DEFAULT_PRICE_LINE_OPTIONS, ...line };
1307
1401
  if (!mergedLine.visible || !Number.isFinite(mergedLine.price)) {
@@ -1326,9 +1420,10 @@ function createChart(element, options = {}) {
1326
1420
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1327
1421
  const labelPaddingX = 8;
1328
1422
  const labelHeight = 20;
1329
- const labelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1330
- const labelX = chartRight + 4;
1331
- const labelY = clamp(lineY - labelHeight / 2, chartTop, chartBottom - labelHeight);
1423
+ const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1424
+ const labelWidth = getRightAxisLabelWidth(chartRight, measuredLabelWidth);
1425
+ const labelX = getRightAxisLabelX(chartRight);
1426
+ const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
1332
1427
  ctx.fillStyle = mergedLine.labelBackgroundColor;
1333
1428
  fillRoundedRect(
1334
1429
  Math.round(labelX),
@@ -1461,17 +1556,18 @@ function createChart(element, options = {}) {
1461
1556
  leftWidgetX = maxWidgetX;
1462
1557
  }
1463
1558
  leftWidgetX = clamp(leftWidgetX, leftWidgetXBase, maxWidgetX);
1464
- const labelY = clamp(lineY - labelHeight / 2, chartTop, chartBottom - labelHeight);
1559
+ const targetLabelY = clamp(lineY - labelHeight / 2, chartTop, chartBottom - labelHeight);
1560
+ const widgetY = placePlotLabel(targetLabelY, labelHeight, chartTop, chartBottom - labelHeight);
1465
1561
  const borderRadius = Math.max(0, mergedLine.labelBorderRadius);
1466
1562
  const widgetBackground = mergedOptions.backgroundColor;
1467
1563
  const widgetBorder = color;
1468
1564
  const textColor = line.labelTextColor ?? (mergedLine.type === "takeProfit" ? "#5eead4" : mergedLine.type === "stop" ? "#fbbf24" : mergedLine.labelTextColor);
1469
1565
  const mainWidgetX = leftWidgetX + actionButtonsTotalWidth + actionButtonsGap;
1470
1566
  ctx.fillStyle = widgetBackground;
1471
- fillRoundedRect(Math.round(mainWidgetX), Math.round(labelY), mainWidgetWidth, labelHeight, borderRadius);
1567
+ fillRoundedRect(Math.round(mainWidgetX), Math.round(widgetY), mainWidgetWidth, labelHeight, borderRadius);
1472
1568
  ctx.strokeStyle = widgetBorder;
1473
1569
  ctx.lineWidth = 1;
1474
- strokeRoundedRect(Math.round(mainWidgetX), Math.round(labelY), mainWidgetWidth, labelHeight, borderRadius);
1570
+ strokeRoundedRect(Math.round(mainWidgetX), Math.round(widgetY), mainWidgetWidth, labelHeight, borderRadius);
1475
1571
  let cursorX = mainWidgetX;
1476
1572
  const separatorColor = "rgba(148,163,184,0.45)";
1477
1573
  if (actionButtonMetrics.length > 0) {
@@ -1481,7 +1577,7 @@ function createChart(element, options = {}) {
1481
1577
  const fullHeight = button.fullHeight ?? mergedLine.actionButtonFullHeight;
1482
1578
  const actionPadding = fullHeight ? 0 : 2;
1483
1579
  const actionX = actionCursorX + actionPadding;
1484
- const actionY = labelY + actionPadding;
1580
+ const actionY = widgetY + actionPadding;
1485
1581
  const actionH = labelHeight - actionPadding * 2;
1486
1582
  const actionW = Math.max(8, width2 - actionPadding * 2);
1487
1583
  const actionRadius = Math.max(1, button.borderRadius ?? mergedLine.actionButtonBorderRadius);
@@ -1521,29 +1617,29 @@ function createChart(element, options = {}) {
1521
1617
  });
1522
1618
  }
1523
1619
  if (qtyWidth > 0) {
1524
- drawText(qtyText, cursorX + segmentPaddingX, labelY + labelHeight / 2, "left", "middle", textColor);
1620
+ drawText(qtyText, cursorX + segmentPaddingX, widgetY + labelHeight / 2, "left", "middle", textColor);
1525
1621
  cursorX += qtyWidth;
1526
1622
  ctx.strokeStyle = separatorColor;
1527
1623
  ctx.beginPath();
1528
- ctx.moveTo(crisp(cursorX), labelY + 4);
1529
- ctx.lineTo(crisp(cursorX), labelY + labelHeight - 4);
1624
+ ctx.moveTo(crisp(cursorX), widgetY + 4);
1625
+ ctx.lineTo(crisp(cursorX), widgetY + labelHeight - 4);
1530
1626
  ctx.stroke();
1531
1627
  }
1532
- drawText(centerText, cursorX + segmentPaddingX, labelY + labelHeight / 2, "left", "middle", textColor);
1628
+ drawText(centerText, cursorX + segmentPaddingX, widgetY + labelHeight / 2, "left", "middle", textColor);
1533
1629
  cursorX += centerWidth;
1534
1630
  if (showCloseButton) {
1535
1631
  ctx.strokeStyle = separatorColor;
1536
1632
  ctx.beginPath();
1537
- ctx.moveTo(crisp(cursorX), labelY + 4);
1538
- ctx.lineTo(crisp(cursorX), labelY + labelHeight - 4);
1633
+ ctx.moveTo(crisp(cursorX), widgetY + 4);
1634
+ ctx.lineTo(crisp(cursorX), widgetY + labelHeight - 4);
1539
1635
  ctx.stroke();
1540
- drawText("x", cursorX + closeWidth / 2, labelY + labelHeight / 2, "center", "middle", textColor);
1636
+ drawText("x", cursorX + closeWidth / 2, widgetY + labelHeight / 2, "center", "middle", textColor);
1541
1637
  if (mergedLine.id) {
1542
1638
  orderActionRegions.push({
1543
1639
  orderId: mergedLine.id,
1544
1640
  action: closeAction,
1545
1641
  x: cursorX,
1546
- y: labelY,
1642
+ y: widgetY,
1547
1643
  width: closeWidth,
1548
1644
  height: labelHeight,
1549
1645
  line: mergedLine
@@ -1564,17 +1660,18 @@ function createChart(element, options = {}) {
1564
1660
  const priceText = formatPrice(renderPrice);
1565
1661
  const pricePaddingX = 8;
1566
1662
  const measuredPriceWidth = getPriceLabelWidth(priceText, pricePaddingX);
1567
- const priceWidth = mergedLine.id === void 0 ? measuredPriceWidth : Math.max(measuredPriceWidth, orderPriceTagWidthById.get(mergedLine.id) ?? 0);
1663
+ const priceWidth = mergedLine.id === void 0 ? getRightAxisLabelWidth(chartRight, measuredPriceWidth) : getRightAxisLabelWidth(chartRight, Math.max(measuredPriceWidth, orderPriceTagWidthById.get(mergedLine.id) ?? 0));
1568
1664
  if (mergedLine.id) {
1569
1665
  orderPriceTagWidthById.set(mergedLine.id, priceWidth);
1570
1666
  }
1571
- const priceX = chartRight + 4;
1667
+ const priceX = getRightAxisLabelX(chartRight);
1668
+ const priceY = placeRightAxisLabel(targetLabelY, labelHeight, chartTop, chartBottom - labelHeight);
1572
1669
  ctx.fillStyle = mergedLine.labelBackgroundColor ?? color;
1573
- fillRoundedRect(Math.round(priceX), Math.round(labelY), priceWidth, labelHeight, borderRadius);
1670
+ fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, borderRadius);
1574
1671
  ctx.strokeStyle = widgetBorder;
1575
1672
  ctx.lineWidth = 1;
1576
- strokeRoundedRect(Math.round(priceX), Math.round(labelY), priceWidth, labelHeight, borderRadius);
1577
- drawText(priceText, priceX + pricePaddingX, labelY + labelHeight / 2, "left", "middle", textColor);
1673
+ strokeRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, borderRadius);
1674
+ drawText(priceText, priceX + pricePaddingX, priceY + labelHeight / 2, "left", "middle", textColor);
1578
1675
  };
1579
1676
  const fillRoundedRect = (x, y, widthValue, heightValue, radiusValue) => {
1580
1677
  const maxRadius = Math.min(widthValue, heightValue) / 2;
@@ -1642,9 +1739,63 @@ function createChart(element, options = {}) {
1642
1739
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1643
1740
  ctx.fillStyle = mergedOptions.backgroundColor;
1644
1741
  ctx.fillRect(0, 0, width, height);
1742
+ const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1743
+ const estimateRightMargin = () => {
1744
+ const paddingX = Math.max(4, labels.labelPaddingX);
1745
+ const measure = (text) => Math.ceil(ctx.measureText(text).width) + paddingX * 2;
1746
+ let required = margin.right - 1;
1747
+ const include = (text) => {
1748
+ const normalized = text?.trim();
1749
+ if (normalized) {
1750
+ required = Math.max(required, measure(normalized));
1751
+ }
1752
+ };
1753
+ const ticker2 = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1754
+ const lastPoint2 = data[data.length - 1];
1755
+ if ((ticker2.visible ?? true) && labels.showLastPrice && lastPoint2) {
1756
+ include(formatPrice(lastPoint2.c));
1757
+ if (ticker2.labelSubtext) include(String(ticker2.labelSubtext));
1758
+ for (const subtext of ticker2.labelSubtexts ?? []) {
1759
+ include(String(subtext));
1760
+ }
1761
+ }
1762
+ if (labels.showSymbolName) {
1763
+ include(labels.symbolName);
1764
+ }
1765
+ if (labels.showPreviousClose) {
1766
+ const previousCloseCandidate = Number.isFinite(labels.previousClosePrice) ? labels.previousClosePrice : data.length > 1 ? data[data.length - 2]?.c : Number.NaN;
1767
+ if (previousCloseCandidate !== void 0 && Number.isFinite(previousCloseCandidate)) {
1768
+ include(`PDC ${formatPrice(previousCloseCandidate)}`);
1769
+ }
1770
+ }
1771
+ if (labels.showHighLow && data.length > 0) {
1772
+ include(`H ${formatPrice(Math.max(...data.map((point) => point.h)))}`);
1773
+ include(`L ${formatPrice(Math.min(...data.map((point) => point.l)))}`);
1774
+ }
1775
+ if (labels.showBidAsk) {
1776
+ if (Number.isFinite(labels.bidPrice)) include(`B ${formatPrice(labels.bidPrice)}`);
1777
+ if (Number.isFinite(labels.askPrice)) include(`A ${formatPrice(labels.askPrice)}`);
1778
+ }
1779
+ for (const line of priceLines) {
1780
+ const mergedLine = { ...DEFAULT_PRICE_LINE_OPTIONS, ...line };
1781
+ if (mergedLine.visible && Number.isFinite(mergedLine.price)) {
1782
+ include(mergedLine.label ?? formatPrice(mergedLine.price));
1783
+ }
1784
+ }
1785
+ for (const line of orderLines) {
1786
+ const mergedLine = { ...DEFAULT_ORDER_LINE_OPTIONS, ...line };
1787
+ const renderPrice = mergedLine.behavior === "follow" && Number.isFinite(mergedLine.followPrice) ? mergedLine.followPrice : mergedLine.price;
1788
+ if (mergedLine.visible && Number.isFinite(renderPrice)) {
1789
+ include(formatPrice(renderPrice));
1790
+ }
1791
+ }
1792
+ const maxRightMargin = Math.max(margin.right, width - margin.left - 160);
1793
+ return Math.min(maxRightMargin, Math.max(margin.right, Math.ceil(required + 1)));
1794
+ };
1795
+ const rightMargin = estimateRightMargin();
1645
1796
  const chartLeft = margin.left;
1646
1797
  const chartTop = margin.top;
1647
- const chartWidth = width - margin.left - margin.right;
1798
+ const chartWidth = width - margin.left - rightMargin;
1648
1799
  const fullChartHeight = height - margin.top - margin.bottom;
1649
1800
  const fullChartBottom = chartTop + fullChartHeight;
1650
1801
  const chartRight = chartLeft + chartWidth;
@@ -1997,7 +2148,7 @@ function createChart(element, options = {}) {
1997
2148
  ctx.beginPath();
1998
2149
  ctx.rect(chartLeft + 1, paneTop + 1, Math.max(0, chartWidth - 2), Math.max(0, paneHeight - 2));
1999
2150
  ctx.clip();
2000
- plugin.draw(
2151
+ const paneInfo = plugin.draw(
2001
2152
  ctx,
2002
2153
  {
2003
2154
  data,
@@ -2030,6 +2181,60 @@ function createChart(element, options = {}) {
2030
2181
  ctx.lineTo(crisp(chartRight), crisp(paneTop));
2031
2182
  ctx.stroke();
2032
2183
  ctx.restore();
2184
+ const axisInfo = paneInfo?.axis;
2185
+ if (axisInfo && Number.isFinite(axisInfo.min) && Number.isFinite(axisInfo.max) && axisInfo.max !== axisInfo.min) {
2186
+ const paneRange = axisInfo.max - axisInfo.min;
2187
+ const yFromPaneValue = (value) => {
2188
+ const ratio = (value - axisInfo.min) / paneRange;
2189
+ return paneBottom - ratio * paneHeight;
2190
+ };
2191
+ const formatPaneValue = (value) => {
2192
+ if (axisInfo.format) {
2193
+ return axisInfo.format(value);
2194
+ }
2195
+ const decimals = axisInfo.decimals ?? (Math.abs(paneRange) <= 2 ? 2 : Math.abs(paneRange) <= 20 ? 1 : 0);
2196
+ return value.toFixed(Math.max(0, Math.min(8, Math.round(decimals))));
2197
+ };
2198
+ const axisTicks = axisInfo.ticks && axisInfo.ticks.length > 0 ? axisInfo.ticks : [axisInfo.min, axisInfo.min + paneRange / 2, axisInfo.max];
2199
+ const uniqueTicks = Array.from(new Set(axisTicks.filter((tick) => Number.isFinite(tick))));
2200
+ ctx.save();
2201
+ ctx.font = `${yAxisFontSize}px ${mergedOptions.fontFamily}`;
2202
+ for (const tick of uniqueTicks) {
2203
+ if (tick < axisInfo.min || tick > axisInfo.max) {
2204
+ continue;
2205
+ }
2206
+ drawText(formatPaneValue(tick), chartRight + 6, yFromPaneValue(tick), "left", "middle", yAxis.textColor);
2207
+ }
2208
+ ctx.restore();
2209
+ if (labels.visible) {
2210
+ const prevFont = ctx.font;
2211
+ ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2212
+ const legendTitle = paneInfo.title ?? plugin.name;
2213
+ const legendValues = paneInfo.legendValues ?? [];
2214
+ const legendParts = [
2215
+ legendTitle,
2216
+ ...legendValues.map((value) => value.text ?? (value.value === void 0 ? "" : formatPaneValue(value.value))).filter(Boolean)
2217
+ ].filter(Boolean);
2218
+ if (legendParts.length > 0) {
2219
+ drawText(legendParts.join(" "), chartLeft + 10, paneTop + 8, "left", "top", labels.indicatorTextColor);
2220
+ }
2221
+ for (const label of paneInfo.valueLabels ?? []) {
2222
+ if (!Number.isFinite(label.value) || label.value < axisInfo.min || label.value > axisInfo.max) {
2223
+ continue;
2224
+ }
2225
+ const text = label.text ?? formatPaneValue(label.value);
2226
+ const labelPaddingX = 7;
2227
+ const labelHeight = Math.max(16, yAxisFontSize + 8);
2228
+ const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(ctx.measureText(text).width) + labelPaddingX * 2);
2229
+ const labelX = getRightAxisLabelX(chartRight);
2230
+ const labelY = clamp(yFromPaneValue(label.value) - labelHeight / 2, paneTop + 2, paneBottom - labelHeight - 2);
2231
+ ctx.fillStyle = label.backgroundColor ?? label.color ?? labels.backgroundColor;
2232
+ fillRoundedRect(Math.round(labelX), Math.round(labelY), labelWidth, labelHeight, Math.max(0, labels.borderRadius));
2233
+ drawText(text, labelX + labelPaddingX, labelY + labelHeight / 2, "left", "middle", label.textColor ?? labels.textColor);
2234
+ }
2235
+ ctx.font = prevFont;
2236
+ }
2237
+ }
2033
2238
  });
2034
2239
  }
2035
2240
  if (crosshair.visible && crosshairPoint && crosshair.showVertical) {
@@ -2061,7 +2266,7 @@ function createChart(element, options = {}) {
2061
2266
  drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
2062
2267
  ctx.font = prevFont;
2063
2268
  }
2064
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2269
+ resetLabelSlots(labels.noOverlapping);
2065
2270
  const priceAxisLabels = [];
2066
2271
  const addPriceAxisLabel = (label) => {
2067
2272
  if (!labels.visible || !Number.isFinite(label.price) || label.text.length === 0) {
@@ -2224,12 +2429,13 @@ function createChart(element, options = {}) {
2224
2429
  ctx.font = baseFont;
2225
2430
  }
2226
2431
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
2432
+ const labelWidth = getRightAxisLabelWidth(chartRight, labelTextWidth);
2227
2433
  return {
2228
2434
  ...label,
2229
2435
  subtexts,
2230
2436
  subtextFontSize,
2231
2437
  height: labelHeight,
2232
- width: labelTextWidth,
2438
+ width: labelWidth,
2233
2439
  targetY: clamp(yFromPrice(label.price), chartTop + 1, chartBottom - 1) - labelHeight / 2,
2234
2440
  y: 0
2235
2441
  };
@@ -2250,7 +2456,16 @@ function createChart(element, options = {}) {
2250
2456
  }
2251
2457
  }
2252
2458
  }
2253
- const labelX = chartRight + 4;
2459
+ if (labels.noOverlapping) {
2460
+ rightAxisLabelSlots.push(
2461
+ ...positionedLabels.map((label) => ({
2462
+ y: label.y,
2463
+ height: label.height
2464
+ }))
2465
+ );
2466
+ rightAxisLabelSlots.sort((a, b) => a.y - b.y);
2467
+ }
2468
+ const labelX = getRightAxisLabelX(chartRight);
2254
2469
  for (const label of positionedLabels) {
2255
2470
  ctx.fillStyle = label.backgroundColor;
2256
2471
  fillRoundedRect(Math.round(labelX), Math.round(label.y), label.width, label.height, labelRadius);
@@ -2295,7 +2510,14 @@ function createChart(element, options = {}) {
2295
2510
  const prevFont = ctx.font;
2296
2511
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2297
2512
  const legendText = labelEntries.join(" ");
2298
- drawText(legendText, chartLeft + 10, chartTop + 10, "left", "top", labels.indicatorTextColor);
2513
+ const offsetX = Math.max(0, Number(labels.indicatorLegendOffsetX) || 0);
2514
+ const offsetY = Math.max(0, Number(labels.indicatorLegendOffsetY) || 0);
2515
+ const position = labels.indicatorLegendPosition;
2516
+ const isRight = position === "top-right" || position === "bottom-right";
2517
+ const isBottom = position === "bottom-left" || position === "bottom-right";
2518
+ const legendX = isRight ? chartRight - offsetX : chartLeft + offsetX;
2519
+ const legendY = isBottom ? chartBottom - offsetY : chartTop + offsetY;
2520
+ drawText(legendText, legendX, legendY, isRight ? "right" : "left", isBottom ? "bottom" : "top", labels.indicatorTextColor);
2299
2521
  ctx.font = prevFont;
2300
2522
  }
2301
2523
  }
@@ -2353,8 +2575,8 @@ function createChart(element, options = {}) {
2353
2575
  if (crosshair.showPriceLabel) {
2354
2576
  const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
2355
2577
  const priceText = formatPrice(hoverPrice);
2356
- const priceWidth = getPriceLabelWidth(priceText, labelPaddingX);
2357
- const priceX = chartRight + 4;
2578
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
2579
+ const priceX = getRightAxisLabelX(chartRight);
2358
2580
  const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
2359
2581
  ctx.fillStyle = labelBackground;
2360
2582
  fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
package/docs/API.md CHANGED
@@ -171,8 +171,11 @@ TradingView-style labels can be controlled from a single top-level object:
171
171
  - `bidPrice`, `askPrice` (optional market data values for bid/ask labels)
172
172
  - `showIndicatorNames` (default `false`; draws active indicator names in the chart)
173
173
  - `showIndicatorValues` (default `false`; appends simple indicator input values)
174
+ - `indicatorLegendPosition` (`"top-left" | "top-right" | "bottom-left" | "bottom-right"`, default `"top-left"`)
175
+ - `indicatorLegendOffsetX` (default `10`)
176
+ - `indicatorLegendOffsetY` (default `10`; increase this if your frontend overlays a symbol/OHLC HUD in the top-left)
174
177
  - `showCountdownToBarClose` (default `false`; draws a bottom-axis countdown based on candle time spacing)
175
- - `noOverlapping` (default `true`; stacks price-scale labels so they do not cover each other)
178
+ - `noOverlapping` (default `true`; stacks price-scale labels, price-line tags, order/position price tags, and order widgets so they do not cover each other)
176
179
  - Style fields: `backgroundColor`, `textColor`, `mutedTextColor`, `symbolNameBackgroundColor`, `symbolNameTextColor`, `previousCloseColor`, `highLowColor`, `bidColor`, `askColor`, `indicatorTextColor`, `borderRadius`, `labelHeight`, `labelPaddingX`
177
180
 
178
181
  Example:
@@ -190,6 +193,8 @@ createChart(root, {
190
193
  askPrice: 5235.0,
191
194
  showIndicatorNames: true,
192
195
  showIndicatorValues: true,
196
+ indicatorLegendPosition: "top-left",
197
+ indicatorLegendOffsetY: 34,
193
198
  showCountdownToBarClose: true,
194
199
  noOverlapping: true
195
200
  }
@@ -311,7 +316,7 @@ Connector/fill visuals:
311
316
  - `pane?: "overlay" | "separate"` (default `"overlay"`)
312
317
  - `paneHeightRatio?: number` (for separate panes)
313
318
  - `defaultInputs?: Record<string, unknown>`
314
- - `draw(ctx, renderContext, inputs): void`
319
+ - `draw(ctx, renderContext, inputs): void | IndicatorPaneRenderInfo`
315
320
 
316
321
  `IndicatorRenderContext` includes:
317
322
 
@@ -319,14 +324,51 @@ Connector/fill visuals:
319
324
  - pane bounds (`chartLeft`, `chartRight`, `chartTop`, `chartBottom`, `chartWidth`, `chartHeight`)
320
325
  - `xFromIndex(index)` helper
321
326
  - `yFromPrice(price)` helper (available for overlay indicators, `null` for separate-pane indicators)
327
+ - `getCandleDirectionByIndex(index)` and `getVolumeByIndex(index)` helpers
322
328
  - theme colors (`upColor`, `downColor`)
323
329
 
330
+ For separate-pane indicators, return `IndicatorPaneRenderInfo` from `draw()` when the core should render TradingView-style pane UI:
331
+
332
+ ```ts
333
+ type IndicatorPaneRenderInfo = {
334
+ title?: string;
335
+ axis?: {
336
+ min: number;
337
+ max: number;
338
+ ticks?: number[];
339
+ decimals?: number;
340
+ format?: (value: number) => string;
341
+ };
342
+ guideLines?: Array<{
343
+ value: number;
344
+ label?: string;
345
+ color?: string;
346
+ style?: "solid" | "dotted" | "dashed";
347
+ }>;
348
+ legendValues?: Array<{
349
+ label?: string;
350
+ value?: number;
351
+ text?: string;
352
+ color?: string;
353
+ }>;
354
+ valueLabels?: Array<{
355
+ value: number;
356
+ text?: string;
357
+ color?: string;
358
+ backgroundColor?: string;
359
+ textColor?: string;
360
+ }>;
361
+ };
362
+ ```
363
+
364
+ The core uses this metadata to draw separate-pane right-side axis values, top-left pane legends, guide-level labels, and latest-value tags. Existing plugins can keep returning nothing.
365
+
324
366
  Built-in:
325
367
 
326
368
  - `"volume"`: overlay histogram by default (uses `OhlcDataPoint.v`; can be moved to separate pane)
327
369
  - `"sma"`: Simple Moving Average (overlay)
328
370
  - `"ema"`: Exponential Moving Average (overlay)
329
- - `"rsi"`: Relative Strength Index (separate pane, 30/50/70 guides)
371
+ - `"rsi"`: Relative Strength Index (separate pane, 30/50/70 guides, 0/30/50/70/100 axis labels, latest-value tag)
330
372
  - `"wma"`: Weighted Moving Average (overlay)
331
373
  - `"vwma"`: Volume Weighted Moving Average (overlay, uses `OhlcDataPoint.v`)
332
374
  - `"rma"`: Wilder's Moving Average (overlay)