react-native-livechart 3.9.0 → 3.10.0

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.
Files changed (40) hide show
  1. package/dist/components/AxisLabelOverlay.d.ts +19 -3
  2. package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +5 -1
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomTooltipOverlay.d.ts +8 -2
  6. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  7. package/dist/components/ExtremaConnectorOverlay.d.ts +32 -0
  8. package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -0
  9. package/dist/components/LiveChart.d.ts.map +1 -1
  10. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  11. package/dist/core/liveChartEngineTick.d.ts +11 -0
  12. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  13. package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
  14. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  15. package/dist/core/resolveConfig.d.ts +32 -2
  16. package/dist/core/resolveConfig.d.ts.map +1 -1
  17. package/dist/core/useLiveChartEngine.d.ts +18 -2
  18. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  19. package/dist/core/useLiveChartSeriesEngine.d.ts +4 -0
  20. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  21. package/dist/hooks/crosshairShared.d.ts +12 -0
  22. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  23. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  24. package/dist/types.d.ts +82 -8
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/AxisLabelOverlay.tsx +399 -46
  28. package/src/components/CrosshairOverlay.tsx +15 -5
  29. package/src/components/CustomTooltipOverlay.tsx +30 -5
  30. package/src/components/ExtremaConnectorOverlay.tsx +193 -0
  31. package/src/components/LiveChart.tsx +37 -8
  32. package/src/components/LiveChartSeries.tsx +18 -0
  33. package/src/core/liveChartEngineTick.ts +51 -6
  34. package/src/core/liveChartSeriesEngineTick.ts +34 -0
  35. package/src/core/resolveConfig.ts +73 -2
  36. package/src/core/useLiveChartEngine.ts +42 -2
  37. package/src/core/useLiveChartSeriesEngine.ts +26 -0
  38. package/src/hooks/crosshairShared.ts +20 -1
  39. package/src/hooks/useCrosshair.ts +6 -0
  40. package/src/types.ts +83 -8
