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.
- package/dist/components/AxisLabelOverlay.d.ts +19 -3
- package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +5 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomTooltipOverlay.d.ts +8 -2
- package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
- package/dist/components/ExtremaConnectorOverlay.d.ts +32 -0
- package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -0
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +11 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +32 -2
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +18 -2
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +4 -0
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +12 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/types.d.ts +82 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/AxisLabelOverlay.tsx +399 -46
- package/src/components/CrosshairOverlay.tsx +15 -5
- package/src/components/CustomTooltipOverlay.tsx +30 -5
- package/src/components/ExtremaConnectorOverlay.tsx +193 -0
- package/src/components/LiveChart.tsx +37 -8
- package/src/components/LiveChartSeries.tsx +18 -0
- package/src/core/liveChartEngineTick.ts +51 -6
- package/src/core/liveChartSeriesEngineTick.ts +34 -0
- package/src/core/resolveConfig.ts +73 -2
- package/src/core/useLiveChartEngine.ts +42 -2
- package/src/core/useLiveChartSeriesEngine.ts +26 -0
- package/src/hooks/crosshairShared.ts +20 -1
- package/src/hooks/useCrosshair.ts +6 -0
- 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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
?
|
|
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
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
const customTooltipActive =
|
|
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
|
-
|
|
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>.
|
|
1355
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
134
|
-
|
|
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;
|