react-native-livechart 4.18.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 (59) 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/DotOverlay.d.ts +4 -2
  4. package/dist/components/DotOverlay.d.ts.map +1 -1
  5. package/dist/components/LiveChart.d.ts.map +1 -1
  6. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  7. package/dist/components/MultiSeriesDots.d.ts +5 -3
  8. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  9. package/dist/constants.d.ts +6 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/core/resolveConfig.d.ts +17 -3
  12. package/dist/core/resolveConfig.d.ts.map +1 -1
  13. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  14. package/dist/draw/candle.d.ts +21 -0
  15. package/dist/draw/candle.d.ts.map +1 -1
  16. package/dist/draw/line.d.ts +7 -1
  17. package/dist/draw/line.d.ts.map +1 -1
  18. package/dist/hooks/resolveChartLayout.d.ts +8 -0
  19. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  20. package/dist/hooks/useCandlePaths.d.ts +3 -1
  21. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  22. package/dist/hooks/useChartPaths.d.ts +3 -1
  23. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  24. package/dist/hooks/useDegen.d.ts +1 -3
  25. package/dist/hooks/useDegen.d.ts.map +1 -1
  26. package/dist/hooks/useMarkers.d.ts.map +1 -1
  27. package/dist/hooks/useMultiSeriesLinePaths.d.ts +3 -1
  28. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  29. package/dist/hooks/useSeriesIndicatorOpacity.d.ts +13 -0
  30. package/dist/hooks/useSeriesIndicatorOpacity.d.ts.map +1 -0
  31. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/math/simplify.d.ts +20 -0
  35. package/dist/math/simplify.d.ts.map +1 -0
  36. package/dist/types.d.ts +77 -16
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/components/CrosshairOverlay.tsx +14 -9
  40. package/src/components/DotOverlay.tsx +17 -1
  41. package/src/components/LiveChart.tsx +194 -54
  42. package/src/components/LiveChartSeries.tsx +28 -10
  43. package/src/components/MultiSeriesDots.tsx +22 -3
  44. package/src/constants.ts +7 -0
  45. package/src/core/resolveConfig.ts +61 -13
  46. package/src/core/useLiveChartEngine.ts +6 -1
  47. package/src/draw/candle.ts +78 -0
  48. package/src/draw/line.ts +20 -7
  49. package/src/hooks/resolveChartLayout.ts +30 -18
  50. package/src/hooks/useCandlePaths.ts +9 -1
  51. package/src/hooks/useChartPaths.ts +37 -7
  52. package/src/hooks/useDegen.ts +2 -6
  53. package/src/hooks/useMarkers.ts +6 -2
  54. package/src/hooks/useMultiSeriesLinePaths.ts +31 -4
  55. package/src/hooks/useSeriesIndicatorOpacity.ts +56 -0
  56. package/src/hooks/useTradeStream.ts +6 -1
  57. package/src/index.ts +1 -0
  58. package/src/math/simplify.ts +197 -0
  59. package/src/types.ts +78 -16
