react-native-livechart 4.17.0 → 4.19.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 (75) hide show
  1. package/dist/components/CrosshairOverlay.d.ts +3 -1
  2. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  3. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  4. package/dist/components/DotOverlay.d.ts +10 -2
  5. package/dist/components/DotOverlay.d.ts.map +1 -1
  6. package/dist/components/LiveChart.d.ts.map +1 -1
  7. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  8. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  9. package/dist/components/MultiSeriesDots.d.ts +5 -3
  10. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  11. package/dist/constants.d.ts +6 -0
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/core/resolveConfig.d.ts +20 -3
  14. package/dist/core/resolveConfig.d.ts.map +1 -1
  15. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  16. package/dist/draw/candle.d.ts +21 -0
  17. package/dist/draw/candle.d.ts.map +1 -1
  18. package/dist/draw/line.d.ts +9 -3
  19. package/dist/draw/line.d.ts.map +1 -1
  20. package/dist/hooks/crosshairShared.d.ts +8 -0
  21. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  22. package/dist/hooks/resolveChartLayout.d.ts +12 -1
  23. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  24. package/dist/hooks/useCandlePaths.d.ts +3 -1
  25. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  26. package/dist/hooks/useChartPaths.d.ts +3 -1
  27. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  28. package/dist/hooks/useCrosshair.d.ts +7 -1
  29. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  30. package/dist/hooks/useDegen.d.ts +1 -3
  31. package/dist/hooks/useDegen.d.ts.map +1 -1
  32. package/dist/hooks/useLiveDot.d.ts +8 -2
  33. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  34. package/dist/hooks/useMarkers.d.ts.map +1 -1
  35. package/dist/hooks/useMultiSeriesLinePaths.d.ts +3 -1
  36. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  37. package/dist/hooks/useSeriesIndicatorOpacity.d.ts +13 -0
  38. package/dist/hooks/useSeriesIndicatorOpacity.d.ts.map +1 -0
  39. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/math/markerCluster.d.ts +6 -0
  43. package/dist/math/markerCluster.d.ts.map +1 -1
  44. package/dist/math/simplify.d.ts +20 -0
  45. package/dist/math/simplify.d.ts.map +1 -0
  46. package/dist/types.d.ts +106 -17
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/components/CrosshairOverlay.tsx +14 -9
  50. package/src/components/CustomMarkerOverlay.tsx +5 -1
  51. package/src/components/DotOverlay.tsx +26 -3
  52. package/src/components/LiveChart.tsx +210 -55
  53. package/src/components/LiveChartSeries.tsx +28 -10
  54. package/src/components/MarkerOverlay.tsx +5 -1
  55. package/src/components/MultiSeriesDots.tsx +22 -3
  56. package/src/constants.ts +7 -0
  57. package/src/core/resolveConfig.ts +67 -13
  58. package/src/core/useLiveChartEngine.ts +6 -1
  59. package/src/draw/candle.ts +78 -0
  60. package/src/draw/line.ts +24 -10
  61. package/src/hooks/crosshairShared.ts +31 -0
  62. package/src/hooks/resolveChartLayout.ts +40 -16
  63. package/src/hooks/useCandlePaths.ts +9 -1
  64. package/src/hooks/useChartPaths.ts +37 -7
  65. package/src/hooks/useCrosshair.ts +29 -2
  66. package/src/hooks/useDegen.ts +2 -6
  67. package/src/hooks/useLiveDot.ts +28 -3
  68. package/src/hooks/useMarkers.ts +11 -3
  69. package/src/hooks/useMultiSeriesLinePaths.ts +31 -4
  70. package/src/hooks/useSeriesIndicatorOpacity.ts +56 -0
  71. package/src/hooks/useTradeStream.ts +6 -1
  72. package/src/index.ts +1 -0
  73. package/src/math/markerCluster.ts +15 -1
  74. package/src/math/simplify.ts +197 -0
  75. package/src/types.ts +107 -17
