hyperprop-charting-library 0.1.49 → 0.1.51

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,10 @@ var DEFAULT_LABELS_OPTIONS = {
74
74
  askPrice: Number.NaN,
75
75
  showIndicatorNames: false,
76
76
  showIndicatorValues: false,
77
+ showIndicatorValueLabels: true,
78
+ indicatorLegendPosition: "top-left",
79
+ indicatorLegendOffsetX: 10,
80
+ indicatorLegendOffsetY: 10,
77
81
  showCountdownToBarClose: false,
78
82
  noOverlapping: true,
79
83
  backgroundColor: "#0b1220",
@@ -507,7 +511,7 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
507
511
  }
508
512
  ctx.restore();
509
513
  };
510
- var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines) => {
514
+ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines, options = {}) => {
511
515
  const visible = [];
512
516
  for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
513
517
  const value = values[index];
@@ -515,7 +519,7 @@ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride,
515
519
  visible.push(value);
516
520
  }
517
521
  }
518
- if (visible.length === 0) return;
522
+ if (visible.length === 0) return void 0;
519
523
  const minValue = minOverride ?? Math.min(...visible);
520
524
  const maxValue = maxOverride ?? Math.max(...visible);
521
525
  const range = maxValue - minValue || 1;
@@ -564,6 +568,51 @@ var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride,
564
568
  }
565
569
  }
566
570
  ctx.restore();
571
+ let latestValue = null;
572
+ for (let index = values.length - 1; index >= 0; index -= 1) {
573
+ const value = values[index];
574
+ if (Number.isFinite(value ?? Number.NaN)) {
575
+ latestValue = value;
576
+ break;
577
+ }
578
+ }
579
+ const decimals = options.decimals ?? 2;
580
+ const formatValue = (value) => value.toFixed(decimals);
581
+ const axisTicks = options.axisTicks ?? guideLines;
582
+ const paneInfo = {
583
+ ...options.title ? { title: options.title } : {},
584
+ axis: {
585
+ min: minValue,
586
+ max: maxValue,
587
+ ...axisTicks ? { ticks: axisTicks } : {},
588
+ decimals
589
+ }
590
+ };
591
+ if (guideLines) {
592
+ paneInfo.guideLines = guideLines.map((value) => ({ value, label: formatValue(value), style: "dotted" }));
593
+ }
594
+ if (latestValue !== null) {
595
+ paneInfo.legendValues = [
596
+ {
597
+ ...options.legendLabel ? { label: options.legendLabel } : {},
598
+ value: latestValue,
599
+ text: formatValue(latestValue),
600
+ color
601
+ }
602
+ ];
603
+ }
604
+ if (options.valueLabel !== false && latestValue !== null) {
605
+ paneInfo.valueLabels = [
606
+ {
607
+ value: latestValue,
608
+ text: formatValue(latestValue),
609
+ color: options.valueLabelColor ?? color,
610
+ backgroundColor: options.valueLabelBackgroundColor ?? options.valueLabelColor ?? color,
611
+ textColor: options.valueLabelTextColor ?? "#0f172a"
612
+ }
613
+ ];
614
+ }
615
+ return paneInfo;
567
616
  };
568
617
  var getPercentileValue = (values, percentile) => {
569
618
  if (values.length === 0) {
@@ -736,7 +785,10 @@ var BUILTIN_STDDEV_INDICATOR = {
736
785
  renderContext.data,
737
786
  () => computeStdDevSeries(renderContext.data, length, inputs.source ?? "close")
738
787
  );
739
- drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2);
788
+ return drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#f97316", Number(inputs.width) || 2, void 0, void 0, void 0, {
789
+ title: `StdDev ${length}`,
790
+ decimals: 2
791
+ });
740
792
  }
741
793
  };
742
794
  var BUILTIN_ATR_INDICATOR = {
@@ -748,7 +800,10 @@ var BUILTIN_ATR_INDICATOR = {
748
800
  draw: (ctx, renderContext, inputs) => {
749
801
  const length = clampIndicatorLength(inputs.length, 14);
750
802
  const values = withCachedSeries(`atr|${length}`, renderContext.data, () => computeAtrSeries(renderContext.data, length));
751
- drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2);
803
+ return drawSeparateSeries(ctx, renderContext, values, inputs.color ?? "#eab308", Number(inputs.width) || 2, void 0, void 0, void 0, {
804
+ title: `ATR ${length}`,
805
+ decimals: 2
806
+ });
752
807
  }
753
808
  };
