hyperprop-charting-library 0.1.49 → 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
  };
@@ -1332,6 +1391,11 @@ function createChart(element, options = {}) {
1332
1391
  const pad = (value) => String(value).padStart(2, "0");
1333
1392
  return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`;
1334
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
+ };
1335
1399
  const drawPriceLine = (line, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
1336
1400
  const mergedLine = { ...DEFAULT_PRICE_LINE_OPTIONS, ...line };
1337
1401
  if (!mergedLine.visible || !Number.isFinite(mergedLine.price)) {
@@ -1356,8 +1420,9 @@ function createChart(element, options = {}) {
1356
1420
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1357
1421
  const labelPaddingX = 8;
1358
1422
  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;
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);
1361
1426
  const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
1362
1427
  ctx.fillStyle = mergedLine.labelBackgroundColor;
1363
1428
  fillRoundedRect(
@@ -1595,11 +1660,11 @@ function createChart(element, options = {}) {
1595
1660
  const priceText = formatPrice(renderPrice);
1596
1661
  const pricePaddingX = 8;
1597
1662
  const measuredPriceWidth = getPriceLabelWidth(priceText, pricePaddingX);
1598
- 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));
1599
1664
  if (mergedLine.id) {
1600
1665
  orderPriceTagWidthById.set(mergedLine.id, priceWidth);
1601
1666
  }
1602
- const priceX = chartRight + 4;
1667
+ const priceX = getRightAxisLabelX(chartRight);
1603
1668
  const priceY = placeRightAxisLabel(targetLabelY, labelHeight, chartTop, chartBottom - labelHeight);
1604
1669
  ctx.fillStyle = mergedLine.labelBackgroundColor ?? color;
1605
1670
  fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, borderRadius);
@@ -1674,9 +1739,63 @@ function createChart(element, options = {}) {
1674
1739
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
1675
1740
  ctx.fillStyle = mergedOptions.backgroundColor;
1676
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();
1677
1796
  const chartLeft = margin.left;
1678
1797
  const chartTop = margin.top;
1679
- const chartWidth = width - margin.left - margin.right;
1798
+ const chartWidth = width - margin.left - rightMargin;
1680
1799
  const fullChartHeight = height - margin.top - margin.bottom;
1681
1800
  const fullChartBottom = chartTop + fullChartHeight;
1682
1801
  const chartRight = chartLeft + chartWidth;
@@ -2029,7 +2148,7 @@ function createChart(element, options = {}) {
2029
2148
  ctx.beginPath();
2030
2149
  ctx.rect(chartLeft + 1, paneTop + 1, Math.max(0, chartWidth - 2), Math.max(0, paneHeight - 2));
2031
2150
  ctx.clip();
2032
- plugin.draw(
2151
+ const paneInfo = plugin.draw(
2033
2152
  ctx,
2034
2153
  {
2035
2154
  data,
@@ -2062,6 +2181,60 @@ function createChart(element, options = {}) {
2062
2181
  ctx.lineTo(crisp(chartRight), crisp(paneTop));
2063
2182
  ctx.stroke();
2064
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
+ }
2065
2238
  });
2066
2239
  }
2067
2240
  if (crosshair.visible && crosshairPoint && crosshair.showVertical) {
@@ -2093,7 +2266,6 @@ function createChart(element, options = {}) {
2093
2266
  drawText(formatPrice(price), chartRight + 6, y, "left", "middle", yAxis.textColor);
2094
2267
  ctx.font = prevFont;
2095
2268
  }
2096
- const labels = { ...DEFAULT_LABELS_OPTIONS, ...mergedOptions.labels ?? {} };
2097
2269
  resetLabelSlots(labels.noOverlapping);
2098
2270
  const priceAxisLabels = [];
2099
2271
  const addPriceAxisLabel = (label) => {
@@ -2257,12 +2429,13 @@ function createChart(element, options = {}) {
2257
2429
  ctx.font = baseFont;
2258
2430
  }
2259
2431
  const labelTextWidth = Math.max(primaryWidth, subtextWidth);
2432
+ const labelWidth = getRightAxisLabelWidth(chartRight, labelTextWidth);
2260
2433
  return {
2261
2434
  ...label,
2262
2435
  subtexts,
2263
2436
  subtextFontSize,
2264
2437
  height: labelHeight,
2265
- width: labelTextWidth,
2438
+ width: labelWidth,
2266
2439
  targetY: clamp(yFromPrice(label.price), chartTop + 1, chartBottom - 1) - labelHeight / 2,
2267
2440
  y: 0
2268
2441
  };
@@ -2292,7 +2465,7 @@ function createChart(element, options = {}) {
2292
2465
  );
2293
2466
  rightAxisLabelSlots.sort((a, b) => a.y - b.y);
2294
2467
  }
2295
- const labelX = chartRight + 4;
2468
+ const labelX = getRightAxisLabelX(chartRight);
2296
2469
  for (const label of positionedLabels) {
2297
2470
  ctx.fillStyle = label.backgroundColor;
2298
2471
  fillRoundedRect(Math.round(labelX), Math.round(label.y), label.width, label.height, labelRadius);
@@ -2337,7 +2510,14 @@ function createChart(element, options = {}) {
2337
2510
  const prevFont = ctx.font;
2338
2511
  ctx.font = `${Math.max(8, axis.fontSize)}px ${mergedOptions.fontFamily}`;
2339
2512
  const legendText = labelEntries.join(" ");
2340
- 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);
2341
2521
  ctx.font = prevFont;
2342
2522
  }
2343
2523
  }
@@ -2395,8 +2575,8 @@ function createChart(element, options = {}) {
2395
2575
  if (crosshair.showPriceLabel) {
2396
2576
  const hoverPrice = yMin + (chartBottom - cy) / chartHeight * yRange;
2397
2577
  const priceText = formatPrice(hoverPrice);
2398
- const priceWidth = getPriceLabelWidth(priceText, labelPaddingX);
2399
- const priceX = chartRight + 4;
2578
+ const priceWidth = getRightAxisLabelWidth(chartRight, getPriceLabelWidth(priceText, labelPaddingX));
2579
+ const priceX = getRightAxisLabelX(chartRight);
2400
2580
  const priceY = clamp(cy - labelHeight / 2, chartTop, chartBottom - labelHeight);
2401
2581
  ctx.fillStyle = labelBackground;
2402
2582
  fillRoundedRect(Math.round(priceX), Math.round(priceY), priceWidth, labelHeight, labelRadius);
package/docs/API.md CHANGED
@@ -171,6 +171,9 @@ 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
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`
@@ -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)
package/docs/RECIPES.md CHANGED
@@ -146,6 +146,34 @@ 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
+
149
177
  ## Prevent one volume spike from crushing all bars
