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.
Files changed (56) hide show
  1. package/README.md +5 -1
  2. package/dist/components/LiveChart.d.ts.map +1 -1
  3. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  4. package/dist/components/ReferenceLineOverlay.d.ts +10 -3
  5. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  6. package/dist/components/ScrubActionOverlay.d.ts +32 -0
  7. package/dist/components/ScrubActionOverlay.d.ts.map +1 -0
  8. package/dist/components/ThresholdLineOverlay.d.ts +34 -0
  9. package/dist/components/ThresholdLineOverlay.d.ts.map +1 -0
  10. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  11. package/dist/constants.d.ts +4 -0
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/core/resolveConfig.d.ts +55 -1
  14. package/dist/core/resolveConfig.d.ts.map +1 -1
  15. package/dist/hooks/crosshairShared.d.ts +110 -0
  16. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  17. package/dist/hooks/useChartPaths.d.ts +5 -1
  18. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  19. package/dist/hooks/useCrosshair.d.ts +14 -2
  20. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  21. package/dist/hooks/useMarkers.d.ts +2 -0
  22. package/dist/hooks/useMarkers.d.ts.map +1 -1
  23. package/dist/hooks/useReferenceLine.d.ts +36 -3
  24. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  25. package/dist/hooks/useReferenceLinePress.d.ts +23 -0
  26. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -0
  27. package/dist/hooks/useThreshold.d.ts +22 -0
  28. package/dist/hooks/useThreshold.d.ts.map +1 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/math/referenceLines.d.ts +23 -0
  32. package/dist/math/referenceLines.d.ts.map +1 -1
  33. package/dist/math/threshold.d.ts +25 -0
  34. package/dist/math/threshold.d.ts.map +1 -0
  35. package/dist/types.d.ts +190 -6
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/LiveChart.tsx +362 -61
  39. package/src/components/LiveChartSeries.tsx +39 -2
  40. package/src/components/ReferenceLineOverlay.tsx +121 -94
  41. package/src/components/ScrubActionOverlay.tsx +187 -0
  42. package/src/components/ThresholdLineOverlay.tsx +172 -0
  43. package/src/components/XAxisOverlay.tsx +2 -1
  44. package/src/constants.ts +5 -0
  45. package/src/core/resolveConfig.ts +102 -0
  46. package/src/hooks/crosshairShared.ts +293 -0
  47. package/src/hooks/useChartPaths.ts +25 -1
  48. package/src/hooks/useCrosshair.ts +340 -6
  49. package/src/hooks/useMarkers.ts +18 -2
  50. package/src/hooks/useReferenceLine.ts +259 -14
  51. package/src/hooks/useReferenceLinePress.ts +92 -0
  52. package/src/hooks/useThreshold.ts +63 -0
  53. package/src/index.ts +5 -0
  54. package/src/math/referenceLines.ts +55 -0
  55. package/src/math/threshold.ts +59 -0
  56. 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 { measureFontTextWidth } from "../lib/measureFontTextWidth";
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
- /** Padding inside the off-axis badge pill, in px. */
23
- const OFF_AXIS_PILL_PAD_X = 6;
24
- const OFF_AXIS_PILL_PAD_Y = 3;
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 off-axis badge for an off-screen Form-A value. Self-contained
31
- * so callers can `.map()` over a variable-length `referenceLines` array.
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
- // Off-axis target pill styling.
62
- const badgeBackground = line.badgeBackground ?? palette.tooltipBg;
63
- const badgeBorderColor = line.badgeBorderColor ?? color;
64
- const badgeRadius = line.badgeRadius ?? OFF_AXIS_PILL_RADIUS;
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 offBuilder = usePathBuilder();
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
- if (l.visible && !l.offAxis && !isBand) {
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
- const offLinePath = useDerivedValue(() => {
117
- const b = offBuilder.value;
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.offAxis) {
120
- // Start the connector just past the badge pill's right edge so the dashed
121
- // line runs out to the chart edge rather than behind the badge.
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.x1 + 6;
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
- layout.get().visible && !layout.get().offAxis && !isBand ? 1 : 0,
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 offOpacity = useDerivedValue(() =>
163
- layout.get().visible && layout.get().offAxis ? 1 : 0,
164
- );
165
- const labelOpacity = useDerivedValue(() => (layout.get().visible ? 1 : 0));
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 worklets (`getMetrics` allocates + crosses
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 + OFF_AXIS_PILL_PAD_Y * 2,
192
+ height: fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2,
179
193
  };
180
194
  })();
181
195
 
182
- // Off-axis badge pill a rounded background behind the chevron + label.
183
- const pillX = useDerivedValue(() => layout.get().x1 + 2);
196
+ // Pill rectposition/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 - OFF_AXIS_PILL_PAD_Y,
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
- {isBand && (
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
- <Group opacity={offOpacity}>
228
- <Path
229
- path={offLinePath}
230
- style="stroke"
231
- strokeWidth={strokeWidth}
232
- color={color}
233
- >
234
- <DashPathEffect intervals={intervals} />
235
- </Path>
236
- <RoundedRect
237
- x={pillX}
238
- y={pillY}
239
- width={pillW}
240
- height={pillH}
241
- r={badgeRadius}
242
- color={badgeBackground}
243
- />
244
- <RoundedRect
245
- x={pillX}
246
- y={pillY}
247
- width={pillW}
248
- height={pillH}
249
- r={badgeRadius}
250
- color={badgeBorderColor}
251
- style="stroke"
252
- strokeWidth={1}
253
- />
254
- <Path
255
- path={chevronPath}
256
- style="stroke"
257
- strokeWidth={1.5}
258
- color={color}
259
- strokeCap="round"
260
- strokeJoin="round"
261
- />
262
- </Group>
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
- <Group opacity={labelOpacity}>
265
- <SkiaText
266
- x={labelX}
267
- y={labelY}
268
- text={labelText}
269
- font={font}
270
- color={labelColor}
271
- />
272
- </Group>
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
+ }