react-native-livechart 3.9.0 → 3.11.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 (49) hide show
  1. package/dist/components/AreaDotsOverlay.d.ts +24 -0
  2. package/dist/components/AreaDotsOverlay.d.ts.map +1 -0
  3. package/dist/components/AxisLabelOverlay.d.ts +19 -3
  4. package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
  5. package/dist/components/CrosshairOverlay.d.ts +5 -1
  6. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomTooltipOverlay.d.ts +8 -2
  8. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  9. package/dist/components/ExtremaConnectorOverlay.d.ts +32 -0
  10. package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -0
  11. package/dist/components/LiveChart.d.ts.map +1 -1
  12. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  13. package/dist/core/liveChartEngineTick.d.ts +11 -0
  14. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  15. package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
  16. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  17. package/dist/core/resolveConfig.d.ts +45 -2
  18. package/dist/core/resolveConfig.d.ts.map +1 -1
  19. package/dist/core/useLiveChartEngine.d.ts +18 -2
  20. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  21. package/dist/core/useLiveChartSeriesEngine.d.ts +4 -0
  22. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  23. package/dist/hooks/crosshairShared.d.ts +12 -0
  24. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  25. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/theme.d.ts +7 -0
  29. package/dist/theme.d.ts.map +1 -1
  30. package/dist/types.d.ts +103 -8
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/components/AreaDotsOverlay.tsx +62 -0
  34. package/src/components/AxisLabelOverlay.tsx +399 -46
  35. package/src/components/CrosshairOverlay.tsx +15 -5
  36. package/src/components/CustomTooltipOverlay.tsx +30 -5
  37. package/src/components/ExtremaConnectorOverlay.tsx +193 -0
  38. package/src/components/LiveChart.tsx +78 -8
  39. package/src/components/LiveChartSeries.tsx +18 -0
  40. package/src/core/liveChartEngineTick.ts +51 -6
  41. package/src/core/liveChartSeriesEngineTick.ts +34 -0
  42. package/src/core/resolveConfig.ts +100 -2
  43. package/src/core/useLiveChartEngine.ts +42 -2
  44. package/src/core/useLiveChartSeriesEngine.ts +26 -0
  45. package/src/hooks/crosshairShared.ts +20 -1
  46. package/src/hooks/useCrosshair.ts +6 -0
  47. package/src/index.ts +1 -0
  48. package/src/theme.ts +14 -0
  49. package/src/types.ts +105 -8