754
809
  var BUILTIN_RSI_INDICATOR = {
@@ -760,7 +815,7 @@ var BUILTIN_RSI_INDICATOR = {
760
815
  draw: (ctx, renderContext, inputs) => {
761
816
  const length = clampIndicatorLength(inputs.length, 14);
762
817
  const values = withCachedSeries(`rsi|${length}`, renderContext.data, () => computeRsiSeries(renderContext.data, length));
763
- drawSeparateSeries(
818
+ return drawSeparateSeries(
764
819
  ctx,
765
820
  renderContext,
766
821
  values,
@@ -768,7 +823,15 @@ var BUILTIN_RSI_INDICATOR = {
768
823
  Number(inputs.width) || 2,
769
824
  0,
770
825
  100,
771
- [30, 50, 70]
826
+ [30, 50, 70],
827
+ {
828
+ title: `RSI ${length}`,
829
+ axisTicks: [0, 30, 50, 70, 100],
830
+ decimals: 2,
831
+ valueLabelColor: "#9E9E9E",
832
+ valueLabelBackgroundColor: "#9E9E9E",
833
+ valueLabelTextColor: "#0f172a"
834
+ }
772
835
  );
773
836
  }
774
837
  };
@@ -1332,6 +1395,11 @@ function createChart(element, options = {}) {
1332
1395
  const pad = (value) => String(value).padStart(2, "0");
1333
1396
  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`;
1334
1397
  };
1398
+ const getRightAxisLabelX = (chartRight) => chartRight + 1;
1399
+ const getRightAxisLabelWidth = (chartRight, contentWidth) => {
1400
+ const scaleWidth = Math.max(0, Math.floor(width - getRightAxisLabelX(chartRight)));
1401
+ return Math.max(contentWidth, scaleWidth);
1402
+ };
1335
1403
  const drawPriceLine = (line, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
1336
1404
  const mergedLine = { ...DEFAULT_PRICE_LINE_OPTIONS, ...line };
1337
1405
  if (!mergedLine.visible || !Number.isFinite(mergedLine.price)) {
@@ -1356,8 +1424,9 @@ function createChart(element, options = {}) {
1356
1424
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1357
1425
  const labelPaddingX = 8;
1358
1426
  const labelHeight = 20;
1359
- const labelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1360
- const labelX = chartRight + 4;
1427
+ const measuredLabelWidth = mergedLine.label === void 0 ? getPriceLabelWidth(labelText, labelPaddingX) : Math.ceil(ctx.measureText(labelText).width) + labelPaddingX * 2;
1428
+ const labelWidth = getRightAxisLabelWidth(chartRight, measuredLabelWidth);
1429
+ const labelX = getRightAxisLabelX(chartRight);
1361
1430
  const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
1362
1431
  ctx.fillStyle = mergedLine.labelBackgroundColor;
1363
1432
  fillRoundedRect(
@@ -1595,11 +1664,11 @@ function createChart(element, options = {}) {
1595
1664
  const priceText = formatPrice(renderPrice);
1596
1665
  const pricePaddingX = 8;
1597
1666
  const measuredPriceWidth = getPriceLabelWidth(priceText, pricePaddingX);
1598
- const priceWidth = mergedLine.id === void 0 ? measuredPriceWidth : Math.max(measuredPriceWidth, orderPriceTagWidthById.get(mergedLine.id) ?? 0);
1667
+ const priceWidth = mergedLine.id === void 0 ? getRightAxisLabelWidth(chartRight, measuredPriceWidth) : getRightAxisLabelWidth(chartRight, Math.max(measuredPriceWidth, orderPriceTagWidthById.get(mergedLine.id) ?? 0));
1599
1668
  if (mergedLine.id) {
1600
1669
  orderPriceTagWidthById.set(mergedLine.id, priceWidth);
1601
1670
  }
1602
- const priceX = chartRight + 4;
1671
+ const priceX = getRightAxisLabelX(chartRight);
1603
1672
  const priceY = placeRightAxisLabel(targetLabelY, labelHeight, chartTop, chartBottom - labelHeight);
1604
1673
  ctx.fillStyle = mergedLine.labelBackgroundColor ?? color;
1605
1674
  fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, borderRadius);
@@ -1674,9 +1743,63 @@ function createChart(element, options = {}) {
1674
1743
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1675
1744
  ctx.fillStyle = mergedOptions.backgroundColor;
1676
1745
  ctx.fillRect(0, 0, width, height);
1746
+ const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
1747
+ const estimateRightMargin = () => {
1748
+ const paddingX = Math.max(4, labels.labelPaddingX);
1749
+ const measure = (text) => Math.ceil(ctx.measureText(text).width) + paddingX * 2;
1750
+ let required = margin.right - 1;
1751
+ const include = (text) => {
1752
+ const normalized = text?.trim();
1753
+ if (normalized) {
1754
+ required = Math.max(required, measure(normalized));
1755
+ }
1756
+ };
1757
+ const ticker2 = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
1758
+ const lastPoint2 = data[data.length - 1];
1759
+ if ((ticker2.visible ?? true) && labels.showLastPrice && lastPoint2) {
1760
+ include(formatPrice(lastPoint2.c));
1761
+ if (ticker2.labelSubtext) include(String(ticker2.labelSubtext));
1762
+ for (const subtext of ticker2.labelSubtexts ?? []) {
1763
+ include(String(subtext));
1764
+ }
1765
+ }
1766
+ if (labels.showSymbolName) {
1767
+ include(labels.symbolName);
1768
+ }
1769
+ if (labels.showPreviousClose) {
1770
+ const previousCloseCandidate = Number.isFinite(labels.previousClosePrice) ? labels.previousClosePrice : data.length > 1 ? data[data.length - 2]?.c : Number.NaN;
1771
+ if (previousCloseCandidate !== void 0 && Number.isFinite(previousCloseCandidate)) {
1772
+ include(`PDC ${formatPrice(previousCloseCandidate)}`);
1773
+ }
1774
+ }
1775
+ if (labels.showHighLow && data.length > 0) {
1776
+ include(`H ${formatPrice(Math.max(...data.map((point) => point.h)))}`);
1777
+ include(`L ${formatPrice(Math.min(...data.map((point) => point.l)))}`);
1778
+ }
1779
+ if (labels.showBidAsk) {
1780
+ if (Number.isFinite(labels.bidPrice)) include(`B ${formatPrice(labels.bidPrice)}`);
1781
+ if (Number.isFinite(labels.askPrice)) include(`A ${formatPrice(labels.askPrice)}`);
1782
+ }
1783
+ for (const line of priceLines) {
1784
+ const mergedLine = { ...DEFAULT_PRICE_LINE_OPTIONS, ...line };
1785
+ if (mergedLine.visible && Number.isFinite(mergedLine.price)) {
1786
+ include(mergedLine.label ?? formatPrice(mergedLine.price));
1787
+ }
1788
+ }
1789
+ for (const line of orderLines) {
1790
+ const mergedLine = { ...DEFAULT_ORDER_LINE_OPTIONS, ...line };
1791
+ const renderPrice = mergedLine.behavior === "follow" && Number.isFinite(mergedLine.followPrice) ? mergedLine.followPrice : mergedLine.price;
1792
+ if (mergedLine.visible && Number.isFinite(renderPrice)) {
1793
+ include(formatPrice(renderPrice));
1794
+ }
1795
+ }
1796
+ const maxRightMargin = Math.max(margin.right, width - margin.left - 160);
1797
+ return Math.min(maxRightMargin, Math.max(margin.right, Math.ceil(required + 1)));
1798
+ };
1799
+ const rightMargin = estimateRightMargin();
1677
1800
  const chartLeft = margin.left;
1678
1801
  const chartTop = margin.top;
1679
- const chartWidth = width - margin.left - margin.right;
1802
+ const chartWidth = width - margin.left - rightMargin;
1680
1803
  const fullChartHeight = height - margin.top - margin.bottom;
1681
1804
  const fullChartBottom = chartTop + fullChartHeight;
1682
1805
  const chartRight = chartLeft + chartWidth;
@@ -2029,7 +2152,7 @@ function createChart(element, options = {}) {
2029
2152
  ctx.beginPath();
2030
2153
  ctx.rect(chartLeft + 1, paneTop + 1, Math.max(0, chartWidth - 2), Math.max(0, paneHeight - 2));
2031
2154
  ctx.clip();
2032
- plugin.draw(
2155
+ const paneInfo = plugin.draw(
2033
2156
  ctx,
2034
2157
  {
2035
2158
  data,
@@ -2062,6 +2185,63 @@ function createChart(element, options = {}) {
2062
2185
  ctx.lineTo(crisp(chartRight), crisp(paneTop));
2063
2186
  ctx.stroke();
2064
2187
  ctx.restore();
2188
+ const axisInfo = paneInfo?.axis;
2189
+ if (axisInfo && Number.isFinite(axisInfo.min) && Number.isFinite(axisInfo.max) && axisInfo.max !== axisInfo.min) {
2190
+ const paneRange = axisInfo.max - axisInfo.min;
2191
+ const yFromPaneValue = (value) => {
2192
+ const ratio = (value - axisInfo.min) / paneRange;
2193
+ return paneBottom - ratio * paneHeight;
2194
+ };
2195
+ const formatPaneValue = (value) => {
2196
+ if (axisInfo.format) {
2197
+ return axisInfo.format(value);
2198
+ }
2199
+ const decimals = axisInfo.decimals ?? (Math.abs(paneRange) <= 2 ? 2 : Math.abs(paneRange) <= 20 ? 1 : 0);
2200
+ return value.toFixed(Math.max(0, Math.min(8, Math.round(decimals))));
2201
+ };
2202
+ const axisTicks = axisInfo.ticks && axisInfo.ticks.length > 0 ? axisInfo.ticks : [axisInfo.min, axisInfo.min + paneRange / 2, axisInfo.max];
2203
+ const uniqueTicks = Array.from(new Set(axisTicks.filter((tick) => Number.isFinite(tick))));
2204
+ ctx.save();
2205
+ ctx.font = `${yAxisFontSize}px ${mergedOptions.fontFamily}`;
2206
+ for (const tick of uniqueTicks) {
2207
+ if (tick < axisInfo.min || tick > axisInfo.max) {
2208
+ continue;
2209
+ }
2210
+ drawText(formatPaneValue(tick), chartRight + 6, yFromPaneValue(tick), "left", "middle", yAxis.textColor);
2211
+ }
2212
+ ctx.restore();
2213
+ if (labels.visible) {
2214
+ const prevFont = ctx.font;
2215
+ ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2216
+ const legendTitle = paneInfo.title ?? plugin.name;
2217
+ const legendValues = paneInfo.legendValues ?? [];
2218
+ const legendParts = [
2219
+ legendTitle,
2220
+ ...legendValues.map((value) => value.text ?? (value.value === void 0 ? "" : formatPaneValue(value.value))).filter(Boolean)
2221
+ ].filter(Boolean);
2222
+ if (legendParts.length > 0) {
2223
+ drawText(legendParts.join(" "), chartLeft + 10, paneTop + 8, "left", "top", labels.indicatorTextColor);
2224
+ }
2225
+ for (const label of paneInfo.valueLabels ?? []) {
2226
+ if (!labels.showIndicatorValueLabels) {
2227
+ continue;
2228
+ }
2229
+ if (!Number.isFinite(label.value) || label.value < axisInfo.min || label.value > axisInfo.max) {
2230
+ continue;
2231
+ }
2232
+ const text = label.text ?? formatPaneValue(label.value);
2233
+ const labelPaddingX = 7;
2234
+ const labelHeight = Math.max(16, yAxisFontSize + 8);
2235
+ const labelWidth = getRightAxisLabelWidth(chartRight, Math.ceil(ctx.measureText(text).width) + labelPaddingX * 2);
2236
+ const labelX = getRightAxisLabelX(chartRight);
2237
+ const labelY = clamp(yFromPaneValue(label.value) - labelHeight / 2, paneTop + 2, paneBottom - labelHeight - 2);
2238
+ ctx.fillStyle = label.backgroundColor ?? label.color ?? labels.backgroundColor;
2239
+ fillRoundedRect(Math.round(labelX), Math.round(labelY), labelWidth, labelHeight, Math.max(0, labels.borderRadius));
2240
+ drawText(text, labelX + labelPaddingX, labelY + labelHeight / 2, "left", "middle", label.textColor ?? labels.textColor);
2241
+ }
2242
+ ctx.font = prevFont;
2243
+ }
2244
+ }
2065
2245
  });
2066
2246
  }
2067
2247
  if (crosshair.visible && crosshairPoint && crosshair.showVertical) {
@@ -2093,7 +2273,6 @@ function createChart(element, options = {}) {
2093
2273
  drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
2094
2274
  ctx.font = prevFont;
2095
2275
  }
2096
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2097
2276
  resetLabelSlots(labels.noOverlapping);
2098
2277
  const priceAxisLabels = [];
2099
2278
  const addPriceAxisLabel = (label) => {
@@ -2257,12 +2436,13 @@ function createChart(element, options = {}) {
2257
2436
  ctx.font = baseFont;
2258
2437
  }
2259
2438
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
2439
+ const labelWidth = getRightAxisLabelWidth(chartRight, labelTextWidth);
2260
2440
  return {
2261
2441
  ...label,
2262
2442
  subtexts,
2263
2443
  subtextFontSize,
2264
2444
  height: labelHeight,
2265
- width: labelTextWidth,
2445
+ width: labelWidth,
2266
2446
  targetY: clamp(yFromPrice(label.price), chartTop + 1, chartBottom - 1) - labelHeight / 2,
2267
2447
  y: 0
2268
2448
  };
@@ -2292,7 +2472,7 @@ function createChart(element, options = {}) {
2292
2472
  );
2293
2473
  rightAxisLabelSlots.sort((a, b) => a.y - b.y);
2294
2474
  }
2295
- const labelX = chartRight + 4;
2475
+ const labelX = getRightAxisLabelX(chartRight);
2296
2476
  for (const label of positionedLabels) {
2297
2477
  ctx.fillStyle = label.backgroundColor;
2298
2478
  fillRoundedRect(Math.round(labelX), Math.round(label.y), label.width, label.height, labelRadius);
@@ -2337,7 +2517,14 @@ function createChart(element, options = {}) {
2337
2517
  const prevFont = ctx.font;
2338
2518
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2339
2519
  const legendText = labelEntries.join(" ");
2340
- drawText(legendText, chartLeft + 10, chartTop + 10, "left", "top", labels.indicatorTextColor);
2520
+ const offsetX = Math.max(0, Number(labels.indicatorLegendOffsetX) || 0);
2521
+ const offsetY = Math.max(0, Number(labels.indicatorLegendOffsetY) || 0);
2522
+ const position = labels.indicatorLegendPosition;
2523
+ const isRight = position === "top-right" || position === "bottom-right";
2524
+ const isBottom = position === "bottom-left" || position === "bottom-right";
2525
+ const legendX = isRight ? chartRight - offsetX : chartLeft + offsetX;
2526
+ const legendY = isBottom ? chartBottom - offsetY : chartTop + offsetY;
2527
+ drawText(legendText, legendX, legendY, isRight ? "right" : "left", isBottom ? "bottom" : "top", labels.indicatorTextColor);
2341
2528
  ctx.font = prevFont;
2342
2529
  }
2343
2530
  }
@@ -2395,8 +2582,8 @@ function createChart(element, options = {}) {
2395
2582
  if (crosshair.showPriceLabel) {
2396
2583
  const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
2397
2584
  const priceText = formatPrice(hoverPrice);
2398
- const priceWidth = getPriceLabelWidth(priceText, labelPaddingX);
2399
- const priceX = chartRight + 4;
2585
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
2586
+ const priceX = getRightAxisLabelX(chartRight);
2400
2587
  const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
2401
2588
  ctx.fillStyle = labelBackground;
2402
2589
  fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
package/docs/API.md CHANGED
@@ -171,6 +171,10 @@ 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
+ - `showIndicatorValueLabels` (default `true`; controls separate-pane right-side indicator value tags, such as RSI's latest-value label)
175
+ - `indicatorLegendPosition` (`"top-left" | "top-right" | "bottom-left" | "bottom-right"`, default `"top-left"`)
176
+ - `indicatorLegendOffsetX` (default `10`)
177
+ - `indicatorLegendOffsetY` (default `10`; increase this if your frontend overlays a symbol/OHLC HUD in the top-left)
174
178
  - `showCountdownToBarClose` (default `false`; draws a bottom-axis countdown based on candle time spacing)
175
179
  - `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
180
  - Style fields: `backgroundColor`, `textColor`, `mutedTextColor`, `symbolNameBackgroundColor`, `symbolNameTextColor`, `previousCloseColor`, `highLowColor`, `bidColor`, `askColor`, `indicatorTextColor`, `borderRadius`, `labelHeight`, `labelPaddingX`
@@ -190,6 +194,9 @@ createChart(root, {
190
194
  askPrice: 5235.0,
191
195
  showIndicatorNames: true,
192
196
  showIndicatorValues: true,
197
+ showIndicatorValueLabels: true,
198
+ indicatorLegendPosition: "top-left",
199
+ indicatorLegendOffsetY: 34,
193
200
  showCountdownToBarClose: true,
194
201
  noOverlapping: true
195
202
  }
@@ -311,7 +318,7 @@ Connector/fill visuals:
311
318
  - `pane?: "overlay" | "separate"` (default `"overlay"`)
312
319
  - `paneHeightRatio?: number` (for separate panes)
313
320
  - `defaultInputs?: Record<string, unknown>`
314
- - `draw(ctx, renderContext, inputs): void`
321
+ - `draw(ctx, renderContext, inputs): void | IndicatorPaneRenderInfo`
315
322
 
316
323
  `IndicatorRenderContext` includes:
317
324
 
@@ -319,14 +326,51 @@ Connector/fill visuals:
319
326
  - pane bounds (`chartLeft`, `chartRight`, `chartTop`, `chartBottom`, `chartWidth`, `chartHeight`)
320
327
  - `xFromIndex(index)` helper
321
328
  - `yFromPrice(price)` helper (available for overlay indicators, `null` for separate-pane indicators)
329
+ - `getCandleDirectionByIndex(index)` and `getVolumeByIndex(index)` helpers
322
330
  - theme colors (`upColor`, `downColor`)
323
331
 
332
+ For separate-pane indicators, return `IndicatorPaneRenderInfo` from `draw()` when the core should render TradingView-style pane UI:
333
+
334
+ ```ts
335
+ type IndicatorPaneRenderInfo = {
336
+ title?: string;
337
+ axis?: {
338
+ min: number;
339
+ max: number;
340
+ ticks?: number[];
341
+ decimals?: number;
342
+ format?: (value: number) => string;
343
+ };
344
+ guideLines?: Array<{
345
+ value: number;
346
+ label?: string;
347
+ color?: string;
348
+ style?: "solid" | "dotted" | "dashed";
349
+ }>;
350
+ legendValues?: Array<{
351
+ label?: string;
352
+ value?: number;
353
+ text?: string;
354
+ color?: string;
355
+ }>;
356
+ valueLabels?: Array<{
357
+ value: number;
358
+ text?: string;
359
+ color?: string;
360
+ backgroundColor?: string;
361
+ textColor?: string;
362
+ }>;
363
+ };
364
+ ```
365
+
366
+ 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.
367
+
324
368
  Built-in:
325
369
 
326
370
  - `"volume"`: overlay histogram by default (uses `OhlcDataPoint.v`; can be moved to separate pane)
327
371
  - `"sma"`: Simple Moving Average (overlay)
328
372
  - `"ema"`: Exponential Moving Average (overlay)
329
- - `"rsi"`: Relative Strength Index (separate pane, 30/50/70 guides)
373
+ - `"rsi"`: Relative Strength Index (separate pane, 30/50/70 guides, 0/30/50/70/100 axis labels, latest-value tag)
330
374
  - `"wma"`: Weighted Moving Average (overlay)
331
375
  - `"vwma"`: Volume Weighted Moving Average (overlay, uses `OhlcDataPoint.v`)
332
376
  - `"rma"`: Wilder's Moving Average (overlay)
package/docs/RECIPES.md CHANGED
@@ -146,6 +146,46 @@ const rsiId = chart.addIndicator("rsi", { length: 14 }, { pane: "separate", pane
146
146
  const volumeId = chart.addIndicator("volume", { upOpacity: 0.72, downOpacity: 0.72 });
147
147
  ```
148
148
 
149
+ ## Move the main indicator legend away from a HUD
150
+
151
+ The main overlay indicator legend defaults to the chart pane's top-left corner. If your app renders a symbol/OHLC HUD over the canvas, move the legend with `labels.indicatorLegend*`.
152
+
153
+ ```ts
154
+ const chart = createChart(root, {
155
+ labels: {
156
+ showIndicatorNames: true,
157
+ showIndicatorValues: true,
158
+ indicatorLegendPosition: "top-left",
159
+ indicatorLegendOffsetX: 10,
160
+ indicatorLegendOffsetY: 34
161
+ }
162
+ });
163
+ ```
164
+
165
+ You can also place it in another corner:
166
+
167
+ ```ts
168
+ chart.updateOptions({
169
+ labels: {
170
+ indicatorLegendPosition: "top-right",
171
+ indicatorLegendOffsetX: 12,
172
+ indicatorLegendOffsetY: 10
173
+ }
174
+ });
175
+ ```
176
+
177
+ ## Hide separate-pane indicator value tags
178
+
179
+ RSI and other separate-pane indicators can draw a latest-value tag on the right axis. Hide those tags while keeping the pane scale and legend:
180
+
181
+ ```ts
182
+ chart.updateOptions({
183
+ labels: {
184
+ showIndicatorValueLabels: false
185
+ }
186
+ });
187
+ ```
188
+
149
189
  ## Prevent one volume spike from crushing all bars
150
190
 
151
191
  ```ts
@@ -160,6 +200,78 @@ Available built-ins:
160
200
 
161
201
  - `volume`, `sma`, `ema`, `rsi`, `wma`, `vwma`, `rma`, `hma`, `stddev`, `atr`
162
202
 
203
+ ## Add a custom separate-pane indicator with scale labels
204
+
205
+ Separate-pane plugins can return pane metadata from `draw()` so the chart renders the right-side scale, top-left legend, and latest-value tag.
206
+
207
+ ```ts
208
+ import { type IndicatorPlugin } from "hyperprop-charting-library";
209
+
210
+ const momentumPlugin: IndicatorPlugin<{ length: number; color: string }> = {
211
+ id: "momentum",
212
+ name: "Momentum",
213
+ pane: "separate",
214
+ paneHeightRatio: 0.18,
215
+ defaultInputs: { length: 10, color: "#38bdf8" },
216
+ draw: (ctx, renderContext, inputs) => {
217
+ const values = renderContext.data.map((point, index, data) => {
218
+ const prior = data[index - inputs.length];
219
+ return prior ? point.c - prior.c : null;
220
+ });
221
+ const visibleValues = values
222
+ .slice(renderContext.startIndex, renderContext.endIndex + 1)
223
+ .filter((value): value is number => Number.isFinite(value ?? Number.NaN));
224
+ if (visibleValues.length === 0) return;
225
+
226
+ const maxAbs = Math.max(...visibleValues.map((value) => Math.abs(value)), 1);
227
+ const min = -maxAbs;
228
+ const max = maxAbs;
229
+ const yFromValue = (value: number) => {
230
+ const ratio = (value - min) / (max - min || 1);
231
+ return renderContext.chartBottom - ratio * renderContext.chartHeight;
232
+ };
233
+
234
+ ctx.save();
235
+ ctx.strokeStyle = inputs.color;
236
+ ctx.lineWidth = 2;
237
+ ctx.beginPath();
238
+ let drawing = false;
239
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
240
+ const value = values[index];
241
+ if (!Number.isFinite(value ?? Number.NaN)) {
242
+ drawing = false;
243
+ continue;
244
+ }
245
+ const x = renderContext.xFromIndex(index);
246
+ const y = yFromValue(value as number);
247
+ if (!drawing) {
248
+ ctx.moveTo(x, y);
249
+ drawing = true;
250
+ } else {
251
+ ctx.lineTo(x, y);
252
+ }
253
+ }
254
+ ctx.stroke();
255
+ ctx.restore();
256
+
257
+ const latest = [...values].reverse().find((value): value is number => Number.isFinite(value ?? Number.NaN));
258
+ return {
259
+ title: `Momentum ${inputs.length}`,
260
+ axis: { min, max, ticks: [min, 0, max], decimals: 2 },
261
+ guideLines: [{ value: 0, label: "0", style: "dotted" }],
262
+ legendValues: latest === undefined ? [] : [{ value: latest, text: latest.toFixed(2), color: inputs.color }],
263
+ valueLabels:
264
+ latest === undefined
265
+ ? []
266
+ : [{ value: latest, text: latest.toFixed(2), backgroundColor: inputs.color, textColor: "#0f172a" }]
267
+ };
268
+ }
269
+ };
270
+
271
+ chart.registerIndicator(momentumPlugin);
272
+ chart.addIndicator("momentum", { length: 10 });
273
+ ```
274
+
163
275
  ## Resize indicator pane from frontend
164
276
 
165
277
  Use `paneHeightRatio` so your app can wire a drag handle or slider:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperprop-charting-library",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "Lightweight TypeScript charting core",
5
5
  "type": "module",
6
6
  "main": "./dist/hyperprop-charting-library.cjs",