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.
- package/dist/components/AreaDotsOverlay.d.ts +24 -0
- package/dist/components/AreaDotsOverlay.d.ts.map +1 -0
- 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 +45 -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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/types.d.ts +103 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/AreaDotsOverlay.tsx +62 -0
- 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 +78 -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 +100 -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/index.ts +1 -0
- package/src/theme.ts +14 -0
- package/src/types.ts +105 -8
|
@@ -1,17 +1,61 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
TextInput,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type LayoutChangeEvent,
|
|
8
|
+
} from "react-native";
|
|
2
9
|
import Animated, {
|
|
3
10
|
useAnimatedProps,
|
|
11
|
+
useAnimatedReaction,
|
|
12
|
+
useAnimatedStyle,
|
|
4
13
|
useDerivedValue,
|
|
14
|
+
useSharedValue,
|
|
5
15
|
} from "react-native-reanimated";
|
|
6
16
|
import type { SharedValue } from "react-native-reanimated";
|
|
17
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
7
18
|
|
|
8
19
|
import type { ResolvedAxisLabelConfig } from "../core/resolveConfig";
|
|
9
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
ChartEngineExtrema,
|
|
22
|
+
ChartEngineLayout,
|
|
23
|
+
} from "../core/useLiveChartEngine";
|
|
10
24
|
import type { ChartPadding } from "../draw/line";
|
|
25
|
+
import type { FontWeight } from "../types";
|
|
26
|
+
|
|
27
|
+
/** Resolved text-styling knobs shared by the edge and extrema built-in labels. */
|
|
28
|
+
interface LabelFontStyle {
|
|
29
|
+
fontSize?: number;
|
|
30
|
+
fontWeight?: FontWeight;
|
|
31
|
+
fontFamily?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Pluck the font knobs off a resolved axis-label config. */
|
|
35
|
+
function labelFont(cfg: ResolvedAxisLabelConfig): LabelFontStyle {
|
|
36
|
+
return {
|
|
37
|
+
fontSize: cfg.fontSize,
|
|
38
|
+
fontWeight: cfg.fontWeight,
|
|
39
|
+
fontFamily: cfg.fontFamily,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
11
42
|
|
|
12
43
|
/** Editable={false} TextInput so its `text` prop can be animated on the UI thread. */
|
|
13
44
|
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
|
|
14
45
|
|
|
46
|
+
/** Marker dot drawn at the exact extrema point (built-in `"extrema"` label). */
|
|
47
|
+
const EXTREMA_DOT_SIZE = 7;
|
|
48
|
+
/** Gap (px) between the extrema dot and its value text. */
|
|
49
|
+
const EXTREMA_GAP = 3;
|
|
50
|
+
/** How far off-plot (px) the extrema point may sit before the label is hidden. */
|
|
51
|
+
const EXTREMA_CULL = 24;
|
|
52
|
+
/** `"extrema-edge"` top label's inset from the very top of the chart (px). The
|
|
53
|
+
* connector ({@link ExtremaConnectorOverlay}) reads this to stop at the label. */
|
|
54
|
+
export const EXTREMA_EDGE_INSET = 2;
|
|
55
|
+
/** Default value-text size (px). Mirrors `styles.extremaText`; the connector
|
|
56
|
+
* reads it to estimate where the edge label ends. */
|
|
57
|
+
export const EXTREMA_LABEL_FONT_SIZE = 11;
|
|
58
|
+
|
|
15
59
|
/**
|
|
16
60
|
* The batteries-included axis edge label — an animated RN text driven by a
|
|
17
61
|
* `SharedValue<number>` (the chart's current top / bottom Y-axis bound). The
|
|
@@ -26,11 +70,13 @@ function BuiltInAxisValueLabel({
|
|
|
26
70
|
format,
|
|
27
71
|
color,
|
|
28
72
|
position,
|
|
73
|
+
font,
|
|
29
74
|
}: {
|
|
30
75
|
value: SharedValue<number>;
|
|
31
76
|
format: (v: number) => string;
|
|
32
77
|
color: string;
|
|
33
|
-
position: "left" | "right";
|
|
78
|
+
position: "left" | "right" | "extrema" | "extrema-edge";
|
|
79
|
+
font: LabelFontStyle;
|
|
34
80
|
}) {
|
|
35
81
|
const text = useDerivedValue(() => format(value.get()));
|
|
36
82
|
const animatedProps = useAnimatedProps(() => {
|
|
@@ -45,12 +91,239 @@ function BuiltInAxisValueLabel({
|
|
|
45
91
|
color,
|
|
46
92
|
textAlign: position === "left" ? "left" : "right",
|
|
47
93
|
padding: 0,
|
|
94
|
+
// undefined keys are ignored by RN, so each falls back to its default.
|
|
95
|
+
fontSize: font.fontSize,
|
|
96
|
+
fontWeight: font.fontWeight,
|
|
97
|
+
fontFamily: font.fontFamily,
|
|
48
98
|
}}
|
|
49
99
|
animatedProps={animatedProps}
|
|
50
100
|
/>
|
|
51
101
|
);
|
|
52
102
|
}
|
|
53
103
|
|
|
104
|
+
/**
|
|
105
|
+
* An axis label floated at the *actual data point* where the extreme value
|
|
106
|
+
* occurs — `topLabel` over the highest point, `bottomLabel` under the lowest —
|
|
107
|
+
* rather than pinned to a fixed plot edge. The chart projects the extremum's
|
|
108
|
+
* (time, value) to a canvas pixel each frame and positions the label there on
|
|
109
|
+
* the UI thread, so the dot + value track the point as the chart scrolls and
|
|
110
|
+
* the Y-axis rescales (the same projection the markers / scrub dot use).
|
|
111
|
+
*
|
|
112
|
+
* The value text rides a JS-thread `useState` rather than UI-thread animated
|
|
113
|
+
* text: it changes only when a *new* extremum appears (not every frame), and a
|
|
114
|
+
* plain `<Text>` re-measures on change so the box stays correctly centered over
|
|
115
|
+
* the point (an animated `TextInput`'s width is fixed at its last layout, which
|
|
116
|
+
* would clip / drift as the digit count changes). Hidden (`opacity: 0`) when the
|
|
117
|
+
* window holds no data (value is `NaN`) or the point scrolls off-plot.
|
|
118
|
+
*/
|
|
119
|
+
function ExtremaAxisLabel({
|
|
120
|
+
side,
|
|
121
|
+
timeSV,
|
|
122
|
+
valueSV,
|
|
123
|
+
timeOffset,
|
|
124
|
+
edgeAnchor,
|
|
125
|
+
engine,
|
|
126
|
+
padding,
|
|
127
|
+
format,
|
|
128
|
+
color,
|
|
129
|
+
font,
|
|
130
|
+
dotColor,
|
|
131
|
+
dotSize,
|
|
132
|
+
dot,
|
|
133
|
+
render,
|
|
134
|
+
}: {
|
|
135
|
+
/** `"top"` floats above the max point; `"bottom"` below the min point. */
|
|
136
|
+
side: "top" | "bottom";
|
|
137
|
+
timeSV: SharedValue<number>;
|
|
138
|
+
valueSV: SharedValue<number>;
|
|
139
|
+
/**
|
|
140
|
+
* `"extrema-edge"` mode: pin the value label to the plot's top / bottom edge
|
|
141
|
+
* (x-aligned with the extremum) instead of floating it over the point. The dot
|
|
142
|
+
* still sits on the point; a connector line (drawn in Skia) joins them.
|
|
143
|
+
*/
|
|
144
|
+
edgeAnchor: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Seconds added to the extremum time before projecting to x. `0` for line /
|
|
147
|
+
* multi-series (the point sits at its exact time); half a candle width in
|
|
148
|
+
* candle mode so the dot lands on the candle's drawn center, not its left edge.
|
|
149
|
+
*/
|
|
150
|
+
timeOffset: number;
|
|
151
|
+
engine: ChartEngineLayout;
|
|
152
|
+
padding: ChartPadding;
|
|
153
|
+
format: (v: number) => string;
|
|
154
|
+
color: string;
|
|
155
|
+
font: LabelFontStyle;
|
|
156
|
+
/** Dot color; falls back to `color`. */
|
|
157
|
+
dotColor?: string;
|
|
158
|
+
/** Dot diameter (px); falls back to the built-in default. */
|
|
159
|
+
dotSize?: number;
|
|
160
|
+
/** Whether to draw the marker dot. */
|
|
161
|
+
dot: boolean;
|
|
162
|
+
render?: () => React.ReactElement | null;
|
|
163
|
+
}) {
|
|
164
|
+
const isCustom = render != null;
|
|
165
|
+
// Resolved dot geometry. `hasDot` also gates the label's vertical offset (it
|
|
166
|
+
// sits past the dot when shown, otherwise just past the point).
|
|
167
|
+
const dotSizePx = dotSize ?? EXTREMA_DOT_SIZE;
|
|
168
|
+
const hasDot = !isCustom && dot;
|
|
169
|
+
// Measured size of the content (text or custom element) — used only to center
|
|
170
|
+
// and clamp the LABEL horizontally. The dot is pinned to the point regardless.
|
|
171
|
+
const size = useSharedValue({ width: 0, height: 0 });
|
|
172
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
173
|
+
const { width, height } = e.nativeEvent.layout;
|
|
174
|
+
size.value = { width, height };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// The value text changes only when the extremum does, so push it to JS state
|
|
178
|
+
// (a re-measured <Text>) instead of UI-thread animated text. Skipped when a
|
|
179
|
+
// custom element owns the content.
|
|
180
|
+
const [valueText, setValueText] = useState("");
|
|
181
|
+
useAnimatedReaction(
|
|
182
|
+
() => {
|
|
183
|
+
const v = valueSV.get();
|
|
184
|
+
return isCustom || !Number.isFinite(v) ? "" : format(v);
|
|
185
|
+
},
|
|
186
|
+
(curr, prev) => {
|
|
187
|
+
if (curr !== prev) scheduleOnRN(setValueText, curr);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Project the extremum's (time, value) to a canvas pixel once; the dot and the
|
|
192
|
+
// label both read it. `visible` is false when the window is empty or the point
|
|
193
|
+
// scrolls off-plot.
|
|
194
|
+
const proj = useDerivedValue(() => {
|
|
195
|
+
const value = valueSV.get();
|
|
196
|
+
const time = timeSV.get();
|
|
197
|
+
const cw = engine.canvasWidth.get();
|
|
198
|
+
const ch = engine.canvasHeight.get();
|
|
199
|
+
const displayMin = engine.displayMin.get();
|
|
200
|
+
const displayMax = engine.displayMax.get();
|
|
201
|
+
const win = engine.displayWindow.get();
|
|
202
|
+
const ts = engine.timestamp.get();
|
|
203
|
+
|
|
204
|
+
const chartLeft = padding.left;
|
|
205
|
+
const chartRight = cw - padding.right;
|
|
206
|
+
const chartW = chartRight - chartLeft;
|
|
207
|
+
const chartTop = padding.top;
|
|
208
|
+
const chartBottom = ch - padding.bottom;
|
|
209
|
+
const chartH = chartBottom - chartTop;
|
|
210
|
+
const valRange = displayMax - displayMin;
|
|
211
|
+
|
|
212
|
+
if (
|
|
213
|
+
!Number.isFinite(value) ||
|
|
214
|
+
cw === 0 ||
|
|
215
|
+
ch === 0 ||
|
|
216
|
+
chartW <= 0 ||
|
|
217
|
+
chartH <= 0 ||
|
|
218
|
+
win <= 0 ||
|
|
219
|
+
valRange <= 0
|
|
220
|
+
) {
|
|
221
|
+
return { px: 0, py: 0, visible: false };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const winStart = ts - win;
|
|
225
|
+
const px = chartLeft + ((time + timeOffset - winStart) / win) * chartW;
|
|
226
|
+
const py = chartTop + ((displayMax - value) / valRange) * chartH;
|
|
227
|
+
// Scrolled out of view — hide rather than clamp it to the edge.
|
|
228
|
+
const visible = px >= chartLeft - EXTREMA_CULL && px <= chartRight + EXTREMA_CULL;
|
|
229
|
+
return { px, py, visible };
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// The dot sits EXACTLY on the data point (its center at px, py) — never
|
|
233
|
+
// clamped, so it always marks where the high / low occurred.
|
|
234
|
+
const dotStyle = useAnimatedStyle(() => {
|
|
235
|
+
const p = proj.get();
|
|
236
|
+
if (!p.visible) return { opacity: 0 };
|
|
237
|
+
return {
|
|
238
|
+
opacity: 1,
|
|
239
|
+
transform: [
|
|
240
|
+
{ translateX: p.px - dotSizePx / 2 },
|
|
241
|
+
{ translateY: p.py - dotSizePx / 2 },
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// The label is centered on the point but clamped into the plot width so it
|
|
247
|
+
// stays readable near an edge — independent of the dot, which stays on-point.
|
|
248
|
+
const labelStyle = useAnimatedStyle(() => {
|
|
249
|
+
const p = proj.get();
|
|
250
|
+
const s = size.get();
|
|
251
|
+
if (!p.visible) {
|
|
252
|
+
return { opacity: 0, transform: [{ translateX: 0 }, { translateY: 0 }] };
|
|
253
|
+
}
|
|
254
|
+
const cw = engine.canvasWidth.get();
|
|
255
|
+
const chartLeft = padding.left;
|
|
256
|
+
const chartRight = cw - padding.right;
|
|
257
|
+
const rightBound = chartRight - s.width;
|
|
258
|
+
const x = Math.min(
|
|
259
|
+
Math.max(p.px - s.width / 2, chartLeft),
|
|
260
|
+
Math.max(chartLeft, rightBound),
|
|
261
|
+
);
|
|
262
|
+
let y: number;
|
|
263
|
+
if (edgeAnchor) {
|
|
264
|
+
// Pin to the OUTERMOST edge (x stays aligned with the extremum): the top
|
|
265
|
+
// label rides the very top of the chart; the bottom label sits flush with
|
|
266
|
+
// the plot's bottom (clear of the x-axis below). The connector stops at the
|
|
267
|
+
// label, so the line never runs through the text.
|
|
268
|
+
const ch = engine.canvasHeight.get();
|
|
269
|
+
y =
|
|
270
|
+
side === "top"
|
|
271
|
+
? EXTREMA_EDGE_INSET
|
|
272
|
+
: ch - padding.bottom - s.height;
|
|
273
|
+
} else {
|
|
274
|
+
// Sit just past the dot (max → above it, min → below it). With no dot (or a
|
|
275
|
+
// custom element), sit just past the point itself.
|
|
276
|
+
const gap = hasDot ? dotSizePx / 2 + EXTREMA_GAP : EXTREMA_GAP;
|
|
277
|
+
y = side === "top" ? p.py - gap - s.height : p.py + gap;
|
|
278
|
+
}
|
|
279
|
+
return { opacity: 1, transform: [{ translateX: x }, { translateY: y }] };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<>
|
|
284
|
+
{hasDot && (
|
|
285
|
+
<Animated.View
|
|
286
|
+
pointerEvents="none"
|
|
287
|
+
style={[
|
|
288
|
+
styles.extremaDot,
|
|
289
|
+
{
|
|
290
|
+
width: dotSizePx,
|
|
291
|
+
height: dotSizePx,
|
|
292
|
+
borderRadius: dotSizePx / 2,
|
|
293
|
+
backgroundColor: dotColor ?? color,
|
|
294
|
+
},
|
|
295
|
+
dotStyle,
|
|
296
|
+
]}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
<Animated.View
|
|
300
|
+
pointerEvents="none"
|
|
301
|
+
onLayout={onLayout}
|
|
302
|
+
style={[styles.extremaAnchor, labelStyle]}
|
|
303
|
+
>
|
|
304
|
+
{isCustom ? (
|
|
305
|
+
render()
|
|
306
|
+
) : (
|
|
307
|
+
<Text
|
|
308
|
+
style={[
|
|
309
|
+
styles.extremaText,
|
|
310
|
+
{
|
|
311
|
+
color,
|
|
312
|
+
// undefined keys fall back to the base style / RN defaults.
|
|
313
|
+
fontSize: font.fontSize,
|
|
314
|
+
fontWeight: font.fontWeight,
|
|
315
|
+
fontFamily: font.fontFamily,
|
|
316
|
+
},
|
|
317
|
+
]}
|
|
318
|
+
>
|
|
319
|
+
{valueText}
|
|
320
|
+
</Text>
|
|
321
|
+
)}
|
|
322
|
+
</Animated.View>
|
|
323
|
+
</>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
54
327
|
/**
|
|
55
328
|
* React Native overlay (NOT Skia) floating axis edge labels over the Skia
|
|
56
329
|
* canvas — `topLabel` pinned to the plot's top edge and `bottomLabel` to its
|
|
@@ -62,6 +335,10 @@ function BuiltInAxisValueLabel({
|
|
|
62
335
|
* bounds — formatted with the config's `format` (falling back to the chart's
|
|
63
336
|
* `formatValue`) in the config `color` (falling back to `defaultColor`).
|
|
64
337
|
*
|
|
338
|
+
* When a side's `position` is `"extrema"` (and the engine exposes the live
|
|
339
|
+
* extrema), it instead floats at the actual data point where the high / low
|
|
340
|
+
* occurs via {@link ExtremaAxisLabel}.
|
|
341
|
+
*
|
|
65
342
|
* Because the chart's `<View>` is the canvas size, the plot's top edge sits
|
|
66
343
|
* `padding.top` px from the top and its bottom edge `padding.bottom` px from
|
|
67
344
|
* the bottom — so positioning needs only the resolved padding (no live height,
|
|
@@ -75,59 +352,135 @@ export function AxisLabelOverlay({
|
|
|
75
352
|
formatValue,
|
|
76
353
|
defaultColor,
|
|
77
354
|
padding,
|
|
355
|
+
extremaTimeOffset = 0,
|
|
78
356
|
}: {
|
|
79
357
|
topLabel: ResolvedAxisLabelConfig | null;
|
|
80
358
|
bottomLabel: ResolvedAxisLabelConfig | null;
|
|
81
|
-
engine: ChartEngineLayout
|
|
359
|
+
engine: ChartEngineLayout & Partial<ChartEngineExtrema>;
|
|
82
360
|
formatValue: (v: number) => string;
|
|
83
361
|
defaultColor: string;
|
|
84
362
|
padding: ChartPadding;
|
|
363
|
+
/**
|
|
364
|
+
* Seconds added to each extremum time before projecting (for `"extrema"`
|
|
365
|
+
* labels). Half a candle width in candle mode so the dot aligns with the
|
|
366
|
+
* candle's drawn center; `0` (default) for line / multi-series.
|
|
367
|
+
*/
|
|
368
|
+
extremaTimeOffset?: number;
|
|
85
369
|
}) {
|
|
86
370
|
if (!topLabel && !bottomLabel) return null;
|
|
371
|
+
|
|
372
|
+
// Both extrema modes need the engine's live extrema SharedValues; without them
|
|
373
|
+
// (e.g. an engine that doesn't track them) fall back to the edge label.
|
|
374
|
+
const topMode = topLabel?.position;
|
|
375
|
+
const bottomMode = bottomLabel?.position;
|
|
376
|
+
const topExtrema =
|
|
377
|
+
(topMode === "extrema" || topMode === "extrema-edge") &&
|
|
378
|
+
engine.extremaMaxTime != null &&
|
|
379
|
+
engine.extremaMaxValue != null;
|
|
380
|
+
const bottomExtrema =
|
|
381
|
+
(bottomMode === "extrema" || bottomMode === "extrema-edge") &&
|
|
382
|
+
engine.extremaMinTime != null &&
|
|
383
|
+
engine.extremaMinValue != null;
|
|
384
|
+
|
|
87
385
|
return (
|
|
88
386
|
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
89
|
-
{topLabel &&
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
topLabel.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
387
|
+
{topLabel &&
|
|
388
|
+
(topExtrema ? (
|
|
389
|
+
<ExtremaAxisLabel
|
|
390
|
+
side="top"
|
|
391
|
+
timeSV={engine.extremaMaxTime!}
|
|
392
|
+
valueSV={engine.extremaMaxValue!}
|
|
393
|
+
timeOffset={extremaTimeOffset}
|
|
394
|
+
edgeAnchor={topMode === "extrema-edge"}
|
|
395
|
+
engine={engine}
|
|
396
|
+
padding={padding}
|
|
397
|
+
format={topLabel.format ?? formatValue}
|
|
398
|
+
color={topLabel.color ?? defaultColor}
|
|
399
|
+
font={labelFont(topLabel)}
|
|
400
|
+
dotColor={topLabel.dotColor}
|
|
401
|
+
dotSize={topLabel.dotSize}
|
|
402
|
+
dot={topLabel.dot}
|
|
403
|
+
render={topLabel.render ?? undefined}
|
|
404
|
+
/>
|
|
405
|
+
) : (
|
|
406
|
+
<View
|
|
407
|
+
style={{
|
|
408
|
+
position: "absolute",
|
|
409
|
+
top: padding.top,
|
|
410
|
+
left: padding.left,
|
|
411
|
+
right: padding.right,
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
{topLabel.render ? (
|
|
415
|
+
topLabel.render()
|
|
416
|
+
) : (
|
|
417
|
+
<BuiltInAxisValueLabel
|
|
418
|
+
value={engine.displayMax}
|
|
419
|
+
format={topLabel.format ?? formatValue}
|
|
420
|
+
color={topLabel.color ?? defaultColor}
|
|
421
|
+
position={topLabel.position}
|
|
422
|
+
font={labelFont(topLabel)}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
425
|
+
</View>
|
|
426
|
+
))}
|
|
427
|
+
{bottomLabel &&
|
|
428
|
+
(bottomExtrema ? (
|
|
429
|
+
<ExtremaAxisLabel
|
|
430
|
+
side="bottom"
|
|
431
|
+
timeSV={engine.extremaMinTime!}
|
|
432
|
+
valueSV={engine.extremaMinValue!}
|
|
433
|
+
timeOffset={extremaTimeOffset}
|
|
434
|
+
edgeAnchor={bottomMode === "extrema-edge"}
|
|
435
|
+
engine={engine}
|
|
436
|
+
padding={padding}
|
|
437
|
+
format={bottomLabel.format ?? formatValue}
|
|
438
|
+
color={bottomLabel.color ?? defaultColor}
|
|
439
|
+
font={labelFont(bottomLabel)}
|
|
440
|
+
dotColor={bottomLabel.dotColor}
|
|
441
|
+
dotSize={bottomLabel.dotSize}
|
|
442
|
+
dot={bottomLabel.dot}
|
|
443
|
+
render={bottomLabel.render ?? undefined}
|
|
444
|
+
/>
|
|
445
|
+
) : (
|
|
446
|
+
<View
|
|
447
|
+
style={{
|
|
448
|
+
position: "absolute",
|
|
449
|
+
bottom: padding.bottom,
|
|
450
|
+
left: padding.left,
|
|
451
|
+
right: padding.right,
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
{bottomLabel.render ? (
|
|
455
|
+
bottomLabel.render()
|
|
456
|
+
) : (
|
|
457
|
+
<BuiltInAxisValueLabel
|
|
458
|
+
value={engine.displayMin}
|
|
459
|
+
format={bottomLabel.format ?? formatValue}
|
|
460
|
+
color={bottomLabel.color ?? defaultColor}
|
|
461
|
+
position={bottomLabel.position}
|
|
462
|
+
font={labelFont(bottomLabel)}
|
|
463
|
+
/>
|
|
464
|
+
)}
|
|
465
|
+
</View>
|
|
466
|
+
))}
|
|
131
467
|
</View>
|
|
132
468
|
);
|
|
133
469
|
}
|
|
470
|
+
|
|
471
|
+
const styles = StyleSheet.create({
|
|
472
|
+
// top/left 0 + a measured transform places each piece over the point.
|
|
473
|
+
extremaAnchor: { position: "absolute", top: 0, left: 0, alignItems: "center" },
|
|
474
|
+
// The dot, pinned exactly on the data point (transform lands its center there).
|
|
475
|
+
// Size + radius are applied inline (configurable via `dotSize`).
|
|
476
|
+
extremaDot: {
|
|
477
|
+
position: "absolute",
|
|
478
|
+
top: 0,
|
|
479
|
+
left: 0,
|
|
480
|
+
},
|
|
481
|
+
extremaText: {
|
|
482
|
+
fontSize: EXTREMA_LABEL_FONT_SIZE,
|
|
483
|
+
padding: 0,
|
|
484
|
+
fontVariant: ["tabular-nums"],
|
|
485
|
+
},
|
|
486
|
+
});
|
|
@@ -27,6 +27,7 @@ export function CrosshairOverlay({
|
|
|
27
27
|
showTooltip = true,
|
|
28
28
|
children,
|
|
29
29
|
renderTooltip,
|
|
30
|
+
lineTop,
|
|
30
31
|
selectionDot,
|
|
31
32
|
selectionY,
|
|
32
33
|
scrubActive,
|
|
@@ -58,6 +59,10 @@ export function CrosshairOverlay({
|
|
|
58
59
|
/** Public custom-tooltip hook for line mode: when provided (and no `children`
|
|
59
60
|
* are passed), its result replaces the default value/time tooltip body. */
|
|
60
61
|
renderTooltip?: () => ReactNode;
|
|
62
|
+
/** Canvas-Y at which the crosshair line should start, so it stops just below a
|
|
63
|
+
* top-pinned custom tooltip instead of running up through it. -1 (or omitted)
|
|
64
|
+
* → start at `padding.top`. Fed by {@link CustomTooltipOverlay}. */
|
|
65
|
+
lineTop?: SharedValue<number>;
|
|
61
66
|
/** Resolved selection-dot config; `null` hides it, a `component` renders the
|
|
62
67
|
* consumer's dot, otherwise the built-in dot. */
|
|
63
68
|
selectionDot?: ResolvedSelectionDotConfig | null;
|
|
@@ -97,11 +102,16 @@ export function CrosshairOverlay({
|
|
|
97
102
|
// captured plain values keeps the dependency array a constant size. SharedValue
|
|
98
103
|
// reads stay reactive regardless of this list.
|
|
99
104
|
const p1 = useDerivedValue(
|
|
100
|
-
() =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
() => {
|
|
106
|
+
// A top-pinned custom tooltip pushes the line's start down to its measured
|
|
107
|
+
// bottom (lineTop) so the line stops at the label; -1 → no top tooltip.
|
|
108
|
+
const lt = lineTop?.value ?? -1;
|
|
109
|
+
return {
|
|
110
|
+
x: scrubX.value,
|
|
111
|
+
y: lt >= 0 ? lt : padding.top,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
[scrubX, padding.top, lineTop],
|
|
105
115
|
);
|
|
106
116
|
const p2 = useDerivedValue(
|
|
107
117
|
() => ({
|
|
@@ -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
|
|