react-native-livechart 3.3.0 → 3.5.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/README.md +5 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/ReferenceLineOverlay.d.ts +10 -3
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/ScrubActionOverlay.d.ts +32 -0
- package/dist/components/ScrubActionOverlay.d.ts.map +1 -0
- package/dist/components/ThresholdLineOverlay.d.ts +34 -0
- package/dist/components/ThresholdLineOverlay.d.ts.map +1 -0
- package/dist/components/XAxisOverlay.d.ts.map +1 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +55 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +110 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +5 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +14 -2
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts +2 -0
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/useReferenceLine.d.ts +36 -3
- package/dist/hooks/useReferenceLine.d.ts.map +1 -1
- package/dist/hooks/useReferenceLinePress.d.ts +23 -0
- package/dist/hooks/useReferenceLinePress.d.ts.map +1 -0
- package/dist/hooks/useThreshold.d.ts +22 -0
- package/dist/hooks/useThreshold.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/referenceLines.d.ts +23 -0
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +25 -0
- package/dist/math/threshold.d.ts.map +1 -0
- package/dist/types.d.ts +190 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +362 -61
- package/src/components/LiveChartSeries.tsx +39 -2
- package/src/components/ReferenceLineOverlay.tsx +121 -94
- package/src/components/ScrubActionOverlay.tsx +187 -0
- package/src/components/ThresholdLineOverlay.tsx +172 -0
- package/src/components/XAxisOverlay.tsx +2 -1
- package/src/constants.ts +5 -0
- package/src/core/resolveConfig.ts +102 -0
- package/src/hooks/crosshairShared.ts +293 -0
- package/src/hooks/useChartPaths.ts +25 -1
- package/src/hooks/useCrosshair.ts +340 -6
- package/src/hooks/useMarkers.ts +18 -2
- package/src/hooks/useReferenceLine.ts +259 -14
- package/src/hooks/useReferenceLinePress.ts +92 -0
- package/src/hooks/useThreshold.ts +63 -0
- package/src/index.ts +5 -0
- package/src/math/referenceLines.ts +55 -0
- package/src/math/threshold.ts +59 -0
- package/src/types.ts +195 -6
|
@@ -12,23 +12,21 @@ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
|
12
12
|
import type { ChartPadding } from "../draw/line";
|
|
13
13
|
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
14
14
|
import { useReferenceLine } from "../hooks/useReferenceLine";
|
|
15
|
-
import {
|
|
16
|
-
import { referenceLineForm } from "../math/referenceLines";
|
|
15
|
+
import { referenceLineForm, resolveReferenceBadge } from "../math/referenceLines";
|
|
17
16
|
import type { LiveChartPalette, ReferenceLine } from "../types";
|
|
18
17
|
|
|
19
18
|
/** Translucent fill alpha for value / time bands. */
|
|
20
19
|
const BAND_FILL_OPACITY = 0.16;
|
|
21
20
|
|
|
22
|
-
/**
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const OFF_AXIS_PILL_RADIUS = 5;
|
|
21
|
+
/** Vertical padding inside the badge pill, in px (kept in sync with the layout). */
|
|
22
|
+
const BADGE_PILL_PAD_Y = 3;
|
|
23
|
+
const BADGE_PILL_RADIUS = 5;
|
|
26
24
|
|
|
27
25
|
/**
|
|
28
26
|
* Renders one reference line or band into the chart canvas. Handles all three
|
|
29
27
|
* `ReferenceLine` forms (horizontal line, horizontal value band, vertical time
|
|
30
|
-
* band) plus the
|
|
31
|
-
* so callers can `.map()` over a variable-length
|
|
28
|
+
* band) plus the Form-A pill badge (in-range tag + off-screen chevron pin).
|
|
29
|
+
* Self-contained so callers can `.map()` over a variable-length array.
|
|
32
30
|
*/
|
|
33
31
|
export function ReferenceLineOverlay({
|
|
34
32
|
engine,
|
|
@@ -37,6 +35,7 @@ export function ReferenceLineOverlay({
|
|
|
37
35
|
palette,
|
|
38
36
|
formatValue,
|
|
39
37
|
font,
|
|
38
|
+
badgeLayer = false,
|
|
40
39
|
}: {
|
|
41
40
|
engine: ChartEngineLayout;
|
|
42
41
|
padding: ChartPadding;
|
|
@@ -44,6 +43,13 @@ export function ReferenceLineOverlay({
|
|
|
44
43
|
palette: LiveChartPalette;
|
|
45
44
|
formatValue: (v: number) => string;
|
|
46
45
|
font: SkFont;
|
|
46
|
+
/**
|
|
47
|
+
* Render only the badge + label (`true`) or only the lines / bands (`false`,
|
|
48
|
+
* default). The caller draws the base pass behind the chart content and the
|
|
49
|
+
* badge pass **above** the left-edge fade, so badges/labels stay crisp instead
|
|
50
|
+
* of being erased by the fade's `dstOut` blend.
|
|
51
|
+
*/
|
|
52
|
+
badgeLayer?: boolean;
|
|
47
53
|
}) {
|
|
48
54
|
const form = referenceLineForm(line);
|
|
49
55
|
const isBand = form === "value-band" || form === "time-band";
|
|
@@ -58,21 +64,24 @@ export function ReferenceLineOverlay({
|
|
|
58
64
|
const bandFillOpacity = line.fillOpacity ?? BAND_FILL_OPACITY;
|
|
59
65
|
const hasBandBorder = isBand && line.strokeWidth !== undefined;
|
|
60
66
|
|
|
61
|
-
//
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
67
|
+
// Resolved badge appearance (badge config → fallback flat fields → theme).
|
|
68
|
+
const badge = resolveReferenceBadge(line);
|
|
69
|
+
const badgeBackground = badge?.background ?? palette.tooltipBg;
|
|
70
|
+
const badgeBorderColor = badge?.borderColor ?? color;
|
|
71
|
+
const badgeRadius = badge?.radius ?? BADGE_PILL_RADIUS;
|
|
65
72
|
|
|
66
73
|
const lineBuilder = usePathBuilder();
|
|
67
74
|
const bandBuilder = usePathBuilder();
|
|
68
75
|
const borderBuilder = usePathBuilder();
|
|
69
|
-
const
|
|
76
|
+
const connBuilder = usePathBuilder();
|
|
70
77
|
const chevBuilder = usePathBuilder();
|
|
71
78
|
|
|
72
79
|
const linePath = useDerivedValue(() => {
|
|
73
80
|
const b = lineBuilder.value;
|
|
74
81
|
const l = layout.get();
|
|
75
|
-
|
|
82
|
+
// A plain full-width line. A badge instead draws a pill + a connector to the
|
|
83
|
+
// opposite edge, below.
|
|
84
|
+
if (l.visible && !l.badge && !isBand) {
|
|
76
85
|
b.moveTo(l.x1, l.y);
|
|
77
86
|
b.lineTo(l.x2, l.y);
|
|
78
87
|
}
|
|
@@ -113,19 +122,13 @@ export function ReferenceLineOverlay({
|
|
|
113
122
|
return b.detach();
|
|
114
123
|
});
|
|
115
124
|
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
// Badge connector — the dashed line from the pill out to the opposite edge.
|
|
126
|
+
const connPath = useDerivedValue(() => {
|
|
127
|
+
const b = connBuilder.value;
|
|
118
128
|
const l = layout.get();
|
|
119
|
-
if (l.visible && l.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const pillRight =
|
|
123
|
-
l.labelX + measureFontTextWidth(font, l.label) + OFF_AXIS_PILL_PAD_X;
|
|
124
|
-
const start = pillRight + 4;
|
|
125
|
-
if (start < l.x2) {
|
|
126
|
-
b.moveTo(start, l.y);
|
|
127
|
-
b.lineTo(l.x2, l.y);
|
|
128
|
-
}
|
|
129
|
+
if (l.visible && l.badge && l.connStart >= 0) {
|
|
130
|
+
b.moveTo(l.connStart, l.y);
|
|
131
|
+
b.lineTo(l.connEnd, l.y);
|
|
129
132
|
}
|
|
130
133
|
return b.detach();
|
|
131
134
|
});
|
|
@@ -133,8 +136,8 @@ export function ReferenceLineOverlay({
|
|
|
133
136
|
const chevronPath = useDerivedValue(() => {
|
|
134
137
|
const b = chevBuilder.value;
|
|
135
138
|
const l = layout.get();
|
|
136
|
-
if (l.visible && l.offAxis) {
|
|
137
|
-
const cx = l.
|
|
139
|
+
if (l.visible && l.offAxis && l.chevronCx >= 0) {
|
|
140
|
+
const cx = l.chevronCx;
|
|
138
141
|
const cy = l.y;
|
|
139
142
|
const s = 4;
|
|
140
143
|
if (l.chevronUp) {
|
|
@@ -150,55 +153,64 @@ export function ReferenceLineOverlay({
|
|
|
150
153
|
return b.detach();
|
|
151
154
|
});
|
|
152
155
|
|
|
153
|
-
const lineOpacity = useDerivedValue(() =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
const lineOpacity = useDerivedValue(() => {
|
|
157
|
+
const l = layout.get();
|
|
158
|
+
return l.visible && !l.badge && !isBand ? 1 : 0;
|
|
159
|
+
});
|
|
156
160
|
const bandOpacity = useDerivedValue(() =>
|
|
157
161
|
layout.get().visible && isBand ? bandFillOpacity : 0,
|
|
158
162
|
);
|
|
159
163
|
const bandBorderOpacity = useDerivedValue(() =>
|
|
160
164
|
layout.get().visible && hasBandBorder ? 1 : 0,
|
|
161
165
|
);
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
const badgeOpacity = useDerivedValue(() => {
|
|
167
|
+
const l = layout.get();
|
|
168
|
+
return l.visible && l.badge ? 1 : 0;
|
|
169
|
+
});
|
|
170
|
+
// Text + icon ride in the badge pass too, so they stay crisp above the fade.
|
|
171
|
+
const labelOpacity = useDerivedValue(() => {
|
|
172
|
+
const l = layout.get();
|
|
173
|
+
return l.visible && l.label.length > 0 ? 1 : 0;
|
|
174
|
+
});
|
|
175
|
+
const iconOpacity = useDerivedValue(() => {
|
|
176
|
+
const l = layout.get();
|
|
177
|
+
return l.visible && l.icon.length > 0 ? 1 : 0;
|
|
178
|
+
});
|
|
166
179
|
|
|
167
180
|
const labelX = useDerivedValue(() => layout.get().labelX);
|
|
168
181
|
const labelY = useDerivedValue(() => layout.get().labelY);
|
|
169
182
|
const labelText = useDerivedValue(() => layout.get().label);
|
|
183
|
+
const iconX = useDerivedValue(() => layout.get().iconX);
|
|
184
|
+
const iconText = useDerivedValue(() => layout.get().icon);
|
|
170
185
|
|
|
171
186
|
// Font metrics depend only on the (stable) font, so read them once instead of
|
|
172
|
-
// on every frame inside the pill
|
|
173
|
-
// JSI). The off-axis pill's ascent offset and height are then plain constants.
|
|
187
|
+
// on every frame inside the pill worklet (`getMetrics` allocates + crosses JSI).
|
|
174
188
|
const { ascent: fontAscent, height: pillH } = (() => {
|
|
175
189
|
const fm = font.getMetrics();
|
|
176
190
|
return {
|
|
177
191
|
ascent: fm.ascent,
|
|
178
|
-
height: fm.descent - fm.ascent +
|
|
192
|
+
height: fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2,
|
|
179
193
|
};
|
|
180
194
|
})();
|
|
181
195
|
|
|
182
|
-
//
|
|
183
|
-
const pillX = useDerivedValue(() => layout.get().
|
|
196
|
+
// Pill rect — position/size from the layout; vertically centered on the line.
|
|
197
|
+
const pillX = useDerivedValue(() => layout.get().pillX);
|
|
198
|
+
const pillW = useDerivedValue(() => layout.get().pillW);
|
|
184
199
|
const pillY = useDerivedValue(
|
|
185
|
-
() => layout.get().labelY + fontAscent -
|
|
200
|
+
() => layout.get().labelY + fontAscent - BADGE_PILL_PAD_Y,
|
|
186
201
|
);
|
|
187
|
-
const pillW = useDerivedValue(() => {
|
|
188
|
-
const l = layout.get();
|
|
189
|
-
const textW = measureFontTextWidth(font, l.label);
|
|
190
|
-
return l.labelX + textW + OFF_AXIS_PILL_PAD_X - (l.x1 + 2);
|
|
191
|
-
});
|
|
192
202
|
|
|
193
203
|
return (
|
|
194
204
|
<Group>
|
|
195
|
-
{
|
|
205
|
+
{/* Base pass — bands + lines, drawn behind the chart content (and faded at
|
|
206
|
+
the left edge like the rest of the content). */}
|
|
207
|
+
{!badgeLayer && isBand && (
|
|
196
208
|
<Group opacity={bandOpacity}>
|
|
197
209
|
<Path path={bandPath} style="fill" color={color} />
|
|
198
210
|
</Group>
|
|
199
211
|
)}
|
|
200
212
|
|
|
201
|
-
{hasBandBorder && (
|
|
213
|
+
{!badgeLayer && hasBandBorder && (
|
|
202
214
|
<Group opacity={bandBorderOpacity}>
|
|
203
215
|
<Path
|
|
204
216
|
path={bandBorderPath}
|
|
@@ -211,7 +223,7 @@ export function ReferenceLineOverlay({
|
|
|
211
223
|
</Group>
|
|
212
224
|
)}
|
|
213
225
|
|
|
214
|
-
{!isBand && (
|
|
226
|
+
{!badgeLayer && !isBand && (
|
|
215
227
|
<Group opacity={lineOpacity}>
|
|
216
228
|
<Path
|
|
217
229
|
path={linePath}
|
|
@@ -224,52 +236,67 @@ export function ReferenceLineOverlay({
|
|
|
224
236
|
</Group>
|
|
225
237
|
)}
|
|
226
238
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
239
|
+
{/* Badge pass — connector + pill + chevron + icon, above the left-edge fade. */}
|
|
240
|
+
{badgeLayer && (
|
|
241
|
+
<Group opacity={badgeOpacity}>
|
|
242
|
+
<Path
|
|
243
|
+
path={connPath}
|
|
244
|
+
style="stroke"
|
|
245
|
+
strokeWidth={strokeWidth}
|
|
246
|
+
color={color}
|
|
247
|
+
>
|
|
248
|
+
<DashPathEffect intervals={intervals} />
|
|
249
|
+
</Path>
|
|
250
|
+
<RoundedRect
|
|
251
|
+
x={pillX}
|
|
252
|
+
y={pillY}
|
|
253
|
+
width={pillW}
|
|
254
|
+
height={pillH}
|
|
255
|
+
r={badgeRadius}
|
|
256
|
+
color={badgeBackground}
|
|
257
|
+
/>
|
|
258
|
+
<RoundedRect
|
|
259
|
+
x={pillX}
|
|
260
|
+
y={pillY}
|
|
261
|
+
width={pillW}
|
|
262
|
+
height={pillH}
|
|
263
|
+
r={badgeRadius}
|
|
264
|
+
color={badgeBorderColor}
|
|
265
|
+
style="stroke"
|
|
266
|
+
strokeWidth={1}
|
|
267
|
+
/>
|
|
268
|
+
<Path
|
|
269
|
+
path={chevronPath}
|
|
270
|
+
style="stroke"
|
|
271
|
+
strokeWidth={1.5}
|
|
272
|
+
color={color}
|
|
273
|
+
strokeCap="round"
|
|
274
|
+
strokeJoin="round"
|
|
275
|
+
/>
|
|
276
|
+
<Group opacity={iconOpacity}>
|
|
277
|
+
<SkiaText
|
|
278
|
+
x={iconX}
|
|
279
|
+
y={labelY}
|
|
280
|
+
text={iconText}
|
|
281
|
+
font={font}
|
|
282
|
+
color={labelColor}
|
|
283
|
+
/>
|
|
284
|
+
</Group>
|
|
285
|
+
</Group>
|
|
286
|
+
)}
|
|
263
287
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
288
|
+
{/* Labels ride in the badge pass too, so they stay crisp above the fade. */}
|
|
289
|
+
{badgeLayer && (
|
|
290
|
+
<Group opacity={labelOpacity}>
|
|
291
|
+
<SkiaText
|
|
292
|
+
x={labelX}
|
|
293
|
+
y={labelY}
|
|
294
|
+
text={labelText}
|
|
295
|
+
font={font}
|
|
296
|
+
color={labelColor}
|
|
297
|
+
/>
|
|
298
|
+
</Group>
|
|
299
|
+
)}
|
|
273
300
|
</Group>
|
|
274
301
|
);
|
|
275
302
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Circle,
|
|
3
|
+
DashPathEffect,
|
|
4
|
+
Group,
|
|
5
|
+
Line,
|
|
6
|
+
RoundedRect,
|
|
7
|
+
Text as SkiaText,
|
|
8
|
+
type SkFont,
|
|
9
|
+
} from "@shopify/react-native-skia";
|
|
10
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
11
|
+
|
|
12
|
+
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
13
|
+
import type { ChartPadding } from "../draw/line";
|
|
14
|
+
import type {
|
|
15
|
+
ActionBadgeLayout,
|
|
16
|
+
TimeBadgeLayout,
|
|
17
|
+
} from "../hooks/crosshairShared";
|
|
18
|
+
import type { LiveChartPalette } from "../types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scrub-action ("order ticket") overlay: the locked reticle (a horizontal price
|
|
22
|
+
* level line + a faint vertical line crossing at the reticle) and the right-gutter
|
|
23
|
+
* action badge — a circular icon button + a separate price pill. All keyed off the
|
|
24
|
+
* lock SharedValues, so it tracks the reticle on the UI thread without re-renders.
|
|
25
|
+
*
|
|
26
|
+
* Mounted by `LiveChart` **outside** the degen-shake group so the rendered badge
|
|
27
|
+
* stays aligned with the (untransformed) tap hit-test in {@link useCrosshair}.
|
|
28
|
+
* Visibility rides on a 0/1 opacity driven by `lockActive` (no React remount).
|
|
29
|
+
*/
|
|
30
|
+
export function ScrubActionOverlay({
|
|
31
|
+
lockActive,
|
|
32
|
+
lockX,
|
|
33
|
+
lockY,
|
|
34
|
+
actionBadge,
|
|
35
|
+
timeBadge,
|
|
36
|
+
engine,
|
|
37
|
+
padding,
|
|
38
|
+
palette,
|
|
39
|
+
font,
|
|
40
|
+
icon,
|
|
41
|
+
lineColor,
|
|
42
|
+
background,
|
|
43
|
+
iconColor,
|
|
44
|
+
}: {
|
|
45
|
+
lockActive: SharedValue<boolean>;
|
|
46
|
+
lockX: SharedValue<number>;
|
|
47
|
+
lockY: SharedValue<number>;
|
|
48
|
+
actionBadge: SharedValue<ActionBadgeLayout>;
|
|
49
|
+
timeBadge?: SharedValue<TimeBadgeLayout>;
|
|
50
|
+
engine: ChartEngineLayout;
|
|
51
|
+
padding: ChartPadding;
|
|
52
|
+
palette: LiveChartPalette;
|
|
53
|
+
font: SkFont;
|
|
54
|
+
icon: string;
|
|
55
|
+
lineColor?: string;
|
|
56
|
+
background?: string;
|
|
57
|
+
iconColor?: string;
|
|
58
|
+
}) {
|
|
59
|
+
const opacity = useDerivedValue(() => (lockActive.value ? 1 : 0), [lockActive]);
|
|
60
|
+
|
|
61
|
+
// Horizontal level line (the chosen price), full plot width.
|
|
62
|
+
const hLeft = useDerivedValue(
|
|
63
|
+
() => ({ x: padding.left, y: lockY.value }),
|
|
64
|
+
[padding.left, lockY],
|
|
65
|
+
);
|
|
66
|
+
const hRight = useDerivedValue(
|
|
67
|
+
() => ({ x: engine.canvasWidth.value - padding.right, y: lockY.value }),
|
|
68
|
+
[engine.canvasWidth, padding.right, lockY],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Faint vertical line at the reticle X, full plot height.
|
|
72
|
+
const vTop = useDerivedValue(
|
|
73
|
+
() => ({ x: lockX.value, y: padding.top }),
|
|
74
|
+
[lockX, padding.top],
|
|
75
|
+
);
|
|
76
|
+
const vBottom = useDerivedValue(
|
|
77
|
+
() => ({ x: lockX.value, y: engine.canvasHeight.value - padding.bottom }),
|
|
78
|
+
[lockX, engine.canvasHeight, padding.bottom],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Action badge — a circular icon button + a separate price pill (2px gap).
|
|
82
|
+
// Center the icon by its *visual* bounds (not advance box) so a symbol like
|
|
83
|
+
// "+" sits dead-center in the circle. The glyph bounds are constant, so measure
|
|
84
|
+
// once and offset the per-frame circle center.
|
|
85
|
+
const iconBounds = icon ? font.measureText(icon) : null;
|
|
86
|
+
const iconOffX = iconBounds ? -iconBounds.x - iconBounds.width / 2 : 0;
|
|
87
|
+
const iconOffY = iconBounds ? -iconBounds.y - iconBounds.height / 2 : 0;
|
|
88
|
+
|
|
89
|
+
const iconOpacity = useDerivedValue(() =>
|
|
90
|
+
actionBadge.value.hasIcon ? 1 : 0,
|
|
91
|
+
);
|
|
92
|
+
const iconCx = useDerivedValue(() => actionBadge.value.iconCx);
|
|
93
|
+
const iconCy = useDerivedValue(() => actionBadge.value.iconCy);
|
|
94
|
+
const iconR = useDerivedValue(() => actionBadge.value.iconR);
|
|
95
|
+
const iconGlyphX = useDerivedValue(() => actionBadge.value.iconCx + iconOffX);
|
|
96
|
+
const iconGlyphY = useDerivedValue(() => actionBadge.value.iconCy + iconOffY);
|
|
97
|
+
|
|
98
|
+
const priceOpacity = useDerivedValue(() =>
|
|
99
|
+
actionBadge.value.hasPrice ? 1 : 0,
|
|
100
|
+
);
|
|
101
|
+
const priceX = useDerivedValue(() => actionBadge.value.priceX);
|
|
102
|
+
const priceY = useDerivedValue(() => actionBadge.value.priceY);
|
|
103
|
+
const priceW = useDerivedValue(() => actionBadge.value.priceW);
|
|
104
|
+
const priceH = useDerivedValue(() => actionBadge.value.priceH);
|
|
105
|
+
const priceR = useDerivedValue(() => actionBadge.value.priceH / 2);
|
|
106
|
+
const priceTextX = useDerivedValue(() => actionBadge.value.priceTextX);
|
|
107
|
+
const priceText = useDerivedValue(() => actionBadge.value.priceText);
|
|
108
|
+
|
|
109
|
+
const textY = useDerivedValue(() => actionBadge.value.textY);
|
|
110
|
+
|
|
111
|
+
// X-axis time pill (opt-in). `timeBadge` may be omitted; default to hidden.
|
|
112
|
+
const tb = timeBadge;
|
|
113
|
+
const timeOpacity = useDerivedValue(() => (tb?.value.visible ? 1 : 0), [tb]);
|
|
114
|
+
const timeX = useDerivedValue(() => tb?.value.x ?? -400, [tb]);
|
|
115
|
+
const timeY = useDerivedValue(() => tb?.value.y ?? 0, [tb]);
|
|
116
|
+
const timeW = useDerivedValue(() => tb?.value.w ?? 0, [tb]);
|
|
117
|
+
const timeH = useDerivedValue(() => tb?.value.h ?? 0, [tb]);
|
|
118
|
+
const timeR = useDerivedValue(() => (tb?.value.h ?? 0) / 2, [tb]);
|
|
119
|
+
const timeTextX = useDerivedValue(() => tb?.value.textX ?? -400, [tb]);
|
|
120
|
+
const timeTextY = useDerivedValue(() => tb?.value.textY ?? 0, [tb]);
|
|
121
|
+
const timeText = useDerivedValue(() => tb?.value.timeText ?? "", [tb]);
|
|
122
|
+
|
|
123
|
+
const levelColor = lineColor ?? palette.crosshairLine;
|
|
124
|
+
const pillColor = background ?? palette.badgeBg;
|
|
125
|
+
const labelColor = iconColor ?? palette.badgeText;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Group opacity={opacity}>
|
|
129
|
+
<Line p1={hLeft} p2={hRight} color={levelColor} strokeWidth={1}>
|
|
130
|
+
<DashPathEffect intervals={[4, 4]} />
|
|
131
|
+
</Line>
|
|
132
|
+
<Line p1={vTop} p2={vBottom} color={levelColor} strokeWidth={1}>
|
|
133
|
+
<DashPathEffect intervals={[2, 4]} />
|
|
134
|
+
</Line>
|
|
135
|
+
|
|
136
|
+
{/* Circular icon button (the action). */}
|
|
137
|
+
<Group opacity={iconOpacity}>
|
|
138
|
+
<Circle cx={iconCx} cy={iconCy} r={iconR} color={pillColor} />
|
|
139
|
+
<SkiaText
|
|
140
|
+
x={iconGlyphX}
|
|
141
|
+
y={iconGlyphY}
|
|
142
|
+
text={icon}
|
|
143
|
+
font={font}
|
|
144
|
+
color={labelColor}
|
|
145
|
+
/>
|
|
146
|
+
</Group>
|
|
147
|
+
|
|
148
|
+
{/* Price pill (capsule). */}
|
|
149
|
+
<Group opacity={priceOpacity}>
|
|
150
|
+
<RoundedRect
|
|
151
|
+
x={priceX}
|
|
152
|
+
y={priceY}
|
|
153
|
+
width={priceW}
|
|
154
|
+
height={priceH}
|
|
155
|
+
r={priceR}
|
|
156
|
+
color={pillColor}
|
|
157
|
+
/>
|
|
158
|
+
<SkiaText
|
|
159
|
+
x={priceTextX}
|
|
160
|
+
y={textY}
|
|
161
|
+
text={priceText}
|
|
162
|
+
font={font}
|
|
163
|
+
color={labelColor}
|
|
164
|
+
/>
|
|
165
|
+
</Group>
|
|
166
|
+
|
|
167
|
+
{/* X-axis time pill (capsule) where the vertical line meets the axis. */}
|
|
168
|
+
<Group opacity={timeOpacity}>
|
|
169
|
+
<RoundedRect
|
|
170
|
+
x={timeX}
|
|
171
|
+
y={timeY}
|
|
172
|
+
width={timeW}
|
|
173
|
+
height={timeH}
|
|
174
|
+
r={timeR}
|
|
175
|
+
color={pillColor}
|
|
176
|
+
/>
|
|
177
|
+
<SkiaText
|
|
178
|
+
x={timeTextX}
|
|
179
|
+
y={timeTextY}
|
|
180
|
+
text={timeText}
|
|
181
|
+
font={font}
|
|
182
|
+
color={labelColor}
|
|
183
|
+
/>
|
|
184
|
+
</Group>
|
|
185
|
+
</Group>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DashPathEffect,
|
|
3
|
+
Group,
|
|
4
|
+
Path,
|
|
5
|
+
RoundedRect,
|
|
6
|
+
Text as SkiaText,
|
|
7
|
+
type SkFont,
|
|
8
|
+
} from "@shopify/react-native-skia";
|
|
9
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
10
|
+
|
|
11
|
+
import type { ResolvedThresholdLineConfig } from "../core/resolveConfig";
|
|
12
|
+
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
13
|
+
import type { ChartPadding } from "../draw/line";
|
|
14
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
15
|
+
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
16
|
+
import type { LiveChartPalette } from "../types";
|
|
17
|
+
|
|
18
|
+
/** Padding inside the marker badge pill, in px (mirrors `ReferenceLineOverlay`). */
|
|
19
|
+
const BADGE_PAD_X = 6;
|
|
20
|
+
const BADGE_PAD_Y = 3;
|
|
21
|
+
const BADGE_RADIUS = 5;
|
|
22
|
+
/** Inset from the canvas edge — small so the badge sits as flush to the edge as possible. */
|
|
23
|
+
const BADGE_EDGE_INSET = 2;
|
|
24
|
+
|
|
25
|
+
interface ThresholdMarkerProps {
|
|
26
|
+
engine: ChartEngineLayout;
|
|
27
|
+
padding: ChartPadding;
|
|
28
|
+
lineY: SharedValue<number>;
|
|
29
|
+
visible: SharedValue<boolean>;
|
|
30
|
+
value: SharedValue<number>;
|
|
31
|
+
cfg: ResolvedThresholdLineConfig;
|
|
32
|
+
palette: LiveChartPalette;
|
|
33
|
+
font: SkFont;
|
|
34
|
+
formatValue: (v: number) => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The dashed horizontal marker line at a live threshold value, tracking a
|
|
39
|
+
* `SharedValue` pixel-Y on the UI thread. Drawn **behind** the chart line so the
|
|
40
|
+
* data line stays the focus; the label badge ({@link ThresholdBadgeOverlay}) is
|
|
41
|
+
* a separate layer drawn on top. Hidden when the threshold is off-screen.
|
|
42
|
+
*/
|
|
43
|
+
export function ThresholdLineOverlay({
|
|
44
|
+
engine,
|
|
45
|
+
padding,
|
|
46
|
+
lineY,
|
|
47
|
+
visible,
|
|
48
|
+
cfg,
|
|
49
|
+
palette,
|
|
50
|
+
}: ThresholdMarkerProps) {
|
|
51
|
+
const lineColor = cfg.color ?? palette.refLine;
|
|
52
|
+
const { strokeWidth, intervals } = cfg;
|
|
53
|
+
|
|
54
|
+
const builder = usePathBuilder();
|
|
55
|
+
|
|
56
|
+
const linePath = useDerivedValue(() => {
|
|
57
|
+
const b = builder.value;
|
|
58
|
+
if (visible.get()) {
|
|
59
|
+
const y = lineY.get();
|
|
60
|
+
b.moveTo(padding.left, y);
|
|
61
|
+
b.lineTo(engine.canvasWidth.get() - padding.right, y);
|
|
62
|
+
}
|
|
63
|
+
return b.detach();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const opacity = useDerivedValue(() => (visible.get() ? 1 : 0));
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Group opacity={opacity}>
|
|
70
|
+
<Path
|
|
71
|
+
path={linePath}
|
|
72
|
+
style="stroke"
|
|
73
|
+
strokeWidth={strokeWidth}
|
|
74
|
+
color={lineColor}
|
|
75
|
+
>
|
|
76
|
+
<DashPathEffect intervals={intervals} />
|
|
77
|
+
</Path>
|
|
78
|
+
</Group>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The threshold's label as an opaque badge pill (rounded background + colored
|
|
84
|
+
* border, like `ReferenceLineOverlay`'s off-axis badge). Drawn **on top** of the
|
|
85
|
+
* chart line so the label is never painted over, and anchored hard to the plot's
|
|
86
|
+
* left edge by default (clear of the y-axis labels + live badge) or the right
|
|
87
|
+
* gutter. Renders nothing when there is no label and no `showValue`.
|
|
88
|
+
*/
|
|
89
|
+
export function ThresholdBadgeOverlay({
|
|
90
|
+
engine,
|
|
91
|
+
padding,
|
|
92
|
+
lineY,
|
|
93
|
+
visible,
|
|
94
|
+
value,
|
|
95
|
+
cfg,
|
|
96
|
+
palette,
|
|
97
|
+
font,
|
|
98
|
+
formatValue,
|
|
99
|
+
}: ThresholdMarkerProps) {
|
|
100
|
+
const { label, labelPosition, showValue } = cfg;
|
|
101
|
+
const badgeBackground = palette.tooltipBg;
|
|
102
|
+
const badgeBorderColor = cfg.color ?? palette.refLine;
|
|
103
|
+
const labelColor = cfg.color ?? palette.refLabel;
|
|
104
|
+
|
|
105
|
+
// Font metrics are stable → read once (getMetrics allocates + crosses JSI).
|
|
106
|
+
const { fontAscent, baselineOffset, pillH } = (() => {
|
|
107
|
+
const fm = font.getMetrics();
|
|
108
|
+
return {
|
|
109
|
+
fontAscent: fm.ascent,
|
|
110
|
+
baselineOffset: (fm.ascent + fm.descent) / 2,
|
|
111
|
+
pillH: fm.descent - fm.ascent + BADGE_PAD_Y * 2,
|
|
112
|
+
};
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
const opacity = useDerivedValue(() => (visible.get() ? 1 : 0));
|
|
116
|
+
|
|
117
|
+
const labelText = useDerivedValue(() => {
|
|
118
|
+
if (showValue) {
|
|
119
|
+
const v = formatValue(value.get());
|
|
120
|
+
return label ? `${label} ${v}` : v;
|
|
121
|
+
}
|
|
122
|
+
return label ?? "";
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const pillW = useDerivedValue(
|
|
126
|
+
() => measureFontTextWidth(font, labelText.get()) + BADGE_PAD_X * 2,
|
|
127
|
+
);
|
|
128
|
+
// Left: flush to the canvas edge. Right: flush to the right plot edge.
|
|
129
|
+
const pillX = useDerivedValue(() =>
|
|
130
|
+
labelPosition === "right"
|
|
131
|
+
? engine.canvasWidth.get() - padding.right - BADGE_EDGE_INSET - pillW.get()
|
|
132
|
+
: BADGE_EDGE_INSET,
|
|
133
|
+
);
|
|
134
|
+
const pillY = useDerivedValue(
|
|
135
|
+
() => lineY.get() - baselineOffset + fontAscent - BADGE_PAD_Y,
|
|
136
|
+
);
|
|
137
|
+
const labelX = useDerivedValue(() => pillX.get() + BADGE_PAD_X);
|
|
138
|
+
const labelY = useDerivedValue(() => lineY.get() - baselineOffset);
|
|
139
|
+
|
|
140
|
+
// No label and no value → nothing to draw (hooks above still run, in order).
|
|
141
|
+
if (label === undefined && !showValue) return null;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Group opacity={opacity}>
|
|
145
|
+
<RoundedRect
|
|
146
|
+
x={pillX}
|
|
147
|
+
y={pillY}
|
|
148
|
+
width={pillW}
|
|
149
|
+
height={pillH}
|
|
150
|
+
r={BADGE_RADIUS}
|
|
151
|
+
color={badgeBackground}
|
|
152
|
+
/>
|
|
153
|
+
<RoundedRect
|
|
154
|
+
x={pillX}
|
|
155
|
+
y={pillY}
|
|
156
|
+
width={pillW}
|
|
157
|
+
height={pillH}
|
|
158
|
+
r={BADGE_RADIUS}
|
|
159
|
+
color={badgeBorderColor}
|
|
160
|
+
style="stroke"
|
|
161
|
+
strokeWidth={1}
|
|
162
|
+
/>
|
|
163
|
+
<SkiaText
|
|
164
|
+
x={labelX}
|
|
165
|
+
y={labelY}
|
|
166
|
+
text={labelText}
|
|
167
|
+
font={font}
|
|
168
|
+
color={labelColor}
|
|
169
|
+
/>
|
|
170
|
+
</Group>
|
|
171
|
+
);
|
|
172
|
+
}
|