react-native-livechart 3.2.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 (59) hide show
  1. package/README.md +6 -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/SegmentDividerOverlay.d.ts +19 -0
  9. package/dist/components/SegmentDividerOverlay.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 +23 -1
  14. package/dist/core/resolveConfig.d.ts.map +1 -1
  15. package/dist/core/resolveSegment.d.ts +42 -0
  16. package/dist/core/resolveSegment.d.ts.map +1 -0
  17. package/dist/hooks/crosshairShared.d.ts +110 -0
  18. package/dist/hooks/crosshairShared.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/useSegmentDivider.d.ts +28 -0
  28. package/dist/hooks/useSegmentDivider.d.ts.map +1 -0
  29. package/dist/hooks/useSegmentLineGradient.d.ts +20 -0
  30. package/dist/hooks/useSegmentLineGradient.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/math/referenceLines.d.ts +23 -0
  34. package/dist/math/referenceLines.d.ts.map +1 -1
  35. package/dist/math/segments.d.ts +46 -0
  36. package/dist/math/segments.d.ts.map +1 -0
  37. package/dist/types.d.ts +184 -6
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/LiveChart.tsx +242 -26
  41. package/src/components/LiveChartSeries.tsx +39 -2
  42. package/src/components/ReferenceLineOverlay.tsx +121 -94
  43. package/src/components/ScrubActionOverlay.tsx +187 -0
  44. package/src/components/SegmentDividerOverlay.tsx +84 -0
  45. package/src/components/XAxisOverlay.tsx +2 -1
  46. package/src/constants.ts +5 -0
  47. package/src/core/resolveConfig.ts +40 -0
  48. package/src/core/resolveSegment.ts +62 -0
  49. package/src/hooks/crosshairShared.ts +293 -0
  50. package/src/hooks/useCrosshair.ts +340 -6
  51. package/src/hooks/useMarkers.ts +18 -2
  52. package/src/hooks/useReferenceLine.ts +259 -14
  53. package/src/hooks/useReferenceLinePress.ts +92 -0
  54. package/src/hooks/useSegmentDivider.ts +84 -0
  55. package/src/hooks/useSegmentLineGradient.ts +57 -0
  56. package/src/index.ts +4 -0
  57. package/src/math/referenceLines.ts +55 -0
  58. package/src/math/segments.ts +173 -0
  59. package/src/types.ts +192 -6
@@ -7,6 +7,7 @@ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
7
7
  import {
8
8
  classifyReferenceEdge,
9
9
  referenceLineForm,
10
+ resolveReferenceBadge,
10
11
  } from "../math/referenceLines";
11
12
  import type { ReferenceLine } from "../types";
12
13
 
@@ -21,14 +22,30 @@ export interface ReferenceLineLayout {
21
22
  x1: number;
22
23
  /** Right x extent. */
23
24
  x2: number;
24
- /** Resolved label text (with appended value when `showValue`). */
25
+ /** Resolved label text (with appended value when `showValue`); "" when hidden. */
25
26
  label: string;
26
27
  labelX: number;
27
28
  labelY: number;
28
- /** True when a Form-A line is off-screen and its off-axis badge is showing. */
29
+ /** True when a Form-A value is off-screen (badge pinned to the edge + chevron). */
29
30
  offAxis: boolean;
30
31
  /** Off-axis chevron points up (target above range) vs down (below). */
31
32
  chevronUp: boolean;
33
+ /** True when the value renders as a pill badge (icon / text / chevron tag). */
34
+ badge: boolean;
35
+ /** Badge pill rect left x. */
36
+ pillX: number;
37
+ /** Badge pill rect width. */
38
+ pillW: number;
39
+ /** Badge icon glyph x (-1 when no icon). */
40
+ iconX: number;
41
+ /** Badge icon glyph (""/none). */
42
+ icon: string;
43
+ /** Off-axis chevron center x (-1 when no chevron). */
44
+ chevronCx: number;
45
+ /** Connector dashed-line start x (-1 when no connector). */
46
+ connStart: number;
47
+ /** Connector dashed-line end x. */
48
+ connEnd: number;
32
49
  }