@@ -37,6 +37,7 @@ import {
37
37
  SCRUB_ACTIVATE_X_PX,
38
38
  SCRUB_FAIL_Y_PX,
39
39
  snapPrice,
40
+ snapScrubXToCandleCenter,
40
41
  startPlainScrub,
41
42
  type CrosshairState,
42
43
  updatePlainScrub,
@@ -157,6 +158,12 @@ export function useCrosshair(
157
158
  * Default `false`.
158
159
  */
159
160
  clampToPlot = false,
161
+ /**
162
+ * Candle mode: quantize the plain-scrub X to the hovered candle's center
163
+ * before it enters `scrubX` (`scrub.snapToCandles`). No-op in line mode.
164
+ * Default `false`.
165
+ */
166
+ snapToCandles = false,
160
167
  ): CrosshairState {
161
168
  const scrubX = useSharedValue(-1);
162
169
  const scrubActive = useSharedValue(false);
@@ -519,6 +526,26 @@ export function useCrosshair(
519
526
  ? SCRUB_ACTION_PRESS_HOLD_MS
520
527
  : panGestureDelay;
521
528
 
529
+ // `scrub.snapToCandles`: quantize the scrub X to the hovered candle's center
530
+ // before it enters `scrubX`, so the crosshair — and everything derived from
531
+ // it (time, tooltip, dim edge) — jumps candle-to-candle instead of gliding.
532
+ // Line mode and gaps between candles pass the raw X through.
533
+ /* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
534
+ const snapCandleX = (x: number): number => {
535
+ "worklet";
536
+ if (!snapToCandles || !isCandleMode || !candlesSV) return x;
537
+ return snapScrubXToCandleCenter(
538
+ x,
539
+ candlesSV.get(),
540
+ liveCandleSV?.get() ?? null,
541
+ candleWidthSecs,
542
+ padding,
543
+ engine.canvasWidth.get(),
544
+ engine.timestamp.get(),
545
+ engine.displayWindow.get(),
546
+ );
547
+ };
548
+
522
549
  let gesture = Gesture.Pan()
523
550
  .maxPointers(1)
524
551
  .shouldCancelWhenOutside(false)
@@ -618,7 +645,7 @@ export function useCrosshair(
618
645
  // guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
619
646
  if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
620
647
  startPlainScrub(
621
- e.x,
648
+ snapCandleX(e.x),
622
649
  padding,
623
650
  engine.canvasWidth.get(),
624
651
  clampPlainScrubToPlot,
@@ -653,7 +680,7 @@ export function useCrosshair(
653
680
  return;
654
681
  }
655
682
  updatePlainScrub(
656
- e.x,
683
+ snapCandleX(e.x),
657
684
  padding,
658
685
  engine.canvasWidth.get(),
659
686
  clampPlainScrubToPlot,
@@ -20,8 +20,6 @@ export function useDegen(
20
20
  momentumSV: SharedValue<Momentum>,
21
21
  cfg: ResolvedDegenConfig | null,
22
22
  onShake?: (payload: DegenShakePayload) => void,
23
- /** Static charts run no loops: register the frame callback without starting it. */
24
- isStatic = false,
25
23
  ): {
26
24
  pack: SharedValue<Float64Array<ArrayBuffer>>;
27
25
  packRevision: SharedValue<number>;
@@ -154,6 +152,8 @@ export function useDegen(
154
152
  speedMaxSV,
155
153
  ]);
156
154
 
155
+ // ChartWithDegen only mounts while the resolved effect is enabled; static
156
+ // charts force that config to null and unmount this callback entirely.
157
157
  useFrameCallback(
158
158
  /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
159
159
  "worklet";
@@ -250,10 +250,6 @@ export function useDegen(
250
250
  }
251
251
  prevActiveCount.set(activeCount);
252
252
  },
253
- // Static charts register the callback but never start it, so no degen frame
254
- // loop runs. Live charts keep the default (autostart) loop unchanged. The
255
- // controller also forces `cfg` to null in static, so degen is doubly off.
256
- !isStatic,
257
253
  );
258
254
 
259
255
  const shakeTransform = useDerivedValue(() => {
@@ -1,5 +1,8 @@
1
1
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
- import type { SingleEngineState } from "../core/useLiveChartEngine";
2
+ import type {
3
+ ChartEngineScroll,
4
+ SingleEngineState,
5
+ } from "../core/useLiveChartEngine";
3
6
  import type { ChartPadding } from "../draw/line";
4
7
 
5
8
  /**
@@ -10,17 +13,39 @@ import type { ChartPadding } from "../draw/line";
10
13
  * With `followViewEdge` + `edgeValue`, the dot (and the value line that shares
11
14
  * `dotY`) tracks the visible window's right-edge price while scrolled back, so it
12
15
  * stays aligned with a `followViewEdge` badge instead of marking the live value.
16
+ *
17
+ * With `trackWhileParked` (`dot.trackWhileParked`), the dot instead tracks the
18
+ * **true live point's x** while scrolled back / overscrolled (`viewEnd`
19
+ * frozen), and hides once the live point leaves the visible window.
20
+ * `followViewEdge` wins when both are set — an edge-pinned dot must stay
21
+ * aligned with its badge.
13
22
  */
14
23
  export function useLiveDot(
15
- engine: SingleEngineState,
24
+ engine: SingleEngineState & ChartEngineScroll,
16
25
  padding: ChartPadding,
17
26
  edgeValue?: SharedValue<number>,
18
27
  followViewEdge = false,
28
+ trackWhileParked = false,
19
29
  ) {
20
30
  const dotX = useDerivedValue(() => {
21
31
  const w = engine.canvasWidth.value;
22
32
  if (w === 0) return -100;
23
- return w - padding.right;
33
+ const right = w - padding.right;
34
+ // While parked (scrolled back / overscrolled) the live point is not at the
35
+ // plot edge — track its real x so a pan doesn't lose the dot, and hide it
36
+ // only once the point leaves the window.
37
+ if (trackWhileParked && !followViewEdge && engine.viewEnd.value != null) {
38
+ const data = engine.data.value;
39
+ const last = data[data.length - 1];
40
+ const win = engine.displayWindow.value;
41
+ const chartW = w - padding.left - padding.right;
42
+ if (!last || win <= 0 || chartW <= 0) return -100;
43
+ const x =
44
+ padding.left +
45
+ ((last.time - (engine.timestamp.value - win)) / win) * chartW;
46
+ return x < padding.left || x > right ? -100 : x;
47
+ }
48
+ return right;
24
49
  });
25
50
 
26
51
  const dotY = useDerivedValue(() => {
@@ -1,4 +1,4 @@
1
- import { useRef } from "react";
1
+ import { useEffect, useRef } from "react";
2
2
  import { Gesture } from "react-native-gesture-handler";
3
3
  import {
4
4
  useFrameCallback,
@@ -77,7 +77,7 @@ export function useMarkers(
77
77
  onMarkerPress?.(event);
78
78
  };
79
79
 
80
- useFrameCallback(
80
+ const markerFrameCallback = useFrameCallback(
81
81
  /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
82
82
  "worklet";
83
83
  const cache = cacheRef.current!;
@@ -102,11 +102,19 @@ export function useMarkers(
102
102
  lineData: lineData?.get(),
103
103
  lineLinear,
104
104
  });
105
- clusterMarkers(markers.get(), buf, { config: cluster });
105
+ clusterMarkers(markers.get(), buf, {
106
+ config: cluster,
107
+ minY: 0,
108
+ maxY: engine.canvasHeight.get(),
109
+ });
106
110
  projected.set(buf);
107
111
  },
108
112
  autostart,
109
113
  );
114
+ useEffect(() => {
115
+ // Reanimated does not reactively apply later `autostart` values.
116
+ markerFrameCallback.setActive(autostart);
117
+ }, [autostart, markerFrameCallback]);
110
118
 
111
119
  const tapGesture = Gesture.Tap().onEnd(
112
120
  /* istanbul ignore next -- gesture worklet runs on UI thread, not in Jest */ (
@@ -4,6 +4,10 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
4
  import { MAX_MULTI_SERIES } from "../constants";
5
5
  import type { MultiEngineState } from "../core/useLiveChartEngine";
6
6
  import { buildLinePoints, type ChartPadding } from "../draw/line";
7
+ import {
8
+ makeLineSimplifyScratch,
9
+ simplifyLinePoints,
10
+ } from "../math/simplify";
7
11
  import { drawSpline, makeSplineScratch } from "../math/spline";
8
12
  import { usePathBuilders } from "./usePathBuilder";
9
13
 
@@ -21,25 +25,31 @@ export function useMultiSeriesLinePaths(
21
25
  engine: MultiEngineState,
22
26
  padding: ChartPadding,
23
27
  activeSeriesCount = MAX_MULTI_SERIES,
28
+ /** Shared screen-space simplification tolerance; a series can override it. */
29
+ simplifyTolerance = 0,
24
30
  ): SharedValue<SkPath[]> {
25
31
  const builders = usePathBuilders(MAX_MULTI_SERIES);
26
32
 
27
33
  const poolRef = useRef<{
28
34
  empty: SkPath;
29
35
  ptsBuf: number[];
36
+ simplifiedPts: number[];
30
37
  pathsA: SkPath[];
31
38
  pathsB: SkPath[];
32
39
  pathsTick: boolean;
33
40
  scratch: ReturnType<typeof makeSplineScratch>;
41
+ simplifyScratch: ReturnType<typeof makeLineSimplifyScratch>;
34
42
  } | null>(null);
35
43
  if (poolRef.current === null) {
36
44
  poolRef.current = {
37
45
  empty: Skia.Path.Make(),
38
46
  ptsBuf: [] as number[],
47
+ simplifiedPts: [] as number[],
39
48
  pathsA: [] as SkPath[],
40
49
  pathsB: [] as SkPath[],
41
50
  pathsTick: false,
42
51
  scratch: makeSplineScratch(),
52
+ simplifyScratch: makeLineSimplifyScratch(),
43
53
  };
44
54
  }
45
55
 
@@ -52,19 +62,36 @@ export function useMultiSeriesLinePaths(
52
62
  const out = pool.pathsTick ? pool.pathsA : pool.pathsB;
53
63
  out.length = 0;
54
64
  const count = Math.min(activeSeriesCount, s.length, MAX_MULTI_SERIES);
65
+ const now = engine.timestamp.get();
66
+ const windowSecs = engine.displayWindow.get();
67
+ const canvasWidth = engine.canvasWidth.get();
68
+ const chartWidth = canvasWidth - padding.left - padding.right;
69
+ const absoluteXOffset =
70
+ windowSecs > 0 ? (now - windowSecs) * (chartWidth / windowSecs) : 0;
55
71
  for (let i = 0; i < count; i++) {
56
- const pts = buildLinePoints(
72
+ const rawPts = buildLinePoints(
57
73
  s[i].data,
58
74
  displays[i] ?? s[i].value,
59
- engine.timestamp.get(),
60
- engine.displayWindow.get(),
75
+ now,
76
+ windowSecs,
61
77
  engine.displayMin.get(),
62
78
  engine.displayMax.get(),
63
- engine.canvasWidth.get(),
79
+ canvasWidth,
64
80
  engine.canvasHeight.get(),
65
81
  padding,
66
82
  pool.ptsBuf,
67
83
  );
84
+ const seriesTolerance = s[i].simplify ?? simplifyTolerance;
85
+ const pts =
86
+ Number.isFinite(seriesTolerance) && seriesTolerance > 0
87
+ ? simplifyLinePoints(
88
+ rawPts,
89
+ seriesTolerance,
90
+ pool.simplifiedPts,
91
+ pool.simplifyScratch,
92
+ absoluteXOffset,
93
+ )
94
+ : rawPts;
68
95
  const n = pts.length >> 1;
69
96
  if (n >= 2) {
70
97
  const b = slots[i];
@@ -0,0 +1,56 @@
1
+ import {
2
+ Easing,
3
+ useAnimatedReaction,
4
+ useSharedValue,
5
+ withTiming,
6
+ type SharedValue,
7
+ } from "react-native-reanimated";
8
+
9
+ /** Short independent fade used when replacement data dims the plotted series. */
10
+ export const SERIES_INDICATOR_FADE_MS = 180;
11
+
12
+ /** Treat tiny animation residue at the top end as fully restored. */
13
+ const SERIES_OPACITY_VISIBLE_EPSILON = 0.001;
14
+
15
+ /** Worklet-safe target for live dots and their pulse rings. */
16
+ export function seriesIndicatorOpacityTarget(seriesOpacity: number): 0 | 1 {
17
+ "worklet";
18
+ return seriesOpacity >= 1 - SERIES_OPACITY_VISIBLE_EPSILON ? 1 : 0;
19
+ }
20
+
21
+ /**
22
+ * Fades live dots and pulse rings away while `seriesOpacity` dims replacement
23
+ * data, then restores them once the plotted series is fully opaque again.
24
+ * Keeping this as a separate opacity avoids compositing a translucent dot over
25
+ * its own line, where the stroke would otherwise shine through.
26
+ */
27
+ export function useSeriesIndicatorOpacity(
28
+ seriesOpacity: SharedValue<number>,
29
+ duration: number = SERIES_INDICATOR_FADE_MS,
30
+ ): SharedValue<number> {
31
+ // Fully visible is the common mount state. The reaction snaps an initially
32
+ // dimmed series to 0 on its first UI-thread run so it never flashes a dot.
33
+ const opacity = useSharedValue(1);
34
+
35
+ useAnimatedReaction(
36
+ () => seriesIndicatorOpacityTarget(seriesOpacity.get()),
37
+ (target, previous) => {
38
+ "worklet";
39
+ if (previous === null || previous === undefined || duration === 0) {
40
+ opacity.set(target);
41
+ return;
42
+ }
43
+ if (target !== previous) {
44
+ opacity.set(
45
+ withTiming(target, {
46
+ duration,
47
+ easing: Easing.inOut(Easing.ease),
48
+ }),
49
+ );
50
+ }
51
+ },
52
+ [seriesOpacity, duration],
53
+ );
54
+
55
+ return opacity;
56
+ }
@@ -1,3 +1,4 @@
1
+ import { useEffect } from "react";
1
2
  import {
2
3
  useFrameCallback,
3
4
  useSharedValue,
@@ -31,7 +32,7 @@ export function useTradeStream(
31
32
  const markers = useSharedValue<TradeMarker[]>([]);
32
33
  const state = useSharedValue<TradeStreamState>(createTradeStreamState());
33
34
 
34
- useFrameCallback(
35
+ const tradeFrameCallback = useFrameCallback(
35
36
  /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ (
36
37
  frameInfo,
37
38
  ) => {
@@ -62,6 +63,10 @@ export function useTradeStream(
62
63
  },
63
64
  autostart,
64
65
  );
66
+ useEffect(() => {
67
+ // Reanimated treats `autostart` as mount-time state; synchronize changes.
68
+ tradeFrameCallback.setActive(autostart);
69
+ }, [autostart, tradeFrameCallback]);
65
70
 
66
71
  return markers;
67
72
  }
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export type {
49
49
  ChartSegment,
50
50
  DegenOptions,
51
51
  DegenShakePayload,
52
+ DotGlowConfig,
52
53
  EmptyStateMetrics,
53
54
  FontConfig,
54
55
  FontWeight,
@@ -34,6 +34,12 @@ export interface ResolvedMarkerCluster {
34
34
 
35
35
  export interface ClusterMarkersOpts {
36
36
  config: ResolvedMarkerCluster;
37
+ /** Canvas-space y bounds (typically `0` / canvas height). When set, a
38
+ * `"vertical"` column is additionally capped where the next glyph would
39
+ * cross a bound, so a tall stack can't climb off the chart when its anchor
40
+ * is already near the edge. The base slot always draws. */
41
+ minY?: number;
42
+ maxY?: number;
37
43
  }
38
44
 
39
45
  /** Glyph box used when `marker.size` is unset — mirrors `markerAtlas.DEFAULT_ICON_SIZE`. */
@@ -138,7 +144,15 @@ function layoutBucket(
138
144
  // `maxVisible` caps the column: the oldest glyphs keep their slots and the
139
145
  // newest overflow is simply hidden.
140
146
  const dir = side === "below" ? 1 : -1;
141
- const cap = opts.config.maxVisible;
147
+ let cap = opts.config.maxVisible;
148
+ // Bounds clamp: slot j sits at `base + dir * j * step`; keep only slots
149
+ // whose glyph box stays inside [minY, maxY].
150
+ if (opts.minY !== undefined && opts.maxY !== undefined && step > 0) {
151
+ const base = anchorY + sideDy;
152
+ const room = dir === -1 ? base - h / 2 - opts.minY : opts.maxY - h / 2 - base;
153
+ const fit = 1 + Math.floor(room / step);
154
+ if (fit < cap) cap = Math.max(1, fit);
155
+ }
142
156
  for (let j = 0; j < count; j++) {
143
157
  const p = proj[idx[s + j]];
144
158
  if (j >= cap) {
@@ -0,0 +1,197 @@
1
+ /** Reusable buffers for {@link simplifyLinePoints}. */
2
+ export interface LineSimplifyScratch {
3
+ keep: number[];
4
+ stack: number[];
5
+ }
6
+
7
+ /** Allocate simplification scratch once, outside the per-frame worklet. */
8
+ export function makeLineSimplifyScratch(): LineSimplifyScratch {
9
+ return { keep: [], stack: [] };
10
+ }
11
+
12
+ /**
13
+ * Maximum number of points simplified as one Ramer-Douglas-Peucker range.
14
+ *
15
+ * The visible line is already bounded to roughly two points per horizontal
16
+ * pixel. Splitting that bounded input into overlapping ranges also bounds the
17
+ * simplifier's worst-case work: a pathological zig-zag cannot turn a frame into
18
+ * an O(n²) scan. Range endpoints are retained, so chunking can only keep a few
19
+ * extra points; it cannot erase additional structure.
20
+ */
21
+ const MAX_RDP_RANGE_POINTS = 64;
22
+
23
+ /**
24
+ * Width of each RDP range on the stable, absolute screen-pixel grid.
25
+ *
26
+ * Dense line input contains at most two representatives per pixel bucket. A
27
+ * 30 px range therefore stays within the 64-point work bound even when the
28
+ * decimator and simplifier grids have different phases. Fixed-width ranges
29
+ * also keep their membership stable as the viewport advances.
30
+ */
31
+ const RDP_RANGE_WIDTH_PX = 30;
32
+
33
+ function pointSegmentDistanceSq(
34
+ px: number,
35
+ py: number,
36
+ ax: number,
37
+ ay: number,
38
+ bx: number,
39
+ by: number,
40
+ ): number {
41
+ "worklet";
42
+ const dx = bx - ax;
43
+ const dy = by - ay;
44
+ const lengthSq = dx * dx + dy * dy;
45
+ if (lengthSq === 0) {
46
+ const ex = px - ax;
47
+ const ey = py - ay;
48
+ return ex * ex + ey * ey;
49
+ }
50
+
51
+ let t = ((px - ax) * dx + (py - ay) * dy) / lengthSq;
52
+ if (t < 0) t = 0;
53
+ else if (t > 1) t = 1;
54
+ const ex = px - (ax + t * dx);
55
+ const ey = py - (ay + t * dy);
56
+ return ex * ex + ey * ey;
57
+ }
58
+
59
+ function simplifyRdpGroup(
60
+ points: number[],
61
+ keep: number[],
62
+ stack: number[],
63
+ toleranceSq: number,
64
+ rangeStart: number,
65
+ rangeEnd: number,
66
+ ): void {
67
+ "worklet";
68
+ if (rangeStart === rangeEnd) {
69
+ keep[rangeStart] = 1;
70
+ return;
71
+ }
72
+
73
+ // Oversized fixed-X groups are split into overlapping subranges. The group
74
+ // contents are themselves stable, so this point-count guard cannot move
75
+ // until that absolute group reaches a viewport edge.
76
+ for (let chunkStart = rangeStart; chunkStart < rangeEnd; ) {
77
+ const chunkEnd = Math.min(
78
+ chunkStart + MAX_RDP_RANGE_POINTS - 1,
79
+ rangeEnd,
80
+ );
81
+ keep[chunkStart] = 1;
82
+ keep[chunkEnd] = 1;
83
+ stack.push(chunkStart, chunkEnd);
84
+
85
+ while (stack.length >= 2) {
86
+ const end = stack.pop()!;
87
+ const start = stack.pop()!;
88
+ if (end <= start + 1) continue;
89
+
90
+ const ax = points[start * 2];
91
+ const ay = points[start * 2 + 1];
92
+ const bx = points[end * 2];
93
+ const by = points[end * 2 + 1];
94
+ let furthest = -1;
95
+ let maxDistanceSq = toleranceSq;
96
+
97
+ for (let i = start + 1; i < end; i++) {
98
+ const distanceSq = pointSegmentDistanceSq(
99
+ points[i * 2],
100
+ points[i * 2 + 1],
101
+ ax,
102
+ ay,
103
+ bx,
104
+ by,
105
+ );
106
+ if (distanceSq > maxDistanceSq) {
107
+ maxDistanceSq = distanceSq;
108
+ furthest = i;
109
+ }
110
+ }
111
+
112
+ if (furthest >= 0) {
113
+ keep[furthest] = 1;
114
+ stack.push(start, furthest, furthest, end);
115
+ }
116
+ }
117
+
118
+ chunkStart = chunkEnd;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Simplify flat screen-space points (`[x0, y0, x1, y1, ...]`) with a bounded
124
+ * Ramer-Douglas-Peucker pass.
125
+ *
126
+ * `tolerance` is the maximum removable deviation in pixels. The first and last
127
+ * point, larger peaks/valleys, and the original point order are retained. Pass
128
+ * distinct reusable `out` and `scratch` buffers to avoid per-frame allocation.
129
+ * `absoluteXOffset` maps the viewport-relative X coordinates back onto a fixed
130
+ * screen-pixel grid; live chart callers should pass `winStart * xScale` so
131
+ * historical range membership does not change as the window advances.
132
+ */
133
+ export function simplifyLinePoints(
134
+ points: number[],
135
+ tolerance: number,
136
+ out: number[],
137
+ scratch: LineSimplifyScratch,
138
+ absoluteXOffset = 0,
139
+ ): number[] {
140
+ "worklet";
141
+ if (out === points) return points;
142
+
143
+ out.length = 0;
144
+ const count = points.length >> 1;
145
+ if (!(tolerance > 0) || !Number.isFinite(tolerance) || count <= 2) {
146
+ for (let i = 0; i < count * 2; i++) out.push(points[i]);
147
+ return out;
148
+ }
149
+
150
+ const keep = scratch.keep;
151
+ keep.length = count;
152
+ for (let i = 0; i < count; i++) keep[i] = 0;
153
+
154
+ const stack = scratch.stack;
155
+ stack.length = 0;
156
+ const toleranceSq = tolerance * tolerance;
157
+ const stableXOffset = Number.isFinite(absoluteXOffset) ? absoluteXOffset : 0;
158
+
159
+ // Partition on a fixed absolute-X grid rather than by the first visible
160
+ // point's array index. Dropping a point from the left edge then affects only
161
+ // that edge range; fully visible historical ranges keep identical endpoints
162
+ // and RDP choices while translating across the viewport.
163
+ let groupStart = 0;
164
+ let groupKey = Math.floor(
165
+ (points[0] + stableXOffset) / RDP_RANGE_WIDTH_PX,
166
+ );
167
+ for (let i = 1; i < count; i++) {
168
+ const nextKey = Math.floor(
169
+ (points[i * 2] + stableXOffset) / RDP_RANGE_WIDTH_PX,
170
+ );
171
+ if (nextKey !== groupKey) {
172
+ simplifyRdpGroup(
173
+ points,
174
+ keep,
175
+ stack,
176
+ toleranceSq,
177
+ groupStart,
178
+ i - 1,
179
+ );
180
+ groupStart = i;
181
+ groupKey = nextKey;
182
+ }
183
+ }
184
+ simplifyRdpGroup(
185
+ points,
186
+ keep,
187
+ stack,
188
+ toleranceSq,
189
+ groupStart,
190
+ count - 1,
191
+ );
192
+
193
+ for (let i = 0; i < count; i++) {
194
+ if (keep[i] === 1) out.push(points[i * 2], points[i * 2 + 1]);
195
+ }
196
+ return out;
197
+ }