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
@@ -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,10 +17,13 @@ import type {
17
17
  MultiSeriesDotConfig,
18
18
  PulseConfig,
19
19
  ReferenceLine,
20
+ ScrubActionConfig,
20
21
  ScrubConfig,
21
22
  SelectionDotConfig,
22
23
  SelectionDotProps,
23
24
  SelectionDotRingConfig,
25
+ ThresholdConfig,
26
+ ThresholdLineConfig,
24
27
  TradeEvent,
25
28
  ValueLineConfig,
26
29
  XAxisConfig,
@@ -90,6 +93,24 @@ export interface ResolvedScrubConfig {
90
93
  panGestureDelay: number;
91
94
  }
92
95
 
96
+ export interface ResolvedScrubActionConfig {
97
+ /** Glyph drawn in the action badge. */
98
+ icon: string;
99
+ /** undefined → palette.badgeBg */
100
+ background: string | undefined;
101
+ /** undefined → palette.badgeText */
102
+ iconColor: string | undefined;
103
+ /** undefined → palette.crosshairLine */
104
+ lineColor: string | undefined;
105
+ /** Show the price readout pill; false → icon-only badge. */
106
+ text: boolean;
107
+ /** Show the date/time pill where the vertical line meets the x-axis. */
108
+ timeBadge: boolean;
109
+ /** undefined → no rounding */
110
+ snap: number | undefined;
111
+ dismissOnTapOutside: boolean;
112
+ }
113
+
93
114
  export interface ResolvedGradientConfig {
94
115
  /** undefined → use palette.fillTop (theme-aware) at render time */
95
116
  topOpacity: number | undefined;
@@ -205,6 +226,66 @@ export function resolveValueLine(
205
226
  return resolveToggle(prop, VALUE_LINE_DEFAULTS, false);
206
227
  }
207
228
 
229
+ export interface ResolvedThresholdLineConfig {
230
+ /** undefined → no label. */
231
+ label: string | undefined;
232
+ /** Label side; `"left"` sits inside the plot (clear of the y-axis gutter). */
233
+ labelPosition: "left" | "right";
234
+ /** undefined → use palette.refLine (line) / palette.refLabel (label) at render time. */
235
+ color: string | undefined;
236
+ intervals: [number, number];
237
+ strokeWidth: number;
238
+ showValue: boolean;
239
+ }
240
+
241
+ export interface ResolvedThresholdConfig {
242
+ /** The live split value (Y-axis units). Read on the UI thread each frame. */
243
+ value: SharedValue<number>;
244
+ /** undefined → use palette.candleUp (up-green) at render time. */
245
+ aboveColor: string | undefined;
246
+ /** undefined → use palette.candleDown (down-red) at render time. */
247
+ belowColor: string | undefined;
248
+ fill: boolean;
249
+ line: ResolvedThresholdLineConfig | null;
250
+ }
251
+
252
+ const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
253
+ label: undefined,
254
+ labelPosition: "left",
255
+ color: undefined,
256
+ intervals: [4, 4],
257
+ strokeWidth: 1,
258
+ showValue: false,
259
+ };
260
+
261
+ /**
262
+ * Resolves the `threshold.line` sub-prop to a config or null (no marker line).
263
+ * `true` → dashed defaults, object → merged, falsy/undefined → null.
264
+ */
265
+ export function resolveThresholdLine(
266
+ prop: boolean | ThresholdLineConfig | undefined,
267
+ ): ResolvedThresholdLineConfig | null {
268
+ return resolveToggle(prop, THRESHOLD_LINE_DEFAULTS, false);
269
+ }
270
+
271
+ /**
272
+ * Resolves the `threshold` prop to a fully-typed config or null (disabled).
273
+ * Presence-gated (like `referenceLines`/`markers`): the required `value`
274
+ * SharedValue means there is no boolean form — a config object enables it.
275
+ */
276
+ export function resolveThreshold(
277
+ prop: ThresholdConfig | undefined,
278
+ ): ResolvedThresholdConfig | null {
279
+ if (!prop) return null;
280
+ return {
281
+ value: prop.value,
282
+ aboveColor: prop.aboveColor,
283
+ belowColor: prop.belowColor,
284
+ fill: prop.fill ?? false,
285
+ line: resolveThresholdLine(prop.line),
286
+ };
287
+ }
288
+
208
289
  const BADGE_DEFAULTS: ResolvedBadgeConfig = {
209
290
  variant: "default",
210
291
  tail: true,
@@ -289,6 +370,27 @@ export function resolveScrub(
289
370
  return resolveToggle(prop, SCRUB_DEFAULTS, false);
290
371
  }
291
372
 
373
+ const SCRUB_ACTION_DEFAULTS: ResolvedScrubActionConfig = {
374
+ icon: "+",
375
+ background: undefined,
376
+ iconColor: undefined,
377
+ lineColor: undefined,
378
+ text: true,
379
+ timeBadge: false,
380
+ snap: undefined,
381
+ dismissOnTapOutside: false,
382
+ };
383
+
384
+ /**
385
+ * Resolves `scrubAction` prop to a fully-typed config or null (disabled).
386
+ * Opt-in: `false`/`undefined` → null; `true` → defaults; object → merged.
387
+ */
388
+ export function resolveScrubAction(
389
+ prop: boolean | ScrubActionConfig | undefined,
390
+ ): ResolvedScrubActionConfig | null {
391
+ return resolveToggle(prop, SCRUB_ACTION_DEFAULTS, false);
392
+ }
393
+
292
394
  const GRADIENT_DEFAULTS: ResolvedGradientConfig = {
293
395
  topOpacity: undefined,
294
396
  bottomOpacity: undefined,
@@ -58,6 +58,23 @@ export interface CrosshairState {
58
58
  * (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
59
59
  scrubDotY: SharedValue<number>;
60
60
  gesture: ReturnType<typeof Gesture.Pan>;
61
+
62
+ // ── Scrub-action ("order ticket") lock state — single-series `useCrosshair`
63
+ // only; undefined on the multi-series crosshair. ───────────────────────────
64
+ /** Whether a reticle is currently placed/locked. */
65
+ lockActive?: SharedValue<boolean>;
66
+ /** Locked reticle X in canvas px (-1 when none). */
67
+ lockX?: SharedValue<number>;
68
+ /** Locked reticle Y in canvas px (-1 when none). */
69
+ lockY?: SharedValue<number>;
70
+ /** Chosen price = value at the reticle Y (optionally snapped); null when none. */
71
+ lockPrice?: SharedValue<number | null>;
72
+ /** Right-gutter action-badge layout (also the tap hit rect). */
73
+ actionBadge?: SharedValue<ActionBadgeLayout>;
74
+ /** X-axis time-badge layout at the reticle (opt-in; hidden when off). */
75
+ timeBadge?: SharedValue<TimeBadgeLayout>;
76
+ /** Tap gesture that places/moves the reticle, presses the badge, or dismisses. */
77
+ tapGesture?: ReturnType<typeof Gesture.Tap>;
61
78
  }
62
79
 
63
80
  /**
@@ -83,6 +100,39 @@ export function computeScrubDotY(
83
100
  return padTop + ((displayMax - value) / valRange) * chartH;
84
101
  }
85
102
 
103
+ /**
104
+ * Inverse of {@link computeScrubDotY}: maps a canvas Y pixel back to a value (a
105
+ * free price *level*, used by scrub-action mode where the reticle Y is the chosen
106
+ * price, not the line value at the reticle X). Returns null when the canvas isn't
107
+ * laid out. A degenerate (zero) range collapses to the single value; a Y dragged
108
+ * past the plot edges clamps to `displayMin` / `displayMax` rather than
109
+ * extrapolating to a nonsensical price.
110
+ */
111
+ export function computeValueAtY(
112
+ y: number,
113
+ displayMin: number,
114
+ displayMax: number,
115
+ canvasHeight: number,
116
+ padTop: number,
117
+ padBottom: number,
118
+ ): number | null {
119
+ "worklet";
120
+ const chartH = canvasHeight - padTop - padBottom;
121
+ if (chartH <= 0) return null;
122
+ const valRange = displayMax - displayMin;
123
+ if (valRange === 0) return displayMin;
124
+ const clampedY = Math.min(padTop + chartH, Math.max(padTop, y));
125
+ const frac = (clampedY - padTop) / chartH; // 0 at top, 1 at bottom
126
+ return displayMax - frac * valRange; // top → displayMax, bottom → displayMin
127
+ }
128
+
129
+ /** Rounds `price` to the nearest `increment` (no-op when increment is falsy/≤0). */
130
+ export function snapPrice(price: number, increment?: number): number {
131
+ "worklet";
132
+ if (!increment || increment <= 0) return price;
133
+ return Math.round(price / increment) * increment;
134
+ }
135
+
86
136
  /**
87
137
  * Maps a scrub X position to a window timestamp.
88
138
  * Returns -1 when inactive or when the canvas is not yet laid out.
@@ -313,6 +363,249 @@ export function deriveScrubValueSingle(
313
363
  return interpolateAtTime(data, scrubTime);
314
364
  }
315
365
 
366
+ /**
367
+ * Right-gutter action-badge layout for scrub-action mode — two pills, vertically
368
+ * centered on the locked reticle Y and anchored to the canvas right edge:
369
+ *
370
+ * - a **circular** icon button (the action), and
371
+ * - a capsule **price pill** showing the formatted value,
372
+ *
373
+ * separated by {@link ACTION_BADGE_GAP}px (`[icon] [price]`). The price pill is
374
+ * right-anchored in the gutter with its text horizontally centered; a lone icon
375
+ * (icon-only) anchors to the plot's right edge, attached to the level line.
376
+ * Either element is omitted when its content is empty (icon-only, or no price).
377
+ * The union rect (`x/y/w/h`) is the single source of truth for the tap hit-test
378
+ * (see {@link pointInRect}). When not locked / not laid out / both empty, returns
379
+ * {@link HIDDEN_ACTION_BADGE}.
380
+ *
381
+ * The price text is sized to its actual visual bounds via `font.measureText`
382
+ * (run only while a reticle is shown) so the readout stays exactly centered —
383
+ * see the inline note on `priceTextX` below.
384
+ */
385
+ export interface ActionBadgeLayout {
386
+ /** Union hit rect (icon button + price pill). */
387
+ x: number;
388
+ y: number;
389
+ w: number;
390
+ h: number;
391
+ /** Whether the circular icon button is shown. */
392
+ hasIcon: boolean;
393
+ /** Icon button circle center + radius (the glyph is centered on this by the overlay). */
394
+ iconCx: number;
395
+ iconCy: number;
396
+ iconR: number;
397
+ /** Whether the price pill is shown. */
398
+ hasPrice: boolean;
399
+ /** Price pill rect. */
400
+ priceX: number;
401
+ priceY: number;
402
+ priceW: number;
403
+ priceH: number;
404
+ /** Price text left x. */
405
+ priceTextX: number;
406
+ /** Shared baseline y for the icon glyph + price text. */
407
+ textY: number;
408
+ /** Formatted price string drawn in the price pill. */
409
+ priceText: string;
410
+ /** Whether the badge is shown (locked + laid out + has content). */
411
+ visible: boolean;
412
+ }
413
+
414
+ export const HIDDEN_ACTION_BADGE: ActionBadgeLayout = {
415
+ x: -400,
416
+ y: 0,
417
+ w: 0,
418
+ h: 0,
419
+ hasIcon: false,
420
+ iconCx: -400,
421
+ iconCy: 0,
422
+ iconR: 0,
423
+ hasPrice: false,
424
+ priceX: -400,
425
+ priceY: 0,
426
+ priceW: 0,
427
+ priceH: 0,
428
+ priceTextX: -400,
429
+ textY: 0,
430
+ priceText: "",
431
+ visible: false,
432
+ };
433
+
434
+ /** Gap between the icon button and the price pill, in px. */
435
+ const ACTION_BADGE_GAP = 2;
436
+
437
+ export function computeActionBadgeLayout(
438
+ locked: boolean,
439
+ lockY: number,
440
+ priceText: string,
441
+ icon: string,
442
+ canvasWidth: number,
443
+ /** Plot's right edge (= canvasWidth - padding.right) — where the level line ends. */
444
+ plotRight: number,
445
+ font: SkFont,
446
+ marginEdge: number,
447
+ padX: number,
448
+ padY: number,
449
+ ): ActionBadgeLayout {
450
+ "worklet";
451
+ if (!locked || canvasWidth <= 0) return HIDDEN_ACTION_BADGE;
452
+
453
+ const hasIcon = icon.length > 0;
454
+ const hasPrice = priceText.length > 0;
455
+ if (!hasIcon && !hasPrice) return HIDDEN_ACTION_BADGE;
456
+
457
+ const pillH = font.getSize() + padY * 2;
458
+ const r = pillH / 2;
459
+ const top = lockY - pillH / 2;
460
+ const rightEdge = canvasWidth - marginEdge;
461
+
462
+ const fm = font.getMetrics();
463
+ const textY = lockY - (fm.ascent + fm.descent) / 2;
464
+
465
+ // Price pill (the readout) sits in the price-axis gutter, anchored to the edge.
466
+ let priceW = 0;
467
+ let priceX = rightEdge;
468
+ let priceTextX = rightEdge;
469
+ if (hasPrice) {
470
+ // Size the pill to the text's ACTUAL visual bounds (+ pad each side), then
471
+ // position the text origin so the *ink* — not the advance box — is centered:
472
+ // subtract the glyph's left side-bearing (`bounds.x`). `SkiaText` draws from
473
+ // the pen origin, so an origin at `padX` would leave the ink shifted by the
474
+ // bearing; this lands the visual center exactly on the pill center.
475
+ //
476
+ // This measures the actual string each frame rather than estimating from a
477
+ // per-char monospace width. A uniform width over-reserves for prices with
478
+ // narrow glyphs ("1", "."), which visibly de-centers the readout — and
479
+ // exact centering matters more here than shaving the per-frame `measureText`,
480
+ // which profiling put at ~4% of one core on the simulator (transient, only
481
+ // while a reticle is shown). See the price-pill centering tests.
482
+ const bounds = font.measureText(priceText);
483
+ priceW = bounds.width + padX * 2;
484
+ priceX = rightEdge - priceW;
485
+ priceTextX = priceX + padX - bounds.x;
486
+ }
487
+
488
+ // Circular icon button: left of the price pill, or — when alone (icon-only) —
489
+ // anchored to the plot's right edge so it stays attached to the level line.
490
+ let iconCx = plotRight;
491
+ if (hasIcon && hasPrice) {
492
+ iconCx = priceX - ACTION_BADGE_GAP - r;
493
+ }
494
+
495
+ const unionLeft = hasIcon ? iconCx - r : priceX;
496
+ const unionRight = hasPrice ? rightEdge : iconCx + r;
497
+
498
+ return {
499
+ x: unionLeft,
500
+ y: top,
501
+ w: unionRight - unionLeft,
502
+ h: pillH,
503
+ hasIcon,
504
+ iconCx,
505
+ iconCy: lockY,
506
+ iconR: r,
507
+ hasPrice,
508
+ priceX,
509
+ priceY: top,
510
+ priceW,
511
+ priceH: pillH,
512
+ priceTextX,
513
+ textY,
514
+ priceText,
515
+ visible: true,
516
+ };
517
+ }
518
+
519
+ /**
520
+ * X-axis time-badge layout for scrub-action mode — a small capsule pinned where
521
+ * the reticle's vertical line meets the time axis (the bottom gutter), centered
522
+ * on the reticle X and clamped to stay within the canvas. Display-only (no
523
+ * hit-test): an opt-in readout of the date/time under the reticle, formatted by
524
+ * the chart's `formatTime`. Hidden when not locked / not laid out / empty text.
525
+ */
526
+ export interface TimeBadgeLayout {
527
+ /** Pill rect. */
528
+ x: number;
529
+ y: number;
530
+ w: number;
531
+ h: number;
532
+ /** Time text left x (visual-bounds centered) + shared baseline y. */
533
+ textX: number;
534
+ textY: number;
535
+ /** Formatted time string drawn in the pill. */
536
+ timeText: string;
537
+ /** Whether the badge is shown (locked + laid out + has text). */
538
+ visible: boolean;
539
+ }
540
+
541
+ export const HIDDEN_TIME_BADGE: TimeBadgeLayout = {
542
+ x: -400,
543
+ y: 0,
544
+ w: 0,
545
+ h: 0,
546
+ textX: -400,
547
+ textY: 0,
548
+ timeText: "",
549
+ visible: false,
550
+ };
551
+
552
+ export function computeTimeBadgeLayout(
553
+ locked: boolean,
554
+ lockX: number,
555
+ timeText: string,
556
+ canvasWidth: number,
557
+ /** Baseline y where the x-axis time labels sit (= canvasHeight - padding.bottom
558
+ * + X_AXIS_LABEL_OFFSET_Y). The pill is vertically centered on this row so it
559
+ * aligns with the axis values it overlays. */
560
+ labelBaselineY: number,
561
+ font: SkFont,
562
+ padX: number,
563
+ padY: number,
564
+ marginEdge: number,
565
+ ): TimeBadgeLayout {
566
+ "worklet";
567
+ if (!locked || canvasWidth <= 0 || timeText.length === 0)
568
+ return HIDDEN_TIME_BADGE;
569
+
570
+ const pillH = font.getSize() + padY * 2;
571
+ // Snug to the measured text + centered ink (subtract the left side-bearing), the
572
+ // same approach the price pill uses.
573
+ const bounds = font.measureText(timeText);
574
+ const w = bounds.width + padX * 2;
575
+
576
+ // Center the pill under the reticle X, clamped into the canvas gutter so it
577
+ // never spills past either edge.
578
+ const minX = marginEdge;
579
+ const maxX = Math.max(minX, canvasWidth - marginEdge - w);
580
+ const x = Math.min(maxX, Math.max(minX, lockX - w / 2));
581
+
582
+ // Sit the text on the axis-label baseline (so it lines up with the time values)
583
+ // and center the pill vertically around that text.
584
+ const fm = font.getMetrics();
585
+ const textY = labelBaselineY;
586
+ const cy = labelBaselineY + (fm.ascent + fm.descent) / 2;
587
+ const top = cy - pillH / 2;
588
+ const textX = x + padX - bounds.x;
589
+
590
+ return { x, y: top, w, h: pillH, textX, textY, timeText, visible: true };
591
+ }
592
+
593
+ /** Axis-aligned point-in-rect test with an optional touch-target inflation. */
594
+ export function pointInRect(
595
+ px: number,
596
+ py: number,
597
+ rect: { x: number; y: number; w: number; h: number },
598
+ slop = 0,
599
+ ): boolean {
600
+ "worklet";
601
+ return (
602
+ px >= rect.x - slop &&
603
+ px <= rect.x + rect.w + slop &&
604
+ py >= rect.y - slop &&
605
+ py <= rect.y + rect.h + slop
606
+ );
607
+ }
608
+
316
609
  /** Single-series crosshair tooltip — extracted for tests. */
317
610
  export function deriveCrosshairTooltipSingle(
318
611
  scrubActive: boolean,
@@ -22,9 +22,13 @@ export function useChartPaths(
22
22
  engine: SingleEngineState,
23
23
  padding: ChartPadding,
24
24
  morphT?: SharedValue<number>,
25
+ /** When set, also build `thresholdFillPath` — the band between the line and this
26
+ * pixel-Y, closed at the threshold instead of the chart baseline. */
27
+ thresholdY?: SharedValue<number>,
25
28
  ) {
26
29
  const lineBuilder = usePathBuilder();
27
30
  const fillBuilder = usePathBuilder();
31
+ const thresholdFillBuilder = usePathBuilder();
28
32
 
29
33
  const cacheRef = useRef<{
30
34
  emptyPath: SkPath;
@@ -105,5 +109,25 @@ export function useChartPaths(
105
109
  return b.detach();
106
110
  });
107
111
 
108
- return { linePath, fillPath } as const;
112
+ // Threshold-anchored fill: the same spline, closed at `thresholdY` instead of the
113
+ // baseline, so the band lies between the line and the threshold (the profit/loss
114
+ // area). Painted with the hard-split vertical gradient, the part above the split
115
+ // shows the above-color and the part below shows the below-color.
116
+ const thresholdFillPath = useDerivedValue(() => {
117
+ const cache = cacheRef.current!;
118
+ if (!thresholdY) return cache.emptyPath;
119
+ const yT = thresholdY.get();
120
+ const pts = flatPts.get();
121
+ const n = pts.length >> 1;
122
+ if (n < 2 || !Number.isFinite(yT)) return cache.emptyPath;
123
+ const b = thresholdFillBuilder.value;
124
+ b.moveTo(pts[0], pts[1]);
125
+ drawSpline(b, pts, cache.scratch);
126
+ b.lineTo(pts[(n - 1) * 2], yT);
127
+ b.lineTo(pts[0], yT);
128
+ b.close();
129
+ return b.detach();
130
+ });
131
+
132
+ return { linePath, fillPath, thresholdFillPath } as const;
109
133
  }