33
50
 
34
51
  const INVISIBLE: ReferenceLineLayout = {
@@ -42,15 +59,207 @@ const INVISIBLE: ReferenceLineLayout = {
42
59
  labelY: -1,
43
60
  offAxis: false,
44
61
  chevronUp: false,
62
+ badge: false,
63
+ pillX: 0,
64
+ pillW: 0,
65
+ iconX: -1,
66
+ icon: "",
67
+ chevronCx: -1,
68
+ connStart: -1,
69
+ connEnd: -1,
45
70
  };
46
71
 
47
72
  /** Off-axis indicator inset from the nearest plot edge, in px. */
48
73
  const OFF_AXIS_EDGE_INSET = 12;
49
74
 
75
+ // Badge pill geometry (kept in sync with the overlay's pill height).
76
+ const BADGE_PAD_X = 6;
77
+ const BADGE_GAP = 4;
78
+ const BADGE_CHEV_W = 8;
79
+ /** Pill inset from the pinned plot edge, in px. */
80
+ const BADGE_EDGE_INSET = 2;
81
+ /** Vertical padding inside the pill (matches ReferenceLineOverlay's BADGE_PILL_PAD_Y). */
82
+ const BADGE_PILL_PAD_Y = 3;
83
+
84
+ interface BadgeGeometry {
85
+ pillX: number;
86
+ pillW: number;
87
+ iconX: number;
88
+ textX: number;
89
+ chevronCx: number;
90
+ connStart: number;
91
+ connEnd: number;
92
+ }
93
+
94
+ /**
95
+ * Lays out the badge pill at one plot edge: `[chevron?][icon?][text?]` left→right
96
+ * inside the pill, with a dashed connector running to the opposite edge.
97
+ */
98
+ function badgeGeometry(
99
+ position: "left" | "right",
100
+ icon: string,
101
+ text: string,
102
+ hasChevron: boolean,
103
+ x1: number,
104
+ x2: number,
105
+ font: SkFont,
106
+ ): BadgeGeometry {
107
+ "worklet";
108
+ // Measure the icon's visual bounds (not just width): `SkiaText` draws from the
109
+ // pen origin, so the glyph's left side-bearing (`bounds.x`) must be subtracted
110
+ // to land the ink in its slot. Without this an asymmetric glyph (a ▼ triangle,
111
+ // a "+"/"−") sits off-center in an icon-only pill. Mirrors the action-badge
112
+ // price-pill centering in computeActionBadgeLayout.
113
+ const iconBounds = icon ? font.measureText(icon) : null;
114
+ const iconW = iconBounds ? iconBounds.width : 0;
115
+ const textW = text ? measureFontTextWidth(font, text) : 0;
116
+
117
+ let contentW = 0;
118
+ let count = 0;
119
+ if (hasChevron) {
120
+ contentW += BADGE_CHEV_W;
121
+ count++;
122
+ }
123
+ if (icon) {
124
+ contentW += iconW;
125
+ count++;
126
+ }
127
+ if (text) {
128
+ contentW += textW;
129
+ count++;
130
+ }
131
+ contentW += BADGE_GAP * Math.max(0, count - 1);
132
+
133
+ const pillW = contentW + BADGE_PAD_X * 2;
134
+ const pillX =
135
+ position === "right"
136
+ ? x2 - BADGE_EDGE_INSET - pillW
137
+ : x1 + BADGE_EDGE_INSET;
138
+
139
+ let cursor = pillX + BADGE_PAD_X;
140
+ let chevronCx = -1;
141
+ if (hasChevron) {
142
+ chevronCx = cursor + BADGE_CHEV_W / 2;
143
+ cursor += BADGE_CHEV_W + BADGE_GAP;
144
+ }
145
+ let iconX = -1;
146
+ if (icon) {
147
+ // Pen origin compensates the left side-bearing so the ink starts at `cursor`
148
+ // (and is centered in an icon-only pill, whose width is iconW + 2*padX).
149
+ iconX = cursor - (iconBounds ? iconBounds.x : 0);
150
+ cursor += iconW + BADGE_GAP;
151
+ }
152
+ const textX = text ? cursor : -1;
153
+
154
+ let connStart = -1;
155
+ let connEnd = -1;
156
+ if (position === "right") {
157
+ const end = pillX - 4;
158
+ if (end > x1) {
159
+ connStart = x1;
160
+ connEnd = end;
161
+ }
162
+ } else {
163
+ const start = pillX + pillW + 4;
164
+ if (start < x2) {
165
+ connStart = start;
166
+ connEnd = x2;
167
+ }
168
+ }
169
+
170
+ return { pillX, pillW, iconX, textX, chevronCx, connStart, connEnd };
171
+ }
172
+
173
+ /** Resolved badge text for a Form-A line (in-range tag or off-axis pin). */
174
+ function referenceBadgeText(
175
+ line: ReferenceLine,
176
+ badge: ReturnType<typeof resolveReferenceBadge> & object,
177
+ v: number,
178
+ formatValue: (value: number) => string,
179
+ offAxis: boolean,
180
+ ): string {
181
+ "worklet";
182
+ if (!badge.showText) return "";
183
+ if (offAxis && badge.legacyText) {
184
+ const word = line.offAxisBadgeLabel ?? line.label;
185
+ return word ? `${word}: ${formatValue(v)}` : formatValue(v);
186
+ }
187
+ if (line.showValue && line.label) return `${line.label} ${formatValue(v)}`;
188
+ return line.label ?? formatValue(v);
189
+ }
190
+
191
+ /** Axis-aligned screen rect of a reference-line badge pill (the press hit-target). */
192
+ export interface ReferenceBadgeRect {
193
+ x: number;
194
+ y: number;
195
+ w: number;
196
+ h: number;
197
+ }
198
+
199
+ /**
200
+ * Screen-space pill rect for a Form-A reference line's **badge**, or `null` when
201
+ * the line has no pressable badge (no `badge`/`offAxisBadge`, not a value line, a
202
+ * legacy off-axis badge while in range, or the canvas isn't laid out / range is
203
+ * degenerate). Shares `badgeGeometry` + the edge classification with
204
+ * {@link useReferenceLine}, so the hit-target tracks the rendered pill exactly.
205
+ * Pure worklet — used by {@link useReferenceLinePress} for tap hit-testing.
206
+ */
207
+ export function computeReferenceBadgeRect(
208
+ line: ReferenceLine,
209
+ canvasWidth: number,
210
+ canvasHeight: number,
211
+ padding: ChartPadding,
212
+ dMin: number,
213
+ dMax: number,
214
+ font: SkFont,
215
+ formatValue: (value: number) => string,
216
+ ): ReferenceBadgeRect | null {
217
+ "worklet";
218
+ const badge = resolveReferenceBadge(line);
219
+ if (!badge) return null;
220
+ if (referenceLineForm(line) !== "line" || line.value === undefined) return null;
221
+ if (canvasWidth === 0 || canvasHeight === 0) return null;
222
+
223
+ const chartTop = padding.top;
224
+ const chartBottom = canvasHeight - padding.bottom;
225
+ const chartH = chartBottom - chartTop;
226
+ const valRange = dMax - dMin;
227
+ if (chartH <= 0 || valRange <= 0) return null;
228
+
229
+ const x1 = padding.left;
230
+ const x2 = canvasWidth - padding.right;
231
+ const v = line.value;
232
+ const edge = classifyReferenceEdge(v, dMin, dMax);
233
+
234
+ let y: number;
235
+ let hasChevron: boolean;
236
+ if (edge !== "in") {
237
+ // Off-screen: pinned to the nearest edge with a chevron.
238
+ hasChevron = true;
239
+ y =
240
+ edge === "above"
241
+ ? chartTop + OFF_AXIS_EDGE_INSET
242
+ : chartBottom - OFF_AXIS_EDGE_INSET;
243
+ } else {
244
+ // In range: a legacy off-axis-only badge shows no pill here → not pressable.
245
+ if (!badge.inRange) return null;
246
+ hasChevron = false;
247
+ y = chartTop + ((dMax - v) / valRange) * chartH;
248
+ }
249
+
250
+ const text = referenceBadgeText(line, badge, v, formatValue, edge !== "in");
251
+ const g = badgeGeometry(badge.position, badge.icon, text, hasChevron, x1, x2, font);
252
+ const fm = font.getMetrics();
253
+ const pillH = fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2;
254
+ // The pill is vertically centered on the line's y (see ReferenceLineOverlay).
255
+ return { x: g.pillX, y: y - pillH / 2, w: g.pillW, h: pillH };
256
+ }
257
+
50
258
  /**
51
259
  * Derives screen-space layout for a single reference line or band. Supports all
52
260
  * three `ReferenceLine` forms (horizontal line, horizontal value band, vertical
53
- * time band) plus the off-axis badge for an off-screen Form-A value.
261
+ * time band) plus the pill badge for a Form-A value (in-range tag + off-screen
262
+ * chevron pin).
54
263
  */
55
264
  export function useReferenceLine(
56
265
  engine: ChartEngineLayout,
@@ -60,6 +269,8 @@ export function useReferenceLine(
60
269
  font: SkFont,
61
270
  ): SharedValue<ReferenceLineLayout> {
62
271
  const form = line ? referenceLineForm(line) : "none";
272
+ // Badge presentation depends only on the (stable) line props — resolve once.
273
+ const badge = line ? resolveReferenceBadge(line) : null;
63
274
 
64
275
  return useDerivedValue<ReferenceLineLayout>(() => {
65
276
  if (!line || form === "none") return INVISIBLE;
@@ -103,6 +314,7 @@ export function useReferenceLine(
103
314
  ? bx2 - 4 - measureFontTextWidth(font, label)
104
315
  : bx1 + 4;
105
316
  return {
317
+ ...INVISIBLE,
106
318
  visible: true,
107
319
  y: chartTop,
108
320
  yBottom: chartBottom,
@@ -111,8 +323,6 @@ export function useReferenceLine(
111
323
  label,
112
324
  labelX,
113
325
  labelY: chartTop - fm.ascent + 2,
114
- offAxis: false,
115
- chevronUp: false,
116
326
  };
117
327
  }
118
328
 
@@ -145,6 +355,7 @@ export function useReferenceLine(
145
355
  ? x2 - 4 - measureFontTextWidth(font, label)
146
356
  : x1 + 4;
147
357
  return {
358
+ ...INVISIBLE,
148
359
  visible: true,
149
360
  y: yTop,
150
361
  yBottom: yBot,
@@ -153,39 +364,74 @@ export function useReferenceLine(
153
364
  label,
154
365
  labelX,
155
366
  labelY: yTop - fm.ascent + 2,
156
- offAxis: false,
157
- chevronUp: false,
158
367
  };
159
368
  }
160
369
 
161
- // ── Form A — horizontal line (with optional off-axis badge) ──────────────
370
+ // ── Form A — horizontal line (with optional pill badge) ──────────────────
162
371
  if (line.value === undefined) return INVISIBLE;
163
372
  const v = line.value;
164
373
  const edge = classifyReferenceEdge(v, dMin, dMax);
165
374
 
375
+ // Off-screen: pin the badge to the nearest edge with a chevron (when a badge
376
+ // is configured); otherwise cull the off-screen line (legacy behavior).
166
377
  if (edge !== "in") {
167
- if (!line.offAxisBadge) return INVISIBLE; // legacy: cull off-screen line
378
+ if (!badge) return INVISIBLE;
168
379
  const above = edge === "above";
169
380
  const clampedY = above
170
381
  ? chartTop + OFF_AXIS_EDGE_INSET
171
382
  : chartBottom - OFF_AXIS_EDGE_INSET;
172
- const word = line.offAxisBadgeLabel ?? line.label;
173
- const text = word ? `${word}: ${formatValue(v)}` : formatValue(v);
383
+ const text = referenceBadgeText(line, badge, v, formatValue, true);
384
+ const g = badgeGeometry(badge.position, badge.icon, text, true, x1, x2, font);
174
385
  return {
386
+ ...INVISIBLE,
175
387
  visible: true,
176
388
  y: clampedY,
177
389
  yBottom: clampedY,
178
390
  x1,
179
391
  x2,
180
392
  label: text,
181
- labelX: x1 + 16,
393
+ labelX: g.textX,
182
394
  labelY: clampedY - baselineOffset,
183
395
  offAxis: true,
184
396
  chevronUp: above,
397
+ badge: true,
398
+ pillX: g.pillX,
399
+ pillW: g.pillW,
400
+ iconX: g.iconX,
401
+ icon: badge.icon,
402
+ chevronCx: g.chevronCx,
403
+ connStart: g.connStart,
404
+ connEnd: g.connEnd,
185
405
  };
186
406
  }
187
407
 
188
408
  const y = toY(v);
409
+
410
+ // In-range pill badge (the `badge` config, not the legacy off-axis-only flag).
411
+ if (badge && badge.inRange) {
412
+ const text = referenceBadgeText(line, badge, v, formatValue, false);
413
+ const g = badgeGeometry(badge.position, badge.icon, text, false, x1, x2, font);
414
+ return {
415
+ ...INVISIBLE,
416
+ visible: true,
417
+ y,
418
+ yBottom: y,
419
+ x1,
420
+ x2,
421
+ label: text,
422
+ labelX: g.textX,
423
+ labelY: y - baselineOffset,
424
+ badge: true,
425
+ pillX: g.pillX,
426
+ pillW: g.pillW,
427
+ iconX: g.iconX,
428
+ icon: badge.icon,
429
+ connStart: g.connStart,
430
+ connEnd: g.connEnd,
431
+ };
432
+ }
433
+
434
+ // Plain gutter label (no badge, or a legacy off-axis badge that's in range).
189
435
  let label = line.label ?? formatValue(v);
190
436
  if (line.showValue && line.label) label = `${line.label} ${formatValue(v)}`;
191
437
 
@@ -197,6 +443,7 @@ export function useReferenceLine(
197
443
  else labelX = x2 + 4; // "right" — legacy gutter position
198
444
 
199
445
  return {
446
+ ...INVISIBLE,
200
447
  visible: true,
201
448
  y,
202
449
  yBottom: y,
@@ -205,8 +452,6 @@ export function useReferenceLine(
205
452
  label,
206
453
  labelX,
207
454
  labelY: y - baselineOffset,
208
- offAxis: false,
209
- chevronUp: false,
210
455
  };
211
456
  });
212
457
  }
@@ -0,0 +1,92 @@
1
+ import type { SkFont } from "@shopify/react-native-skia";
2
+ import { Gesture } from "react-native-gesture-handler";
3
+ import { useDerivedValue } from "react-native-reanimated";
4
+ import { scheduleOnRN } from "react-native-worklets";
5
+
6
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
7
+ import type { ChartPadding } from "../draw/line";
8
+ import type { ReferenceLine } from "../types";
9
+ import { pointInRect } from "./crosshairShared";
10
+ import {
11
+ computeReferenceBadgeRect,
12
+ type ReferenceBadgeRect,
13
+ } from "./useReferenceLine";
14
+
15
+ /**
16
+ * Builds a tap gesture that hit-tests a chart's reference-line **badges** (the
17
+ * pill tags for working orders / alerts / targets) and fires `onPress(line,
18
+ * index)` when one is tapped. Mirrors {@link useMarkers}: badge rects are
19
+ * projected to screen each frame on the UI thread (so they track the rescaling
20
+ * axis exactly like the rendered pills), and the gesture hit-tests against them.
21
+ *
22
+ * Also returns a `hitTest` worklet so a coexisting gesture (the scrub-action tap)
23
+ * can defer to a badge under the finger instead of acting on it. Only badge-tagged
24
+ * Form-A (value) lines are pressable; everything else projects to `null`.
25
+ */
26
+ export function useReferenceLinePress(
27
+ engine: ChartEngineLayout,
28
+ padding: ChartPadding,
29
+ lines: ReferenceLine[],
30
+ font: SkFont,
31
+ formatValue: (v: number) => string,
32
+ active: boolean,
33
+ /** Touch-target inflation around each pill, in px. */
34
+ hitSlop: number,
35
+ onPress?: (line: ReferenceLine, index: number) => void,
36
+ ): {
37
+ tapGesture: ReturnType<typeof Gesture.Tap>;
38
+ hitTest: (x: number, y: number) => boolean;
39
+ } {
40
+ // Per-frame badge hit-rects, index-aligned with `lines` (null = no pressable
41
+ // badge / off-screen / not laid out). Recomputed on the UI thread.
42
+ /* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
43
+ const rects = useDerivedValue<(ReferenceBadgeRect | null)[]>(() => {
44
+ if (!active || lines.length === 0) return [];
45
+ const w = engine.canvasWidth.value;
46
+ const h = engine.canvasHeight.value;
47
+ const dMin = engine.displayMin.value;
48
+ const dMax = engine.displayMax.value;
49
+ const out: (ReferenceBadgeRect | null)[] = [];
50
+ for (let i = 0; i < lines.length; i++) {
51
+ out.push(
52
+ computeReferenceBadgeRect(lines[i], w, h, padding, dMin, dMax, font, formatValue),
53
+ );
54
+ }
55
+ return out;
56
+ });
57
+
58
+ // Topmost badge under (x, y), or -1. Last drawn = last in the array = topmost.
59
+ /* istanbul ignore next -- worklet, runs on the UI thread */
60
+ const indexAt = (x: number, y: number): number => {
61
+ "worklet";
62
+ const rs = rects.value;
63
+ for (let i = rs.length - 1; i >= 0; i--) {
64
+ const r = rs[i];
65
+ if (r && pointInRect(x, y, r, hitSlop)) return i;
66
+ }
67
+ return -1;
68
+ };
69
+
70
+ /* istanbul ignore next -- worklet, runs on the UI thread */
71
+ const hitTest = (x: number, y: number): boolean => {
72
+ "worklet";
73
+ return indexAt(x, y) >= 0;
74
+ };
75
+
76
+ /* istanbul ignore next -- runs only via scheduleOnRN from the UI-thread tap */
77
+ function emitPress(index: number) {
78
+ onPress?.(lines[index], index);
79
+ }
80
+
81
+ /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
82
+ const tapGesture = Gesture.Tap()
83
+ .maxDuration(250)
84
+ .onEnd((e, success) => {
85
+ "worklet";
86
+ if (!active || !success) return;
87
+ const i = indexAt(e.x, e.y);
88
+ if (i >= 0) scheduleOnRN(emitPress, i);
89
+ });
90
+
91
+ return { tapGesture, hitTest };
92
+ }
@@ -0,0 +1,84 @@
1
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
+ import type { SkFont } from "@shopify/react-native-skia";
3
+
4
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
5
+ import type { ResolvedSegment } from "../core/resolveSegment";
6
+ import type { ChartPadding } from "../draw/line";
7
+ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
8
+ import { segmentBandX } from "../math/segments";
9
+
10
+ /** Screen-space geometry for one segment's divider + label, per frame. */
11
+ export interface SegmentDividerLayout {
12
+ visible: boolean;
13
+ /** Segment left edge (px) — the divider sits here. */
14
+ x1: number;
15
+ /** Segment right edge (px). */
16
+ x2: number;
17
+ /** Segment top edge (plot top). */
18
+ yTop: number;
19
+ /** Segment bottom edge (plot bottom). */
20
+ yBottom: number;
21
+ label: string;
22
+ labelX: number;
23
+ labelY: number;
24
+ }
25
+
26
+ const INVISIBLE: SegmentDividerLayout = {
27
+ visible: false,
28
+ x1: 0,
29
+ x2: 0,
30
+ yTop: 0,
31
+ yBottom: 0,
32
+ label: "",
33
+ labelX: 0,
34
+ labelY: -1,
35
+ };
36
+
37
+ /**
38
+ * Derives the screen-space geometry for a single segment's dashed divider and
39
+ * caption label each frame: the x-extent projected from its `[from, to]` time
40
+ * range, the plot's top/bottom edges, and the label anchor. The projection math
41
+ * lives in pure `segmentBandX` so it is unit-testable without Reanimated.
42
+ */
43
+ export function useSegmentDivider(
44
+ engine: ChartEngineLayout,
45
+ padding: ChartPadding,
46
+ segment: ResolvedSegment,
47
+ font: SkFont,
48
+ ): SharedValue<SegmentDividerLayout> {
49
+ return useDerivedValue<SegmentDividerLayout>(() => {
50
+ const w = engine.canvasWidth.value;
51
+ const h = engine.canvasHeight.value;
52
+ if (w === 0 || h === 0) return INVISIBLE;
53
+
54
+ const win = engine.displayWindow.value;
55
+ const winStart = engine.timestamp.value - win;
56
+ const x1 = padding.left;
57
+ const x2 = w - padding.right;
58
+
59
+ const band = segmentBandX(segment.from, segment.to, winStart, win, x1, x2);
60
+ if (!band.visible) return INVISIBLE;
61
+
62
+ const yTop = padding.top;
63
+ const yBottom = h - padding.bottom;
64
+
65
+ const label = segment.label ?? "";
66
+ const fm = font.getMetrics();
67
+ const labelX =
68
+ segment.labelPosition === "right"
69
+ ? band.bx2 - 4 - measureFontTextWidth(font, label)
70
+ : band.bx1 + 4;
71
+ const labelY = yTop - fm.ascent + 2;
72
+
73
+ return {
74
+ visible: true,
75
+ x1: band.bx1,
76
+ x2: band.bx2,
77
+ yTop,
78
+ yBottom,
79
+ label,
80
+ labelX,
81
+ labelY,
82
+ };
83
+ });
84
+ }
@@ -0,0 +1,57 @@
1
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
+ import { vec } from "@shopify/react-native-skia";
3
+
4
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
5
+ import type { ResolvedSegment } from "../core/resolveSegment";
6
+ import type { ChartPadding } from "../draw/line";
7
+ import { segmentLineGradient } from "../math/segments";
8
+
9
+ const FALLBACK_POSITIONS = [0, 1];
10
+
11
+ /**
12
+ * Derives the horizontal gradient applied to the line stroke for "scrub focus":
13
+ * the line is a flat `baseColor` at rest, and while scrubbing (or when a segment
14
+ * is `active`) the focused segment stays `baseColor` while the others are
15
+ * de-emphasized with their `lineColor`/`lineColors`. `colors`/`positions` come
16
+ * from the pure `segmentLineGradient`; `gradientEnd` spans the full canvas width
17
+ * so stop fractions and the gradient vector share one coordinate space. Apply it
18
+ * to the same `linePath` as the base line — one stroke, no seam, and the line
19
+ * itself carries the opacity (not a layer painted on top).
20
+ */
21
+ export function useSegmentLineGradient(
22
+ engine: ChartEngineLayout,
23
+ segments: ResolvedSegment[],
24
+ padding: ChartPadding,
25
+ baseColor: string,
26
+ scrubX: SharedValue<number>,
27
+ scrubActive: SharedValue<boolean>,
28
+ ) {
29
+ const data = useDerivedValue(() => {
30
+ const cw = engine.canvasWidth.value;
31
+ const win = engine.displayWindow.value;
32
+ const winStart = engine.timestamp.value - win;
33
+ return segmentLineGradient(
34
+ segments,
35
+ winStart,
36
+ win,
37
+ cw,
38
+ padding.left,
39
+ cw - padding.right,
40
+ baseColor,
41
+ scrubActive.value,
42
+ scrubX.value,
43
+ );
44
+ });
45
+
46
+ const colors = useDerivedValue(
47
+ () => data.value?.colors ?? [baseColor, baseColor],
48
+ );
49
+ const positions = useDerivedValue(
50
+ () => data.value?.positions ?? FALLBACK_POSITIONS,
51
+ );
52
+ const gradientEnd = useDerivedValue(() =>
53
+ vec(Math.max(1, engine.canvasWidth.value), 0),
54
+ );
55
+
56
+ return { colors, positions, gradientEnd };
57
+ }
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export type {
35
35
  CandleMetrics,
36
36
  CandlePoint,
37
37
  ChartInsets,
38
+ ChartSegment,
38
39
  DegenOptions,
39
40
  DegenShakePayload,
40
41
  EmptyStateMetrics,
@@ -63,6 +64,9 @@ export type {
63
64
  MultiSeriesDotConfig,
64
65
  PulseConfig,
65
66
  ReferenceLine,
67
+ ReferenceLineBadgeConfig,
68
+ ScrubActionConfig,
69
+ ScrubActionPoint,
66
70
  ScrubConfig,
67
71
  ScrubPoint,
68
72
  ScrubPointCore,
@@ -40,6 +40,61 @@ export function collectReferenceValues(lines: ReferenceLine[]): number[] {
40
40
  return out;
41
41
  }
42
42
 
43
+ /** Fully-resolved badge presentation for a Form-A reference line. */
44
+ export interface ResolvedReferenceBadge {
45
+ position: "left" | "right";
46
+ /** Leading glyph, or "" for none. */
47
+ icon: string;
48
+ /** Whether the text label is shown. */
49
+ showText: boolean;
50
+ /** undefined → theme tooltipBg at render time. */
51
+ background: string | undefined;
52
+ /** undefined → the line color at render time. */
53
+ borderColor: string | undefined;
54
+ radius: number;
55
+ /** Show the pill at the value when in range (`badge`) vs only off-screen (legacy `offAxisBadge`). */
56
+ inRange: boolean;
57
+ /** Legacy `"word: value"` text format (off-axis badge) vs the `label`/value format. */
58
+ legacyText: boolean;
59
+ }
60
+
61
+ /**
62
+ * Resolves a Form-A reference line's badge presentation from the new `badge`
63
+ * config or the legacy `offAxisBadge` flag (the `badge` config wins). Returns
64
+ * null when neither is set. Pure — driven only by the line props.
65
+ */
66
+ export function resolveReferenceBadge(
67
+ rl: ReferenceLine,
68
+ ): ResolvedReferenceBadge | null {
69
+ "worklet";
70
+ if (rl.badge) {
71
+ const cfg = rl.badge === true ? undefined : rl.badge;
72
+ return {
73
+ position: cfg?.position ?? "left",
74
+ icon: cfg?.icon ?? "",
75
+ showText: cfg?.text ?? true,
76
+ background: cfg?.background ?? rl.badgeBackground,
77
+ borderColor: cfg?.borderColor ?? rl.badgeBorderColor,
78
+ radius: cfg?.radius ?? rl.badgeRadius ?? 5,
79
+ inRange: true,
80
+ legacyText: false,
81
+ };
82
+ }
83
+ if (rl.offAxisBadge) {
84
+ return {
85
+ position: "left",
86
+ icon: "",
87
+ showText: true,
88
+ background: rl.badgeBackground,
89
+ borderColor: rl.badgeBorderColor,
90
+ radius: rl.badgeRadius ?? 5,
91
+ inRange: false,
92
+ legacyText: true,
93
+ };
94
+ }
95
+ return null;
96
+ }
97
+
43
98
  /**
44
99
  * Where a Y value sits relative to the visible plot range:
45
100
  * `"in"` (within [min, max]), `"above"` (greater than max), or `"below"`.