150
178
 
151
179
  ```ts
@@ -160,6 +188,78 @@ Available built-ins:
160
188
 
161
189
  - `volume`, `sma`, `ema`, `rsi`, `wma`, `vwma`, `rma`, `hma`, `stddev`, `atr`
162
190
 
191
+ ## Add a custom separate-pane indicator with scale labels
192
+
193
+ Separate-pane plugins can return pane metadata from `draw()` so the chart renders the right-side scale, top-left legend, and latest-value tag.
194
+
195
+ ```ts
196
+ import { type IndicatorPlugin } from "hyperprop-charting-library";
197
+
198
+ const momentumPlugin: IndicatorPlugin<{ length: number; color: string }> = {
199
+ id: "momentum",
200
+ name: "Momentum",
201
+ pane: "separate",
202
+ paneHeightRatio: 0.18,
203
+ defaultInputs: { length: 10, color: "#38bdf8" },
204
+ draw: (ctx, renderContext, inputs) => {
205
+ const values = renderContext.data.map((point, index, data) => {
206
+ const prior = data[index - inputs.length];
207
+ return prior ? point.c - prior.c : null;
208
+ });
209
+ const visibleValues = values
210
+ .slice(renderContext.startIndex, renderContext.endIndex + 1)
211
+ .filter((value): value is number => Number.isFinite(value ?? Number.NaN));
212
+ if (visibleValues.length === 0) return;
213
+
214
+ const maxAbs = Math.max(...visibleValues.map((value) => Math.abs(value)), 1);
215
+ const min = -maxAbs;
216
+ const max = maxAbs;
217
+ const yFromValue = (value: number) => {
218
+ const ratio = (value - min) / (max - min || 1);
219
+ return renderContext.chartBottom - ratio * renderContext.chartHeight;
220
+ };
221
+
222
+ ctx.save();
223
+ ctx.strokeStyle = inputs.color;
224
+ ctx.lineWidth = 2;
225
+ ctx.beginPath();
226
+ let drawing = false;
227
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
228
+ const value = values[index];
229
+ if (!Number.isFinite(value ?? Number.NaN)) {
230
+ drawing = false;
231
+ continue;
232
+ }
233
+ const x = renderContext.xFromIndex(index);
234
+ const y = yFromValue(value as number);
235
+ if (!drawing) {
236
+ ctx.moveTo(x, y);
237
+ drawing = true;
238
+ } else {
239
+ ctx.lineTo(x, y);
240
+ }
241
+ }
242
+ ctx.stroke();
243
+ ctx.restore();
244
+
245
+ const latest = [...values].reverse().find((value): value is number => Number.isFinite(value ?? Number.NaN));
246
+ return {
247
+ title: `Momentum ${inputs.length}`,
248
+ axis: { min, max, ticks: [min, 0, max], decimals: 2 },
249
+ guideLines: [{ value: 0, label: "0", style: "dotted" }],
250
+ legendValues: latest === undefined ? [] : [{ value: latest, text: latest.toFixed(2), color: inputs.color }],
251
+ valueLabels:
252
+ latest === undefined
253
+ ? []
254
+ : [{ value: latest, text: latest.toFixed(2), backgroundColor: inputs.color, textColor: "#0f172a" }]
255
+ };
256
+ }
257
+ };
258
+
259
+ chart.registerIndicator(momentumPlugin);
260
+ chart.addIndicator("momentum", { length: 10 });
261
+ ```
262
+
163
263
  ## Resize indicator pane from frontend
164
264
 
165
265
  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.50",
4
4
  "description": "Lightweight TypeScript charting core",
5
5
  "type": "module",
6
6
  "main": "./dist/hyperprop-charting-library.cjs",