@@ -18,6 +18,7 @@ import type {
18
18
  LoadingConfig,
19
19
  MarkerClusterConfig,
20
20
  DotConfig,
21
+ DotGlowConfig,
21
22
  DotRingConfig,
22
23
  MultiSeriesDotConfig,
23
24
  PulseConfig,
@@ -40,7 +41,6 @@ import type {
40
41
  YAxisConfig,
41
42
  ZoomConfig,
42
43
  } from "../types";
43
-
44
44
  import type { ComponentType, ReactElement } from "react";
45
45
  import type { SharedValue } from "react-native-reanimated";
46
46
  import type { ResolvedMarkerCluster } from "../math/markerCluster";
@@ -54,6 +54,7 @@ import {
54
54
  LOADING_WAVE_SPEED,
55
55
  MOTION_METRICS_DEFAULTS,
56
56
  RETURN_TO_LIVE_MS,
57
+ SCRUB_CANDLE_DIM_FADE_MS,
57
58
  } from "../constants";
58
59
 
59
60
  // ─── Resolved types (all fields required, no optionals) ──────────────────────
@@ -152,8 +153,12 @@ export interface ResolvedScrubConfig {
152
153
  tooltip: boolean;
153
154
  /** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
154
155
  seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
155
- /** Opacity of content right of the crosshair while scrubbing (dstOut fade). */
156
+ /** Content affected by dimOpacity while scrubbing. */
157
+ dimTarget: "future" | "otherCandles";
158
+ /** Opacity of the content selected by dimTarget while scrubbing. */
156
159
  dimOpacity: number;
160
+ /** Candle-focus dim transition duration in milliseconds. */
161
+ dimFadeMs: number;
157
162
  /** undefined → palette.crosshairLine */
158
163
  crosshairLineColor: string | undefined;
159
164
  /** Vertical crosshair line width in px. */
@@ -199,9 +204,7 @@ export interface ResolvedScrubConfig {
199
204
  export interface ResolvedPerSeriesTooltipConfig {
200
205
  alwaysShow: boolean;
201
206
  bucketSeconds: number | undefined;
202
- formatSeriesValue:
203
- | ((value: number, seriesId: string) => string)
204
- | undefined;
207
+ formatSeriesValue: ((value: number, seriesId: string) => string) | undefined;
205
208
  formatTimeRange: ((from: number, to: number) => string) | undefined;
206
209
  maxLabelChars: number;
207
210
  guideColor: string | undefined;
@@ -688,8 +691,7 @@ export function resolveAxisLabel(
688
691
  ): ResolvedAxisLabelConfig | null {
689
692
  const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
690
693
  if (!resolved) return null;
691
- const connectorProp =
692
- typeof prop === "object" ? prop.connector : undefined;
694
+ const connectorProp = typeof prop === "object" ? prop.connector : undefined;
693
695
  return {
694
696
  ...resolved,
695
697
  connector: resolveConnector(
@@ -716,7 +718,9 @@ export function resolveXAxis(
716
718
  const SCRUB_DEFAULTS: ResolvedScrubConfig = {
717
719
  tooltip: true,
718
720
  seriesTooltip: null,
721
+ dimTarget: "future",
719
722
  dimOpacity: 0.3,
723
+ dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS,
720
724
  crosshairLineColor: undefined,
721
725
  crosshairStrokeWidth: 1,
722
726
  crosshairOvershoot: 0,
@@ -770,11 +774,7 @@ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
770
774
  function resolvePerSeriesTooltip(
771
775
  prop: boolean | PerSeriesTooltipConfig | undefined,
772
776
  ): ResolvedPerSeriesTooltipConfig | null {
773
- const resolved = resolveToggle(
774
- prop,
775
- PER_SERIES_TOOLTIP_DEFAULTS,
776
- false,
777
- );
777
+ const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false);
778
778
  if (!resolved) return null;
779
779
  const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
780
780
  if (dash !== undefined) {
@@ -804,6 +804,19 @@ export function resolveScrub(
804
804
  const seriesTooltip =
805
805
  typeof prop === "object" ? prop.seriesTooltip : undefined;
806
806
  resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
807
+ // `resolveToggle` shallow-merges object props, so an explicitly undefined
808
+ // optional field replaces its default. Restore the public defaults before
809
+ // normalizing; otherwise the numeric clamps produce NaN and an undefined
810
+ // dimTarget selects neither candle scrub rendering path.
811
+ resolved.dimTarget = resolved.dimTarget ?? SCRUB_DEFAULTS.dimTarget;
812
+ resolved.dimOpacity = Math.max(
813
+ 0,
814
+ Math.min(1, resolved.dimOpacity ?? SCRUB_DEFAULTS.dimOpacity),
815
+ );
816
+ resolved.dimFadeMs = Math.max(
817
+ 0,
818
+ resolved.dimFadeMs ?? SCRUB_DEFAULTS.dimFadeMs,
819
+ );
807
820
  resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
808
821
  resolved.crosshairFadeDistance = Math.max(
809
822
  0,
@@ -1181,7 +1194,39 @@ export function resolveTradeStream(
1181
1194
 
1182
1195
  // ─── Dot (shared) ─────────────────────────────────────────────────────────────
1183
1196
 
1184
- /** Resolved halo ring. `color: undefined` means "use the theme `badgeOuterBg`". */
1197
+ /** Resolved static dot glow. `color: undefined` means "use the dot color". */
1198
+ export interface ResolvedDotGlowConfig {
1199
+ color: string | undefined;
1200
+ radius: number;
1201
+ blur: number;
1202
+ opacity: number;
1203
+ }
1204
+
1205
+ const DOT_GLOW_DEFAULTS: ResolvedDotGlowConfig = {
1206
+ color: undefined,
1207
+ radius: 7,
1208
+ blur: 5,
1209
+ opacity: 0.18,
1210
+ };
1211
+
1212
+ /** `undefined`/`false` → off; `true` → restrained defaults; object → merged. */
1213
+ export function resolveDotGlow(
1214
+ prop: boolean | DotGlowConfig | undefined,
1215
+ ): ResolvedDotGlowConfig | null {
1216
+ if (!prop) return null;
1217
+ const config = prop === true ? DOT_GLOW_DEFAULTS : prop;
1218
+ return {
1219
+ color: config.color,
1220
+ radius: Math.max(0, config.radius ?? DOT_GLOW_DEFAULTS.radius),
1221
+ blur: Math.max(0, config.blur ?? DOT_GLOW_DEFAULTS.blur),
1222
+ opacity: Math.max(
1223
+ 0,
1224
+ Math.min(1, config.opacity ?? DOT_GLOW_DEFAULTS.opacity),
1225
+ ),
1226
+ };
1227
+ }
1228
+
1229
+ /** Resolved backing ring. `color: undefined` means "use the theme `badgeOuterBg`". */
1185
1230
  export interface ResolvedDotRingConfig {
1186
1231
  color: string | undefined;
1187
1232
  width: number;
@@ -1203,6 +1248,7 @@ export function resolveDotRing(
1203
1248
  export interface ResolvedDotConfig {
1204
1249
  radius: number;
1205
1250
  ring: ResolvedDotRingConfig | null;
1251
+ glow: ResolvedDotGlowConfig | null;
1206
1252
  show: boolean;
1207
1253
  color: string | undefined;
1208
1254
  trackWhileParked: boolean;
@@ -1211,6 +1257,7 @@ export interface ResolvedDotConfig {
1211
1257
  const DOT_DEFAULTS: ResolvedDotConfig = {
1212
1258
  radius: 3.5,
1213
1259
  ring: RING_DEFAULTS,
1260
+ glow: null,
1214
1261
  show: true,
1215
1262
  color: undefined,
1216
1263
  trackWhileParked: false,
@@ -1231,6 +1278,7 @@ export function resolveDot(
1231
1278
  return {
1232
1279
  radius: prop.radius ?? DOT_DEFAULTS.radius,
1233
1280
  ring: resolveDotRing(prop.ring),
1281
+ glow: resolveDotGlow(prop.glow),
1234
1282
  show: prop.show ?? DOT_DEFAULTS.show,
1235
1283
  color: prop.color,
1236
1284
  trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked,
@@ -560,10 +560,15 @@ export function useLiveChartEngine(
560
560
 
561
561
  // `autostart=false` registers the frame callback without running it — the live
562
562
  // loop is fully inert in static mode (the invariant that makes this worth it).
563
- useFrameCallback((frameInfo) => {
563
+ const engineFrameCallback = useFrameCallback((frameInfo) => {
564
564
  "worklet";
565
565
  applyLiveChartEngineFrame(frameInfo, frameRefs, frameScratchRef.current!);
566
566
  }, !config.static);
567
+ useEffect(() => {
568
+ // Reanimated only seeds `isActive` from `autostart` on mount. Keep the
569
+ // imperative state aligned when a chart switches between static and live.
570
+ engineFrameCallback.setActive(!config.static);
571
+ }, [config.static, engineFrameCallback]);
567
572
 
568
573
  // When time-scroll is disabled while scrolled back, return the window to the
569
574
  // live edge. With a positive duration this glides: snapshot the frozen edge into
@@ -22,6 +22,36 @@ export interface CandleGeometry {
22
22
  wicks: CandleWick[];
23
23
  }
24
24
 
25
+ export interface CandleFocusClip {
26
+ x: number;
27
+ y: number;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ /** Stable off-canvas clip used when no candle is focused. */
33
+ export const HIDDEN_CANDLE_FOCUS_CLIP: CandleFocusClip = {
34
+ x: -1,
35
+ y: 0,
36
+ width: 0,
37
+ height: 0,
38
+ };
39
+
40
+ /**
41
+ * Keep the full-strength candle pass visible while a candle is selected and
42
+ * while the shared dimmed batch is still returning to full opacity. The latter
43
+ * prevents the selected candle from briefly dropping to the dim opacity when a
44
+ * scrub ends or moves into a gap.
45
+ */
46
+ export function computeCandleFocusPassOpacity(
47
+ scrubActive: boolean,
48
+ hasScrubCandle: boolean,
49
+ inactiveCandleOpacity: number,
50
+ ): number {
51
+ "worklet";
52
+ return (scrubActive && hasScrubCandle) || inactiveCandleOpacity < 1 ? 1 : 0;
53
+ }
54
+
25
55
  function candleTimeToX(
26
56
  t: number,
27
57
  padLeft: number,
@@ -44,6 +74,54 @@ function candleValueToY(
44
74
  return padTop + ((displayMax - v) / valRange) * chartH;
45
75
  }
46
76
 
77
+ /**
78
+ * Return the visible screen-space time slot occupied by one candle bucket.
79
+ * The focus renderer clips the existing batched candle paths to this strip,
80
+ * repainting only the selected body and wick without building another SkPath.
81
+ */
82
+ export function computeCandleFocusClip(
83
+ candle: CandlePoint | null,
84
+ padding: ChartPadding,
85
+ canvasW: number,
86
+ canvasH: number,
87
+ winStart: number,
88
+ windowSecs: number,
89
+ candleWidthSecs: number,
90
+ ): CandleFocusClip {
91
+ "worklet";
92
+ if (!candle || windowSecs <= 0 || candleWidthSecs <= 0) {
93
+ return HIDDEN_CANDLE_FOCUS_CLIP;
94
+ }
95
+
96
+ const chartLeft = padding.left;
97
+ const chartRight = canvasW - padding.right;
98
+ const chartTop = padding.top;
99
+ const chartBottom = canvasH - padding.bottom;
100
+ const chartW = chartRight - chartLeft;
101
+ const chartH = chartBottom - chartTop;
102
+ if (chartW <= 0 || chartH <= 0) return HIDDEN_CANDLE_FOCUS_CLIP;
103
+
104
+ const bucketLeft = candleTimeToX(
105
+ candle.time,
106
+ padding.left,
107
+ winStart,
108
+ windowSecs,
109
+ chartW,
110
+ );
111
+ const bucketRight = candleTimeToX(
112
+ candle.time + candleWidthSecs,
113
+ padding.left,
114
+ winStart,
115
+ windowSecs,
116
+ chartW,
117
+ );
118
+ const x = Math.max(chartLeft, bucketLeft);
119
+ const right = Math.min(chartRight, bucketRight);
120
+ if (right <= x) return HIDDEN_CANDLE_FOCUS_CLIP;
121
+
122
+ return { x, y: chartTop, width: right - x, height: chartH };
123
+ }
124
+
47
125
  /** One candle → bodies/wicks; top-level worklet (no nested closures). */
48
126
  function appendCandleShapes(
49
127
  c: CandlePoint,
package/src/draw/line.ts CHANGED
@@ -5,7 +5,7 @@ export {
5
5
  BADGE_MARGIN_RIGHT,
6
6
  BADGE_PILL_PAD_X,
7
7
  BADGE_PILL_PAD_Y,
8
- BADGE_TAIL_LEN
8
+ BADGE_TAIL_LEN,
9
9
  } from "../constants";
10
10
 
11
11
  export interface ChartPadding {
@@ -113,7 +113,8 @@ export function resolveAutoRight(
113
113
  showTail = true,
114
114
  badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
115
115
  ): number {
116
- if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
116
+ if (badge)
117
+ return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
117
118
  if (yAxis) return 44;
118
119
  return DEFAULT_PADDING.right;
119
120
  }
@@ -156,6 +157,15 @@ export function pulseRadialOutset(
156
157
  return Math.ceil(maxRadius + strokeWidth / 2);
157
158
  }
158
159
 
160
+ /**
161
+ * Conservative canvas footprint for a blurred live-dot glow. Two blur radii
162
+ * retain the visible low-opacity falloff without over-inflating chart gutters.
163
+ * Keep this in sync with the `Circle` + `BlurMask` rendering in the dot overlays.
164
+ */
165
+ export function dotGlowRadialOutset(radius: number, blur: number): number {
166
+ return Math.ceil(Math.max(0, radius) + Math.max(0, blur) * 2);
167
+ }
168
+
159
169
  /** Extra space beyond label width so the pulse ring does not touch glyphs. */
160
170
  const PULSE_Y_AXIS_LABEL_GAP = 8;
161
171
 
@@ -283,15 +293,18 @@ export function buildLinePoints(
283
293
  );
284
294
  }
285
295
  } else {
286
- // Min/max-per-pixel-column decimation: within each pixel column keep the
287
- // lowest- and highest-value samples (emitted in their original time order)
288
- // so the line's envelope and any volatility spikes survive at full vertical
289
- // fidelity while the point count stays bounded by the canvas width.
296
+ // Min/max-per-pixel-width decimation: anchor buckets to absolute time, not
297
+ // the moving viewport origin. Existing samples then keep the same bucket as
298
+ // `now` advances, so interior history translates without being reshaped;
299
+ // only the buckets entering or leaving the viewport can change. Within each
300
+ // bucket keep the lowest- and highest-value samples (emitted in their
301
+ // original time order) so the line's envelope and volatility spikes survive
302
+ // at full vertical fidelity while the point count stays bounded by width.
290
303
  let curCol = -2147483648;
291
304
  let minIdx = startIdx;
292
305
  let maxIdx = startIdx;
293
306
  for (let i = startIdx; i < endIdx; i++) {
294
- const col = ((data[i].time - winStart) * xScale) | 0;
307
+ const col = Math.floor(data[i].time * xScale);
295
308
  if (col !== curCol) {
296
309
  if (curCol !== -2147483648) {
297
310
  const a = minIdx <= maxIdx ? minIdx : maxIdx;
@@ -1,6 +1,7 @@
1
1
  import type { SkFont } from "@shopify/react-native-skia";
2
2
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
3
3
  import {
4
+ dotGlowRadialOutset,
4
5
  minPaddingRightForBadgeYAxisAlign,
5
6
  minPaddingRightForYAxisWithPulse,
6
7
  pulseRadialOutset,
@@ -46,6 +47,11 @@ export interface ChartLayoutConfig {
46
47
  * is not clipped. An explicit `insetsOverride` on a side wins over this floor.
47
48
  */
48
49
  pulse?: { maxRadius: number; strokeWidth: number } | null;
50
+ /**
51
+ * Static live-dot glow; floors auto-padding so its blurred falloff is not
52
+ * clipped. Explicit side insets still win.
53
+ */
54
+ dotGlow?: { radius: number; blur: number } | null;
49
55
  /**
50
56
  * When false and the badge uses the right gutter, omit the tail and
51
57
  * round-cap inset from the right padding.
@@ -120,24 +126,28 @@ export function resolveChartLayout(
120
126
  );
121
127
  }
122
128
 
129
+ const pulseOutset = config.pulse
130
+ ? pulseRadialOutset(config.pulse.maxRadius, config.pulse.strokeWidth)
131
+ : 0;
132
+ const glowOutset = config.dotGlow
133
+ ? dotGlowRadialOutset(config.dotGlow.radius, config.dotGlow.blur)
134
+ : 0;
135
+ const dotEffectOutset = Math.max(pulseOutset, glowOutset);
136
+
123
137
  // A right-gutter badge uses its pill layout instead of the bare centered label
124
- // column, so the bare-axis pulse/label floor must not override badge sizing.
125
- // The badge renders above the pulse; the general outlet floor below still
126
- // prevents canvas-edge clipping.
138
+ // column, so the bare-axis effect/label floor must not override badge sizing.
139
+ // The badge renders above the dot effects; the general outlet floor below
140
+ // still prevents canvas-edge clipping.
127
141
  if (
128
- config.pulse &&
142
+ dotEffectOutset > 0 &&
129
143
  config.yAxis &&
130
144
  !badgeUsesRightGutter &&
131
145
  config.insetsOverride?.right == null
132
146
  ) {
133
- const outlet = pulseRadialOutset(
134
- config.pulse.maxRadius,
135
- config.pulse.strokeWidth,
136
- );
137
147
  const labelW = measuredYAxisLabelWidth ?? 49;
138
148
  rightPad = Math.max(
139
149
  rightPad,
140
- minPaddingRightForYAxisWithPulse(outlet, labelW),
150
+ minPaddingRightForYAxisWithPulse(dotEffectOutset, labelW),
141
151
  );
142
152
  }
143
153
 
@@ -160,12 +170,8 @@ export function resolveChartLayout(
160
170
  );
161
171
 
162
172
  let padding: ChartPadding = { ...base, right: rightPad, left: leftPad };
163
- if (config.pulse) {
164
- const outlet = pulseRadialOutset(
165
- config.pulse.maxRadius,
166
- config.pulse.strokeWidth,
167
- );
168
- // The pulse ring needs this much room or it clips at the canvas edge, so it
173
+ if (dotEffectOutset > 0) {
174
+ // The pulse/glow needs this much room or it clips at the canvas edge, so it
169
175
  // acts as a floor on the *auto* padding. An explicitly-provided inset always
170
176
  // wins, though — the caller opts into any clipping that causes (issue #128).
171
177
  // Every other auto-padding already respects an explicit inset (see the
@@ -173,10 +179,16 @@ export function resolveChartLayout(
173
179
  const ins = config.insetsOverride;
174
180
  padding = {
175
181
  ...padding,
176
- right: ins?.right != null ? padding.right : Math.max(padding.right, outlet),
177
- top: ins?.top != null ? padding.top : Math.max(padding.top, outlet),
182
+ right:
183
+ ins?.right != null
184
+ ? padding.right
185
+ : Math.max(padding.right, dotEffectOutset),
186
+ top:
187
+ ins?.top != null ? padding.top : Math.max(padding.top, dotEffectOutset),
178
188
  bottom:
179
- ins?.bottom != null ? padding.bottom : Math.max(padding.bottom, outlet),
189
+ ins?.bottom != null
190
+ ? padding.bottom
191
+ : Math.max(padding.bottom, dotEffectOutset),
180
192
  };
181
193
  }
182
194
 
@@ -1,4 +1,5 @@
1
1
  import { Skia } from "@shopify/react-native-skia";
2
+ import { useEffect } from "react";
2
3
  import {
3
4
  useDerivedValue,
4
5
  useFrameCallback,
@@ -35,6 +36,8 @@ export function useCandleWidthLerp(
35
36
  candleLerpSpeed: number | undefined,
36
37
  /** Static charts run no loops: register without starting. */
37
38
  autostart: boolean,
39
+ /** Only advance the displayed width while candle mode is active. */
40
+ active: boolean,
38
41
  ): SharedValue<number> {
39
42
  const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
40
43
  const displayCandleWidth = useSharedValue(candleWidthSecs);
@@ -46,8 +49,9 @@ export function useCandleWidthLerp(
46
49
  () => candleLerpSpeed ?? CANDLE_WIDTH_LERP_SPEED,
47
50
  );
48
51
 
49
- useFrameCallback((frameInfo) => {
52
+ const widthFrameCallback = useFrameCallback((frameInfo) => {
50
53
  "worklet";
54
+ if (!active) return;
51
55
  const dt = frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS;
52
56
  displayCandleWidth.set(
53
57
  lerp(
@@ -58,6 +62,10 @@ export function useCandleWidthLerp(
58
62
  ),
59
63
  );
60
64
  }, autostart);
65
+ useEffect(() => {
66
+ // `autostart` is an initial seed in Reanimated, not a reactive switch.
67
+ widthFrameCallback.setActive(autostart);
68
+ }, [autostart, widthFrameCallback]);
61
69
 
62
70
  return displayCandleWidth;
63
71
  }
@@ -7,6 +7,10 @@ import type {
7
7
  SingleEngineState,
8
8
  } from "../core/useLiveChartEngine";
9
9
  import { buildLinePoints, type ChartPadding } from "../draw/line";
10
+ import {
11
+ makeLineSimplifyScratch,
12
+ simplifyLinePoints,
13
+ } from "../math/simplify";
10
14
  import { drawSpline, makeSplineScratch } from "../math/spline";
11
15
  import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
12
16
  import { blendPtsY, squigglifyPts } from "../math/squiggly";
@@ -52,6 +56,8 @@ export function useChartPaths(
52
56
  * `samples[]` (so band geometry matches the shader exactly). Takes precedence
53
57
  * over `thresholdY`, the constant (horizontal) case. */
54
58
  thresholdSamples?: SharedValue<number[]>,
59
+ /** Screen-space path simplification tolerance in pixels. `0` disables it. */
60
+ simplifyTolerance = 0,
55
61
  ) {
56
62
  const lineBuilder = usePathBuilder();
57
63
  const fillBuilder = usePathBuilder();
@@ -61,24 +67,28 @@ export function useChartPaths(
61
67
  emptyPath: SkPath;
62
68
  ptsA: number[];
63
69
  ptsB: number[];
70
+ rawPts: number[];
64
71
  ptsTick: boolean;
65
72
  squigglePts: number[];
66
73
  morphA: number[];
67
74
  morphB: number[];
68
75
  morphTick: boolean;
69
76
  scratch: ReturnType<typeof makeSplineScratch>;
77
+ simplifyScratch: ReturnType<typeof makeLineSimplifyScratch>;
70
78
  } | null>(null);
71
79
  if (cacheRef.current === null) {
72
80
  cacheRef.current = {
73
81
  emptyPath: Skia.Path.Make(),
74
82
  ptsA: [] as number[],
75
83
  ptsB: [] as number[],
84
+ rawPts: [] as number[],
76
85
  ptsTick: false,
77
86
  squigglePts: [] as number[],
78
87
  morphA: [] as number[],
79
88
  morphB: [] as number[],
80
89
  morphTick: false,
81
90
  scratch: makeSplineScratch(),
91
+ simplifyScratch: makeLineSimplifyScratch(),
82
92
  };
83
93
  }
84
94
 
@@ -94,32 +104,52 @@ export function useChartPaths(
94
104
  engine.edgeValue.get(),
95
105
  engine.viewEnd.get(),
96
106
  );
107
+ const simplify =
108
+ Number.isFinite(simplifyTolerance) && simplifyTolerance > 0
109
+ ? simplifyTolerance
110
+ : 0;
111
+ const now = engine.timestamp.get();
112
+ const windowSecs = engine.displayWindow.get();
113
+ const canvasWidth = engine.canvasWidth.get();
97
114
  const realPts = buildLinePoints(
98
115
  engine.data.get(),
99
116
  tipValue,
100
- engine.timestamp.get(),
101
- engine.displayWindow.get(),
117
+ now,
118
+ windowSecs,
102
119
  engine.displayMin.get(),
103
120
  engine.displayMax.get(),
104
- engine.canvasWidth.get(),
121
+ canvasWidth,
105
122
  engine.canvasHeight.get(),
106
123
  padding,
107
- buf,
124
+ simplify > 0 ? cache.rawPts : buf,
108
125
  // Only a live-following chart may extend the line to the right edge; a
109
126
  // parked (scrolled-back / overscrolled) window ends at its last real
110
127
  // point rather than fabricating a flat line into dataless space.
111
128
  engine.viewEnd.get() != null,
112
129
  );
130
+ const chartWidth = canvasWidth - padding.left - padding.right;
131
+ const absoluteXOffset =
132
+ windowSecs > 0 ? (now - windowSecs) * (chartWidth / windowSecs) : 0;
133
+ const renderPts =
134
+ simplify > 0
135
+ ? simplifyLinePoints(
136
+ realPts,
137
+ simplify,
138
+ buf,
139
+ cache.simplifyScratch,
140
+ absoluteXOffset,
141
+ )
142
+ : realPts;
113
143
 
114
144
  // Skip blending when fully revealed or no morphT provided
115
145
  const t = morphT?.get() ?? 1;
116
- if (t >= 1 || realPts.length === 0) return realPts;
146
+ if (t >= 1 || renderPts.length === 0) return renderPts;
117
147
 
118
148
  // Compute squiggly Y values at the same X positions as the real line
119
149
  const centerY =
120
150
  (engine.canvasHeight.get() - padding.bottom + padding.top) / 2;
121
151
  const squigglyPts = squigglifyPts(
122
- realPts,
152
+ renderPts,
123
153
  engine.timestamp.get(),
124
154
  centerY,
125
155
  squiggleAmplitude,
@@ -131,7 +161,7 @@ export function useChartPaths(
131
161
  cache.morphTick = !cache.morphTick;
132
162
  return blendPtsY(
133
163
  squigglyPts,
134
- realPts,
164
+ renderPts,
135
165
  t,
136
166
  padding,
137
167
  engine.canvasWidth.get(),
@@ -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,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!;
@@ -111,6 +111,10 @@ export function useMarkers(
111
111
  },
112
112
  autostart,
113
113
  );
114
+ useEffect(() => {
115
+ // Reanimated does not reactively apply later `autostart` values.
116
+ markerFrameCallback.setActive(autostart);
117
+ }, [autostart, markerFrameCallback]);
114
118
 
115
119
  const tapGesture = Gesture.Tap().onEnd(
116
120
  /* istanbul ignore next -- gesture worklet runs on UI thread, not in Jest */ (