@@ -9,7 +9,7 @@ import Animated, {
9
9
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
10
10
  import type { ChartPadding } from "../draw/line";
11
11
  import type { TooltipLayout } from "../hooks/crosshairShared";
12
- import type { TooltipRenderProps } from "../types";
12
+ import type { CandlePoint, TooltipRenderProps } from "../types";
13
13
 
14
14
  // Mirror the Skia tooltip's offsets (see crosshairShared.ts) so a custom pill
15
15
  // lines up with where the built-in one would sit. The vertical edge gap is
@@ -40,18 +40,22 @@ export function CustomTooltipOverlay({
40
40
  scrubValue,
41
41
  scrubTime,
42
42
  scrubActive,
43
+ scrubCandle,
43
44
  crosshairOpacity,
44
45
  tooltipLayout,
45
46
  engine,
46
47
  padding,
47
48
  placement,
48
49
  margin = 8,
50
+ lineTop,
49
51
  }: {
50
52
  renderTooltip: (ctx: TooltipRenderProps) => React.ReactElement | null | undefined;
51
53
  scrubX: SharedValue<number>;
52
54
  scrubValue: SharedValue<number | null>;
53
55
  scrubTime: SharedValue<number>;
54
56
  scrubActive: SharedValue<boolean>;
57
+ /** OHLC candle under the crosshair (candle mode); omitted/`null` in line mode. */
58
+ scrubCandle?: SharedValue<CandlePoint | null>;
55
59
  crosshairOpacity: SharedValue<number>;
56
60
  tooltipLayout: SharedValue<TooltipLayout>;
57
61
  engine: ChartEngineLayout;
@@ -59,11 +63,19 @@ export function CustomTooltipOverlay({
59
63
  placement: "side" | "top" | "bottom";
60
64
  /** Gap (px) between the pill and the plot edge it's pinned to. Default 8. */
61
65
  margin?: number;
66
+ /** When `placement` is `"top"`, the overlay publishes the label's bottom edge
67
+ * (canvas Y) here so {@link CrosshairOverlay} can stop the crosshair line at
68
+ * the label instead of running through it; -1 when not top-pinned/active. */
69
+ lineTop?: SharedValue<number>;
62
70
  }) {
63
71
  // Formatted strings come ready-made off the layout (formatted UI-side in
64
- // computeTooltipLayout), so consumers don't need a worklet-safe formatter.
72
+ // computeTooltipLayout / computeCandleTooltipLayout), so consumers don't need
73
+ // a worklet-safe formatter for the value/time.
65
74
  const valueStr = useDerivedValue(() => tooltipLayout.get().valueStr);
66
75
  const timeStr = useDerivedValue(() => tooltipLayout.get().timeStr);
76
+ // A stable `null` candle SharedValue for line mode so `ctx.candle` is always
77
+ // present (a SharedValue), regardless of mode.
78
+ const nullCandle = useSharedValue<CandlePoint | null>(null);
67
79
 
68
80
  const ctx: TooltipRenderProps = {
69
81
  value: scrubValue,
@@ -71,6 +83,7 @@ export function CustomTooltipOverlay({
71
83
  valueStr,
72
84
  timeStr,
73
85
  active: scrubActive,
86
+ candle: scrubCandle ?? nullCandle,
74
87
  };
75
88
  const element = renderTooltip(ctx);
76
89
 
@@ -79,6 +92,12 @@ export function CustomTooltipOverlay({
79
92
  const onLayout = (e: LayoutChangeEvent) => {
80
93
  const { width, height } = e.nativeEvent.layout;
81
94
  size.value = { width, height };
95
+ // Publish the label's bottom edge (canvas Y) for a top-pinned tooltip so
96
+ // CrosshairOverlay stops the crosshair line at the label instead of running
97
+ // up through it; -1 for side/bottom → the line keeps its default start. The
98
+ // value when idle is irrelevant (the crosshair is hidden), so reacting to the
99
+ // measured height alone — no scrub-active dependency — is enough.
100
+ lineTop?.set(placement === "top" ? margin + height : -1);
82
101
  };
83
102
 
84
103
  const animatedStyle = useAnimatedStyle(() => {
@@ -96,15 +115,21 @@ export function CustomTooltipOverlay({
96
115
  if (x + s.width > rightEdge - EDGE_GAP) x = sx - s.width - OFFSET_X;
97
116
  y = padding.top + margin;
98
117
  } else {
99
- const leftBound = padding.left + EDGE_GAP;
100
- const rightBound = rightEdge - EDGE_GAP - s.width;
118
+ // Center on the crosshair, clamped to the *canvas* edges (not the plot's
119
+ // inner bounds) so a top/bottom-pinned label follows the crosshair all the
120
+ // way into the left/right gutters — e.g. past the wide Y-axis gutter on the
121
+ // right — instead of stopping short of the edge.
122
+ const leftBound = EDGE_GAP;
123
+ const rightBound = cw - EDGE_GAP - s.width;
101
124
  x = Math.min(
102
125
  Math.max(sx - s.width / 2, leftBound),
103
126
  Math.max(leftBound, rightBound),
104
127
  );
105
128
  y =
106
129
  placement === "top"
107
- ? padding.top + margin
130
+ ? // Pin to the canvas top edge (not the plot's inner top), so the label
131
+ // sits above the data and the crosshair line can stop at it.
132
+ margin
108
133
  : ch - padding.bottom - margin - s.height;
109
134
  }
110
135
 
@@ -0,0 +1,193 @@
1
+ import { DashPathEffect, Path } from "@shopify/react-native-skia";
2
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
+
4
+ import type {
5
+ ResolvedAxisLabelConfig,
6
+ ResolvedLineStyleConfig,
7
+ } from "../core/resolveConfig";
8
+ import type {
9
+ ChartEngineExtrema,
10
+ ChartEngineLayout,
11
+ } from "../core/useLiveChartEngine";
12
+ import type { ChartPadding } from "../draw/line";
13
+ import { usePathBuilder } from "../hooks/usePathBuilder";
14
+ import { EXTREMA_EDGE_INSET, EXTREMA_LABEL_FONT_SIZE } from "./AxisLabelOverlay";
15
+
16
+ /** How far off-plot (px) the extremum may sit before the connector is dropped. */
17
+ const CONNECTOR_CULL = 24;
18
+ /** Gap (px) between the connector's end and the value label. */
19
+ const LABEL_GAP = 3;
20
+ /** Single-line text height as a fraction of font size — a safe over-estimate, so
21
+ * the connector stops just shy of the label rather than running under it. */
22
+ const LABEL_HEIGHT_FACTOR = 1.5;
23
+
24
+ /** Connector style + the label font size (to estimate where the label edge is). */
25
+ export interface ResolvedConnector {
26
+ line: ResolvedLineStyleConfig;
27
+ fontSize: number;
28
+ }
29
+
30
+ /**
31
+ * The connector config for one axis-label side — non-null only when it's an
32
+ * `"extrema-edge"` label with its connector enabled. The line `color` defaults
33
+ * to the label color (then `defaultColor`), so the connector matches its readout.
34
+ */
35
+ export function labelConnector(
36
+ cfg: ResolvedAxisLabelConfig | null,
37
+ defaultColor: string,
38
+ ): ResolvedConnector | null {
39
+ if (!cfg || cfg.position !== "extrema-edge" || !cfg.connector) return null;
40
+ return {
41
+ line: {
42
+ ...cfg.connector,
43
+ color: cfg.connector.color ?? cfg.color ?? defaultColor,
44
+ },
45
+ fontSize: cfg.fontSize ?? EXTREMA_LABEL_FONT_SIZE,
46
+ };
47
+ }
48
+
49
+ /** A single vertical connector from the edge value label down/up to its dot. */
50
+ function ConnectorLine({
51
+ side,
52
+ timeSV,
53
+ valueSV,
54
+ timeOffset,
55
+ engine,
56
+ padding,
57
+ config,
58
+ }: {
59
+ side: "top" | "bottom";
60
+ timeSV: SharedValue<number>;
61
+ valueSV: SharedValue<number>;
62
+ timeOffset: number;
63
+ engine: ChartEngineLayout;
64
+ padding: ChartPadding;
65
+ config: ResolvedConnector;
66
+ }) {
67
+ const builder = usePathBuilder();
68
+ const { line, fontSize } = config;
69
+
70
+ const path = useDerivedValue(() => {
71
+ const b = builder.value;
72
+ const value = valueSV.get();
73
+ const time = timeSV.get();
74
+ const cw = engine.canvasWidth.get();
75
+ const ch = engine.canvasHeight.get();
76
+ const displayMin = engine.displayMin.get();
77
+ const displayMax = engine.displayMax.get();
78
+ const win = engine.displayWindow.get();
79
+ const ts = engine.timestamp.get();
80
+
81
+ const chartLeft = padding.left;
82
+ const chartRight = cw - padding.right;
83
+ const chartW = chartRight - chartLeft;
84
+ const chartTop = padding.top;
85
+ const chartBottom = ch - padding.bottom;
86
+ const chartH = chartBottom - chartTop;
87
+ const valRange = displayMax - displayMin;
88
+
89
+ if (
90
+ !Number.isFinite(value) ||
91
+ cw === 0 ||
92
+ ch === 0 ||
93
+ chartW <= 0 ||
94
+ chartH <= 0 ||
95
+ win <= 0 ||
96
+ valRange <= 0
97
+ ) {
98
+ return b.detach();
99
+ }
100
+
101
+ const winStart = ts - win;
102
+ const px = chartLeft + ((time + timeOffset - winStart) / win) * chartW;
103
+ // Off-plot (scrolled out) — emit nothing this frame.
104
+ if (px < chartLeft - CONNECTOR_CULL || px > chartRight + CONNECTOR_CULL) {
105
+ return b.detach();
106
+ }
107
+ const py = chartTop + ((displayMax - value) / valRange) * chartH;
108
+
109
+ // Stop the line just shy of the value label (which sits on the rail), not at
110
+ // the plot edge — so it never runs through the text. The label's inner edge
111
+ // is estimated from its font height (the label measures itself in RN; an
112
+ // over-estimate keeps a clean gap). Skip when the dot is inside the band.
113
+ const band = fontSize * LABEL_HEIGHT_FACTOR;
114
+ if (side === "top") {
115
+ const edgeY = EXTREMA_EDGE_INSET + band + LABEL_GAP;
116
+ if (py <= edgeY) return b.detach();
117
+ b.moveTo(px, edgeY);
118
+ b.lineTo(px, py);
119
+ } else {
120
+ const edgeY = chartBottom - band - LABEL_GAP;
121
+ if (py >= edgeY) return b.detach();
122
+ b.moveTo(px, py);
123
+ b.lineTo(px, edgeY);
124
+ }
125
+ return b.detach();
126
+ });
127
+
128
+ return (
129
+ <Path
130
+ path={path}
131
+ style="stroke"
132
+ strokeWidth={line.strokeWidth}
133
+ color={line.color}
134
+ >
135
+ {line.intervals ? <DashPathEffect intervals={line.intervals} /> : null}
136
+ </Path>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Skia connector lines for `"extrema-edge"` axis labels — a vertical line at the
142
+ * extremum's x joining its edge value label to the marker dot on the data point.
143
+ * Drawn in the canvas (so it dashes "like all lines"); the dot + label are RN
144
+ * overlays positioned with the same projection, so the three pieces line up. The
145
+ * line stops at the label edge, not the plot edge, so it never crosses the text.
146
+ *
147
+ * `top` / `bottom` are non-null only for a side in `"extrema-edge"` mode with its
148
+ * connector enabled.
149
+ */
150
+ export function ExtremaConnectorOverlay({
151
+ engine,
152
+ padding,
153
+ extremaTimeOffset = 0,
154
+ top,
155
+ bottom,
156
+ }: {
157
+ engine: ChartEngineLayout & Partial<ChartEngineExtrema>;
158
+ padding: ChartPadding;
159
+ extremaTimeOffset?: number;
160
+ top: ResolvedConnector | null;
161
+ bottom: ResolvedConnector | null;
162
+ }) {
163
+ const hasTop = top != null && engine.extremaMaxTime != null;
164
+ const hasBottom = bottom != null && engine.extremaMinTime != null;
165
+ if (!hasTop && !hasBottom) return null;
166
+
167
+ return (
168
+ <>
169
+ {hasTop && (
170
+ <ConnectorLine
171
+ side="top"
172
+ timeSV={engine.extremaMaxTime!}
173
+ valueSV={engine.extremaMaxValue!}
174
+ timeOffset={extremaTimeOffset}
175
+ engine={engine}
176
+ padding={padding}
177
+ config={top!}
178
+ />
179
+ )}
180
+ {hasBottom && (
181
+ <ConnectorLine
182
+ side="bottom"
183
+ timeSV={engine.extremaMinTime!}
184
+ valueSV={engine.extremaMinValue!}
185
+ timeOffset={extremaTimeOffset}
186
+ engine={engine}
187
+ padding={padding}
188
+ config={bottom!}
189
+ />
190
+ )}
191
+ </>
192
+ );
193
+ }
@@ -86,6 +86,10 @@ import {
86
86
  ThresholdLineOverlay,
87
87
  } from "./ThresholdLineOverlay";
88
88
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
89
+ import {
90
+ ExtremaConnectorOverlay,
91
+ labelConnector,
92
+ } from "./ExtremaConnectorOverlay";
89
93
  import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
90
94
  import { CustomTooltipOverlay } from "./CustomTooltipOverlay";
91
95
  import { BadgeOverlay } from "./BadgeOverlay";
@@ -625,6 +629,9 @@ function useLiveChartController({
625
629
  lineProp,
626
630
  formatValue,
627
631
  isCandle,
632
+ // Half a candle width (seconds) so an "extrema" axis label's dot lands on the
633
+ // candle's drawn center, not its bucket-start (left) edge. 0 in line mode.
634
+ extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
628
635
  // configs
629
636
  yAxisCfg,
630
637
  xAxisCfg,
@@ -700,6 +707,9 @@ function useLiveChartController({
700
707
  // RN axis edge labels (floated over the canvas as a sibling layer)
701
708
  topLabelCfg,
702
709
  bottomLabelCfg,
710
+ // Skia connector lines for "extrema-edge" labels (dot → edge readout).
711
+ topConnector: labelConnector(topLabelCfg, palette.gridLabel),
712
+ bottomConnector: labelConnector(bottomLabelCfg, palette.gridLabel),
703
713
  };
704
714
  }
705
715
 
@@ -1072,10 +1082,10 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1072
1082
  renderTooltip,
1073
1083
  } = model;
1074
1084
 
1075
- // A custom line-mode tooltip is an RN overlay (sibling of <Canvas>), so the
1076
- // default Skia pill is suppressed here while it's active. Candle keeps its
1077
- // built-in OHLC stack (renderTooltip is ignored in candle mode).
1078
- const customTooltipActive = !isCandle && renderTooltip != null;
1085
+ // A custom tooltip is an RN overlay (sibling of <Canvas>), so the built-in
1086
+ // Skia tooltip is suppressed here while it's active the line pill in line
1087
+ // mode, and the OHLC stack in candle mode (see the stack gate below).
1088
+ const customTooltipActive = renderTooltip != null;
1079
1089
 
1080
1090
  if (!tradeStreamResolved && !scrubCfg) return null;
1081
1091
 
@@ -1110,6 +1120,7 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1110
1120
  palette={palette}
1111
1121
  font={skiaFont}
1112
1122
  showTooltip={scrubCfg.tooltip && !customTooltipActive}
1123
+ lineTop={crosshair.tooltipLineTop}
1113
1124
  selectionDot={selectionDot}
1114
1125
  selectionY={crosshair.scrubDotY}
1115
1126
  scrubActive={crosshair.scrubActive}
@@ -1128,8 +1139,9 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1128
1139
  >
1129
1140
  {/* Candle charts render a multi-line OHLC tooltip; the line
1130
1141
  chart falls back to CrosshairOverlay's default value/time
1131
- body. Composed as children rather than a JSX-valued prop. */}
1132
- {isCandle ? (
1142
+ body. Composed as children rather than a JSX-valued prop.
1143
+ Suppressed when a custom `renderTooltip` owns the readout. */}
1144
+ {isCandle && !customTooltipActive ? (
1133
1145
  <MultiSeriesTooltipStack
1134
1146
  tooltipLayout={crosshair.tooltipLayout}
1135
1147
  font={skiaFont}
@@ -1282,6 +1294,9 @@ export function LiveChart(props: LiveChartProps) {
1282
1294
  scrubCfg,
1283
1295
  crosshair,
1284
1296
  isCandle,
1297
+ extremaTimeOffset,
1298
+ topConnector,
1299
+ bottomConnector,
1285
1300
  } = model;
1286
1301
 
1287
1302
  return (
@@ -1312,6 +1327,16 @@ export function LiveChart(props: LiveChartProps) {
1312
1327
  {/* Line stack above the fade so the line stays crisp at the left edge. */}
1313
1328
  <ChartStack model={model} />
1314
1329
 
1330
+ {/* "extrema-edge" connector lines (dot → edge readout), above the chart
1331
+ content so the dashed guide reads over the line / candles. */}
1332
+ <ExtremaConnectorOverlay
1333
+ engine={engine}
1334
+ padding={effectivePadding}
1335
+ extremaTimeOffset={extremaTimeOffset}
1336
+ top={topConnector}
1337
+ bottom={bottomConnector}
1338
+ />
1339
+
1315
1340
  {/* Reference-line badges + labels above the fade so they stay crisp. */}
1316
1341
  <ChartRefBadgeLayer model={model} />
1317
1342
 
@@ -1336,6 +1361,7 @@ export function LiveChart(props: LiveChartProps) {
1336
1361
  formatValue={formatValue}
1337
1362
  defaultColor={palette.gridLabel}
1338
1363
  padding={effectivePadding}
1364
+ extremaTimeOffset={extremaTimeOffset}
1339
1365
  />
1340
1366
 
1341
1367
  {/* Custom-rendered markers — RN views floated over the canvas (non-Skia),
@@ -1351,20 +1377,23 @@ export function LiveChart(props: LiveChartProps) {
1351
1377
  )}
1352
1378
 
1353
1379
  {/* Custom scrub tooltip — an RN view floated over the canvas (non-Skia),
1354
- positioned on the UI thread. Sibling of <Canvas>. Line mode only. */}
1355
- {scrubCfg && renderTooltip && !isCandle && (
1380
+ positioned on the UI thread. Sibling of <Canvas>. Works in line and
1381
+ candle mode (candle exposes the OHLC via `scrubCandle`). */}
1382
+ {scrubCfg && renderTooltip && (
1356
1383
  <CustomTooltipOverlay
1357
1384
  renderTooltip={renderTooltip}
1358
1385
  scrubX={crosshair.scrubX}
1359
1386
  scrubValue={crosshair.scrubValue}
1360
1387
  scrubTime={crosshair.scrubTime}
1361
1388
  scrubActive={crosshair.scrubActive}
1389
+ scrubCandle={crosshair.scrubCandle}
1362
1390
  crosshairOpacity={crosshair.crosshairOpacity}
1363
1391
  tooltipLayout={crosshair.tooltipLayout}
1364
1392
  engine={engine}
1365
1393
  padding={effectivePadding}
1366
1394
  placement={scrubCfg.tooltipPlacement}
1367
1395
  margin={scrubCfg.tooltipMargin}
1396
+ lineTop={crosshair.tooltipLineTop}
1368
1397
  />
1369
1398
  )}
1370
1399
  </View>
@@ -63,6 +63,10 @@ import {
63
63
  } from "../theme";
64
64
  import type { LiveChartSeriesProps, Marker, SeriesConfig } from "../types";
65
65
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
66
+ import {
67
+ ExtremaConnectorOverlay,
68
+ labelConnector,
69
+ } from "./ExtremaConnectorOverlay";
66
70
  import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
67
71
  import { CrosshairLine } from "./CrosshairLine";
68
72
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
@@ -395,6 +399,9 @@ function useLiveChartSeriesController({
395
399
  // RN axis edge labels (floated over the canvas as a sibling layer)
396
400
  topLabelCfg,
397
401
  bottomLabelCfg,
402
+ // Skia connector lines for "extrema-edge" labels (dot → edge readout).
403
+ topConnector: labelConnector(topLabelCfg, palette.gridLabel),
404
+ bottomConnector: labelConnector(bottomLabelCfg, palette.gridLabel),
398
405
  };
399
406
  }
400
407
 
@@ -654,6 +661,8 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
654
661
  formatValue,
655
662
  topLabelCfg,
656
663
  bottomLabelCfg,
664
+ topConnector,
665
+ bottomConnector,
657
666
  markersActive,
658
667
  markersSV,
659
668
  renderMarker,
@@ -696,6 +705,15 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
696
705
  <Canvas style={{ flex: 1, minHeight: layoutHeight || 1 }}>
697
706
  <SeriesChartStack model={model} />
698
707
 
708
+ {/* "extrema-edge" connector lines (dot → edge readout). Outside the
709
+ stack's degen-shake group so they track the (unshaken) RN dot. */}
710
+ <ExtremaConnectorOverlay
711
+ engine={engine}
712
+ padding={effectivePadding}
713
+ top={topConnector}
714
+ bottom={bottomConnector}
715
+ />
716
+
699
717
  {leftEdgeFadeCfg && (
700
718
  <LeftEdgeFade
701
719
  paddingLeft={effectivePadding.left}
@@ -9,6 +9,17 @@ export interface EngineTickMutable {
9
9
  displayMax: number;
10
10
  displayWindow: number;
11
11
  timestamp: number;
12
+ /**
13
+ * Value + time of the lowest / highest data point in the visible window —
14
+ * the actual extrema, NOT the smoothed display bounds (which carry margin and
15
+ * fold in the live value / reference lines). `NaN` when the window holds no
16
+ * data. Used to float `topLabel` / `bottomLabel` at their occurrence point
17
+ * (see {@link AxisLabelConfig.position} `"extrema"`).
18
+ */
19
+ extremaMinValue: number;
20
+ extremaMaxValue: number;
21
+ extremaMinTime: number;
22
+ extremaMaxTime: number;
12
23
  }
13
24
 
14
25
  export interface EngineTickInput {
@@ -93,6 +104,12 @@ export function tickLiveChartEngineFrame(
93
104
 
94
105
  let tMin = Infinity;
95
106
  let tMax = -Infinity;
107
+ // Time of the running min / max — captured alongside the value so an extrema
108
+ // label can be pinned at the point's x. Holds the data extrema only (folded
109
+ // below with the live value / references for the Y range, but snapshotted
110
+ // before that into `extrema*` so the label tracks the real high/low).
111
+ let minTime = 0;
112
+ let maxTime = 0;
96
113
 
97
114
  if (input.mode === "candle") {
98
115
  const candles = input.candles;
@@ -107,17 +124,29 @@ export function tickLiveChartEngineFrame(
107
124
  for (let i = lo; i < candles.length; i++) {
108
125
  if (candles[i].time > state.timestamp) break;
109
126
  /* istanbul ignore next -- trivial min/max */
110
- if (candles[i].low < tMin) tMin = candles[i].low;
127
+ if (candles[i].low < tMin) {
128
+ tMin = candles[i].low;
129
+ minTime = candles[i].time;
130
+ }
111
131
  /* istanbul ignore next -- trivial min/max */
112
- if (candles[i].high > tMax) tMax = candles[i].high;
132
+ if (candles[i].high > tMax) {
133
+ tMax = candles[i].high;
134
+ maxTime = candles[i].time;
135
+ }
113
136
  }
114
137
  }
115
138
  const lc = input.liveCandle;
116
139
  if (lc) {
117
140
  /* istanbul ignore next -- trivial min/max */
118
- if (lc.low < tMin) tMin = lc.low;
141
+ if (lc.low < tMin) {
142
+ tMin = lc.low;
143
+ minTime = lc.time;
144
+ }
119
145
  /* istanbul ignore next -- trivial min/max */
120
- if (lc.high > tMax) tMax = lc.high;
146
+ if (lc.high > tMax) {
147
+ tMax = lc.high;
148
+ maxTime = lc.time;
149
+ }
121
150
  }
122
151
  } else {
123
152
  const points = input.points;
@@ -130,11 +159,27 @@ export function tickLiveChartEngineFrame(
130
159
  }
131
160
  for (let i = lo; i < points.length; i++) {
132
161
  const v = points[i].value;
133
- if (v < tMin) tMin = v;
134
- if (v > tMax) tMax = v;
162
+ if (v < tMin) {
163
+ tMin = v;
164
+ minTime = points[i].time;
165
+ }
166
+ if (v > tMax) {
167
+ tMax = v;
168
+ maxTime = points[i].time;
169
+ }
135
170
  }
136
171
  }
137
172
 
173
+ // Snapshot the raw data extrema (value + time) before the live value /
174
+ // reference values fold into tMin/tMax for the Y range. NaN when the window
175
+ // is empty so the extrema label hides rather than pinning to a fake point.
176
+ const hasMin = tMin !== Infinity;
177
+ const hasMax = tMax !== -Infinity;
178
+ state.extremaMinValue = hasMin ? tMin : NaN;
179
+ state.extremaMaxValue = hasMax ? tMax : NaN;
180
+ state.extremaMinTime = hasMin ? minTime : NaN;
181
+ state.extremaMaxTime = hasMax ? maxTime : NaN;
182
+
138
183
  const cv = state.displayValue;
139
184
  if (cv < tMin) tMin = cv;
140
185
  if (cv > tMax) tMax = cv;
@@ -9,6 +9,16 @@ export interface MultiEngineTickMutable {
9
9
  timestamp: number;
10
10
  displayValues: number[];
11
11
  opacities: number[];
12
+ /**
13
+ * Value + time of the lowest / highest point across the visible series — the
14
+ * actual extrema, NOT the smoothed display bounds. `NaN` when no visible
15
+ * series has data in the window. Used to float `topLabel` / `bottomLabel` at
16
+ * their occurrence point (see {@link AxisLabelConfig.position} `"extrema"`).
17
+ */
18
+ extremaMinValue: number;
19
+ extremaMaxValue: number;
20
+ extremaMinTime: number;
21
+ extremaMaxTime: number;
12
22
  }
13
23
 
14
24
  export interface MultiEngineTickInput {
@@ -95,6 +105,13 @@ export function tickLiveChartSeriesEngineFrame(
95
105
 
96
106
  let tMin = Infinity;
97
107
  let tMax = -Infinity;
108
+ // Time of the running global min / max data point — captured so an extrema
109
+ // label can be pinned at its x (data extrema only; the live tips folded below
110
+ // into the Y range are snapshotted out before that).
111
+ let minTime = 0;
112
+ let maxTime = 0;
113
+ let dataMin = Infinity;
114
+ let dataMax = -Infinity;
98
115
 
99
116
  for (let i = 0; i < n; i++) {
100
117
  if (series[i].visible === false) continue;
@@ -112,12 +129,29 @@ export function tickLiveChartSeriesEngineFrame(
112
129
  if (v < tMin) tMin = v;
113
130
  /* istanbul ignore next -- trivial min/max */
114
131
  if (v > tMax) tMax = v;
132
+ if (v < dataMin) {
133
+ dataMin = v;
134
+ minTime = points[j].time;
135
+ }
136
+ if (v > dataMax) {
137
+ dataMax = v;
138
+ maxTime = points[j].time;
139
+ }
115
140
  }
116
141
  const cv = state.displayValues[i];
117
142
  if (cv < tMin) tMin = cv;
118
143
  if (cv > tMax) tMax = cv;
119
144
  }
120
145
 
146
+ // Snapshot the raw data extrema before the references fold in. NaN when no
147
+ // visible series has data in the window so the extrema label hides.
148
+ const hasMin = dataMin !== Infinity;
149
+ const hasMax = dataMax !== -Infinity;
150
+ state.extremaMinValue = hasMin ? dataMin : NaN;
151
+ state.extremaMaxValue = hasMax ? dataMax : NaN;
152
+ state.extremaMinTime = hasMin ? minTime : NaN;
153
+ state.extremaMaxTime = hasMax ? maxTime : NaN;
154
+
121
155
  const ref = input.referenceValue;
122
156
  if (ref !== undefined) {
123
157
  if (ref < tMin) tMin = ref;