@@ -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
+ }
@@ -19,6 +19,7 @@ import {
19
19
 
20
20
  import { DEFAULT_ACCENT_COLOR } from "../constants";
21
21
  import {
22
+ resolveAreaDots,
22
23
  resolveAxisLabel,
23
24
  resolveBadge,
24
25
  resolveDegen,
@@ -73,6 +74,7 @@ import {
73
74
  applyPaletteOverride,
74
75
  leftEdgeFadeColorsFromBgRgb,
75
76
  parseColorRgb,
77
+ parseColorRgba,
76
78
  resolveTheme,
77
79
  } from "../theme";
78
80
  import type {
@@ -85,7 +87,12 @@ import {
85
87
  ThresholdBadgeOverlay,
86
88
  ThresholdLineOverlay,
87
89
  } from "./ThresholdLineOverlay";
90
+ import { AreaDotsOverlay } from "./AreaDotsOverlay";
88
91
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
92
+ import {
93
+ ExtremaConnectorOverlay,
94
+ labelConnector,
95
+ } from "./ExtremaConnectorOverlay";
89
96
  import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
90
97
  import { CustomTooltipOverlay } from "./CustomTooltipOverlay";
91
98
  import { BadgeOverlay } from "./BadgeOverlay";
@@ -146,6 +153,7 @@ function useLiveChartController({
146
153
  theme = "dark",
147
154
  accentColor = DEFAULT_ACCENT_COLOR,
148
155
  gradient = true,
156
+ areaDots,
149
157
  line: lineProp,
150
158
  font: fontProp,
151
159
  insets,
@@ -232,6 +240,9 @@ function useLiveChartController({
232
240
  // Static charts run no gestures, so scrub-action (tap/drag to lock a price) is off.
233
241
  const scrubActionCfg = isStatic ? null : resolveScrubAction(scrubAction);
234
242
  const gradientCfg = isCandle ? null : resolveGradient(gradient);
243
+ // Dot-lattice area fill (clipped to the under-line region). Inert in candle
244
+ // mode, same as the gradient fill.
245
+ const areaDotsCfg = isCandle ? null : resolveAreaDots(areaDots);
235
246
  // Threshold split is a line-mode feature (candle bodies carry their own up/down
236
247
  // colors), so it's inert in candle mode — same as the area gradient.
237
248
  const thresholdCfg = isCandle ? null : resolveThreshold(threshold);
@@ -405,6 +416,22 @@ function useLiveChartController({
405
416
  // Straight polyline instead of the monotone cubic when line.curve === "linear".
406
417
  lineProp?.curve === "linear",
407
418
  );
419
+
420
+ // Area-dots fill shader color as a vec4 (channels 0..1), with the config
421
+ // `opacity` folded into the alpha. Defaults to a faint tint of the line/accent
422
+ // color (theme-aware) so out-of-the-box dots read as a subtle field.
423
+ const areaDotRgb = parseColorRgb(lineProp?.color ?? palette.line);
424
+ const [adR, adG, adB, adA] = parseColorRgba(
425
+ areaDotsCfg?.color ??
426
+ `rgba(${areaDotRgb[0]}, ${areaDotRgb[1]}, ${areaDotRgb[2]}, 0.22)`,
427
+ );
428
+ const areaDotColorVec = [
429
+ adR / 255,
430
+ adG / 255,
431
+ adB / 255,
432
+ adA * (areaDotsCfg?.opacity ?? 1),
433
+ ];
434
+
408
435
  const { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } =
409
436
  useCandlePaths(
410
437
  engine,
@@ -625,6 +652,9 @@ function useLiveChartController({
625
652
  lineProp,
626
653
  formatValue,
627
654
  isCandle,
655
+ // Half a candle width (seconds) so an "extrema" axis label's dot lands on the
656
+ // candle's drawn center, not its bucket-start (left) edge. 0 in line mode.
657
+ extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
628
658
  // configs
629
659
  yAxisCfg,
630
660
  xAxisCfg,
@@ -632,6 +662,8 @@ function useLiveChartController({
632
662
  scrubCfg,
633
663
  scrubActionCfg,
634
664
  gradientCfg,
665
+ areaDotsCfg,
666
+ areaDotColorVec,
635
667
  valueLineCfg,
636
668
  pulseCfg,
637
669
  dotCfg,
@@ -700,6 +732,9 @@ function useLiveChartController({
700
732
  // RN axis edge labels (floated over the canvas as a sibling layer)
701
733
  topLabelCfg,
702
734
  bottomLabelCfg,
735
+ // Skia connector lines for "extrema-edge" labels (dot → edge readout).
736
+ topConnector: labelConnector(topLabelCfg, palette.gridLabel),
737
+ bottomConnector: labelConnector(bottomLabelCfg, palette.gridLabel),
703
738
  };
704
739
  }
705
740
 
@@ -726,6 +761,8 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
726
761
  gridStyleCfg,
727
762
  metricsCfg,
728
763
  gradientCfg,
764
+ areaDotsCfg,
765
+ areaDotColorVec,
729
766
  fillPath,
730
767
  gradientEnd,
731
768
  gradientColors,
@@ -755,6 +792,20 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
755
792
  </Group>
756
793
  )}
757
794
 
795
+ {/* Dot-lattice area fill (the under-line `fillPath` painted with a dot
796
+ shader). Drawn before the gradient so a gradient (if also enabled)
797
+ composites on top. */}
798
+ {areaDotsCfg && (
799
+ <Group opacity={reveal.fillOpacity}>
800
+ <AreaDotsOverlay
801
+ fillPath={fillPath}
802
+ color={areaDotColorVec}
803
+ spacing={areaDotsCfg.spacing}
804
+ size={areaDotsCfg.size}
805
+ />
806
+ </Group>
807
+ )}
808
+
758
809
  {/* Area gradient fill */}
759
810
  {gradientCfg && (
760
811
  <Group opacity={reveal.fillOpacity}>
@@ -1072,10 +1123,10 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1072
1123
  renderTooltip,
1073
1124
  } = model;
1074
1125
 
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;
1126
+ // A custom tooltip is an RN overlay (sibling of <Canvas>), so the built-in
1127
+ // Skia tooltip is suppressed here while it's active the line pill in line
1128
+ // mode, and the OHLC stack in candle mode (see the stack gate below).
1129
+ const customTooltipActive = renderTooltip != null;
1079
1130
 
1080
1131
  if (!tradeStreamResolved && !scrubCfg) return null;
1081
1132
 
@@ -1110,6 +1161,7 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1110
1161
  palette={palette}
1111
1162
  font={skiaFont}
1112
1163
  showTooltip={scrubCfg.tooltip && !customTooltipActive}
1164
+ lineTop={crosshair.tooltipLineTop}
1113
1165
  selectionDot={selectionDot}
1114
1166
  selectionY={crosshair.scrubDotY}
1115
1167
  scrubActive={crosshair.scrubActive}
@@ -1128,8 +1180,9 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1128
1180
  >
1129
1181
  {/* Candle charts render a multi-line OHLC tooltip; the line
1130
1182
  chart falls back to CrosshairOverlay's default value/time
1131
- body. Composed as children rather than a JSX-valued prop. */}
1132
- {isCandle ? (
1183
+ body. Composed as children rather than a JSX-valued prop.
1184
+ Suppressed when a custom `renderTooltip` owns the readout. */}
1185
+ {isCandle && !customTooltipActive ? (
1133
1186
  <MultiSeriesTooltipStack
1134
1187
  tooltipLayout={crosshair.tooltipLayout}
1135
1188
  font={skiaFont}
@@ -1282,6 +1335,9 @@ export function LiveChart(props: LiveChartProps) {
1282
1335
  scrubCfg,
1283
1336
  crosshair,
1284
1337
  isCandle,
1338
+ extremaTimeOffset,
1339
+ topConnector,
1340
+ bottomConnector,
1285
1341
  } = model;
1286
1342
 
1287
1343
  return (
@@ -1312,6 +1368,16 @@ export function LiveChart(props: LiveChartProps) {
1312
1368
  {/* Line stack above the fade so the line stays crisp at the left edge. */}
1313
1369
  <ChartStack model={model} />
1314
1370
 
1371
+ {/* "extrema-edge" connector lines (dot → edge readout), above the chart
1372
+ content so the dashed guide reads over the line / candles. */}
1373
+ <ExtremaConnectorOverlay
1374
+ engine={engine}
1375
+ padding={effectivePadding}
1376
+ extremaTimeOffset={extremaTimeOffset}
1377
+ top={topConnector}
1378
+ bottom={bottomConnector}
1379
+ />
1380
+
1315
1381
  {/* Reference-line badges + labels above the fade so they stay crisp. */}
1316
1382
  <ChartRefBadgeLayer model={model} />
1317
1383
 
@@ -1336,6 +1402,7 @@ export function LiveChart(props: LiveChartProps) {
1336
1402
  formatValue={formatValue}
1337
1403
  defaultColor={palette.gridLabel}
1338
1404
  padding={effectivePadding}
1405
+ extremaTimeOffset={extremaTimeOffset}
1339
1406
  />
1340
1407
 
1341
1408
  {/* Custom-rendered markers — RN views floated over the canvas (non-Skia),
@@ -1351,20 +1418,23 @@ export function LiveChart(props: LiveChartProps) {
1351
1418
  )}
1352
1419
 
1353
1420
  {/* 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 && (
1421
+ positioned on the UI thread. Sibling of <Canvas>. Works in line and
1422
+ candle mode (candle exposes the OHLC via `scrubCandle`). */}
1423
+ {scrubCfg && renderTooltip && (
1356
1424
  <CustomTooltipOverlay
1357
1425
  renderTooltip={renderTooltip}
1358
1426
  scrubX={crosshair.scrubX}
1359
1427
  scrubValue={crosshair.scrubValue}
1360
1428
  scrubTime={crosshair.scrubTime}
1361
1429
  scrubActive={crosshair.scrubActive}
1430
+ scrubCandle={crosshair.scrubCandle}
1362
1431
  crosshairOpacity={crosshair.crosshairOpacity}
1363
1432
  tooltipLayout={crosshair.tooltipLayout}
1364
1433
  engine={engine}
1365
1434
  padding={effectivePadding}
1366
1435
  placement={scrubCfg.tooltipPlacement}
1367
1436
  margin={scrubCfg.tooltipMargin}
1437
+ lineTop={crosshair.tooltipLineTop}
1368
1438
  />
1369
1439
  )}
1370
1440
  </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;