react-native-livechart 3.3.0 → 3.4.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 (44) hide show
  1. package/README.md +4 -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/XAxisOverlay.d.ts.map +1 -1
  9. package/dist/constants.d.ts +4 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/core/resolveConfig.d.ts +23 -1
  12. package/dist/core/resolveConfig.d.ts.map +1 -1
  13. package/dist/hooks/crosshairShared.d.ts +110 -0
  14. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  15. package/dist/hooks/useCrosshair.d.ts +14 -2
  16. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  17. package/dist/hooks/useMarkers.d.ts +2 -0
  18. package/dist/hooks/useMarkers.d.ts.map +1 -1
  19. package/dist/hooks/useReferenceLine.d.ts +36 -3
  20. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  21. package/dist/hooks/useReferenceLinePress.d.ts +23 -0
  22. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -0
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/math/referenceLines.d.ts +23 -0
  26. package/dist/math/referenceLines.d.ts.map +1 -1
  27. package/dist/types.d.ts +131 -6
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/components/LiveChart.tsx +165 -21
  31. package/src/components/LiveChartSeries.tsx +39 -2
  32. package/src/components/ReferenceLineOverlay.tsx +121 -94
  33. package/src/components/ScrubActionOverlay.tsx +187 -0
  34. package/src/components/XAxisOverlay.tsx +2 -1
  35. package/src/constants.ts +5 -0
  36. package/src/core/resolveConfig.ts +40 -0
  37. package/src/hooks/crosshairShared.ts +293 -0
  38. package/src/hooks/useCrosshair.ts +340 -6
  39. package/src/hooks/useMarkers.ts +18 -2
  40. package/src/hooks/useReferenceLine.ts +259 -14
  41. package/src/hooks/useReferenceLinePress.ts +92 -0
  42. package/src/index.ts +3 -0
  43. package/src/math/referenceLines.ts +55 -0
  44. package/src/types.ts +134 -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { Group, Path, type SkFont } from "@shopify/react-native-skia";
2
2
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
3
+ import { X_AXIS_LABEL_OFFSET_Y } from "../constants";
3
4
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
4
5
  import type { ChartPadding } from "../draw/line";
5
6
  import type { XAxisEntry } from "../hooks/useXAxis";
@@ -10,7 +11,7 @@ import { AnimatedLabel } from "./AnimatedLabel";
10
11
 
11
12
  const MAX_X_LABELS = 10;
12
13
  const TICK_HEIGHT = 5;
13
- const LABEL_OFFSET_Y = 19;
14
+ const LABEL_OFFSET_Y = X_AXIS_LABEL_OFFSET_Y;
14
15
 
15
16
  export function XAxisOverlay({
16
17
  entries,
package/src/constants.ts CHANGED
@@ -62,6 +62,11 @@ export const BADGE_MARGIN_RIGHT = BADGE_METRICS_DEFAULTS.marginEdge;
62
62
  /** Gap between the live dot and the badge tail tip. */
63
63
  export const BADGE_DOT_GAP = BADGE_METRICS_DEFAULTS.dotGap;
64
64
 
65
+ /** Baseline offset (px) below the plot bottom where x-axis time labels sit.
66
+ * Shared so overlays that align to the time-axis row (e.g. the scrub-action
67
+ * time badge) match the {@link XAxisOverlay} labels. */
68
+ export const X_AXIS_LABEL_OFFSET_Y = 19;
69
+
65
70
  /** Maximum simultaneous series rendered (paths/dots slots). */
66
71
  export const MAX_MULTI_SERIES = 12;
67
72
 
@@ -17,6 +17,7 @@ import type {
17
17
  MultiSeriesDotConfig,
18
18
  PulseConfig,
19
19
  ReferenceLine,
20
+ ScrubActionConfig,
20
21
  ScrubConfig,
21
22
  SelectionDotConfig,
22
23
  SelectionDotProps,
@@ -90,6 +91,24 @@ export interface ResolvedScrubConfig {
90
91
  panGestureDelay: number;
91
92
  }
92
93
 
94
+ export interface ResolvedScrubActionConfig {
95
+ /** Glyph drawn in the action badge. */
96
+ icon: string;
97
+ /** undefined → palette.badgeBg */
98
+ background: string | undefined;
99
+ /** undefined → palette.badgeText */
100
+ iconColor: string | undefined;
101
+ /** undefined → palette.crosshairLine */
102
+ lineColor: string | undefined;
103
+ /** Show the price readout pill; false → icon-only badge. */
104
+ text: boolean;
105
+ /** Show the date/time pill where the vertical line meets the x-axis. */
106
+ timeBadge: boolean;
107
+ /** undefined → no rounding */
108
+ snap: number | undefined;
109
+ dismissOnTapOutside: boolean;
110
+ }
111
+
93
112
  export interface ResolvedGradientConfig {
94
113
  /** undefined → use palette.fillTop (theme-aware) at render time */
95
114
  topOpacity: number | undefined;
@@ -289,6 +308,27 @@ export function resolveScrub(
289
308
  return resolveToggle(prop, SCRUB_DEFAULTS, false);
290
309
  }
291
310
 
311
+ const SCRUB_ACTION_DEFAULTS: ResolvedScrubActionConfig = {
312
+ icon: "+",
313
+ background: undefined,
314
+ iconColor: undefined,
315
+ lineColor: undefined,
316
+ text: true,
317
+ timeBadge: false,
318
+ snap: undefined,
319
+ dismissOnTapOutside: false,
320
+ };
321
+
322
+ /**
323
+ * Resolves `scrubAction` prop to a fully-typed config or null (disabled).
324
+ * Opt-in: `false`/`undefined` → null; `true` → defaults; object → merged.
325
+ */
326
+ export function resolveScrubAction(
327
+ prop: boolean | ScrubActionConfig | undefined,
328
+ ): ResolvedScrubActionConfig | null {
329
+ return resolveToggle(prop, SCRUB_ACTION_DEFAULTS, false);
330
+ }
331
+
292
332
  const GRADIENT_DEFAULTS: ResolvedGradientConfig = {
293
333
  topOpacity: undefined,
294
334
  bottomOpacity: undefined,