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
@@ -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. */
@@ -192,14 +197,14 @@ export interface ResolvedScrubConfig {
192
197
  hideOverlaysOnScrub: boolean;
193
198
  /** Reject outside plain-scrub starts and clamp active scrub X to the plot. */
194
199
  clampToPlot: boolean;
200
+ /** Candle mode: quantize the scrub X to the hovered candle's center. */
201
+ snapToCandles: boolean;
195
202
  }
196
203
 
197
204
  export interface ResolvedPerSeriesTooltipConfig {
198
205
  alwaysShow: boolean;
199
206
  bucketSeconds: number | undefined;
200
- formatSeriesValue:
201
- | ((value: number, seriesId: string) => string)
202
- | undefined;
207
+ formatSeriesValue: ((value: number, seriesId: string) => string) | undefined;
203
208
  formatTimeRange: ((from: number, to: number) => string) | undefined;
204
209
  maxLabelChars: number;
205
210
  guideColor: string | undefined;
@@ -686,8 +691,7 @@ export function resolveAxisLabel(
686
691
  ): ResolvedAxisLabelConfig | null {
687
692
  const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
688
693
  if (!resolved) return null;
689
- const connectorProp =
690
- typeof prop === "object" ? prop.connector : undefined;
694
+ const connectorProp = typeof prop === "object" ? prop.connector : undefined;
691
695
  return {
692
696
  ...resolved,
693
697
  connector: resolveConnector(
@@ -714,7 +718,9 @@ export function resolveXAxis(
714
718
  const SCRUB_DEFAULTS: ResolvedScrubConfig = {
715
719
  tooltip: true,
716
720
  seriesTooltip: null,
721
+ dimTarget: "future",
717
722
  dimOpacity: 0.3,
723
+ dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS,
718
724
  crosshairLineColor: undefined,
719
725
  crosshairStrokeWidth: 1,
720
726
  crosshairOvershoot: 0,
@@ -734,6 +740,7 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
734
740
  panGestureDelay: 0,
735
741
  hideOverlaysOnScrub: false,
736
742
  clampToPlot: false,
743
+ snapToCandles: false,
737
744
  };
738
745
 
739
746
  const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
@@ -767,11 +774,7 @@ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
767
774
  function resolvePerSeriesTooltip(
768
775
  prop: boolean | PerSeriesTooltipConfig | undefined,
769
776
  ): ResolvedPerSeriesTooltipConfig | null {
770
- const resolved = resolveToggle(
771
- prop,
772
- PER_SERIES_TOOLTIP_DEFAULTS,
773
- false,
774
- );
777
+ const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false);
775
778
  if (!resolved) return null;
776
779
  const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
777
780
  if (dash !== undefined) {
@@ -801,6 +804,19 @@ export function resolveScrub(
801
804
  const seriesTooltip =
802
805
  typeof prop === "object" ? prop.seriesTooltip : undefined;
803
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
+ );
804
820
  resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
805
821
  resolved.crosshairFadeDistance = Math.max(
806
822
  0,
@@ -1178,7 +1194,39 @@ export function resolveTradeStream(
1178
1194
 
1179
1195
  // ─── Dot (shared) ─────────────────────────────────────────────────────────────
1180
1196
 
1181
- /** 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`". */
1182
1230
  export interface ResolvedDotRingConfig {
1183
1231
  color: string | undefined;
1184
1232
  width: number;
@@ -1200,15 +1248,19 @@ export function resolveDotRing(
1200
1248
  export interface ResolvedDotConfig {
1201
1249
  radius: number;
1202
1250
  ring: ResolvedDotRingConfig | null;
1251
+ glow: ResolvedDotGlowConfig | null;
1203
1252
  show: boolean;
1204
1253
  color: string | undefined;
1254
+ trackWhileParked: boolean;
1205
1255
  }
1206
1256
 
1207
1257
  const DOT_DEFAULTS: ResolvedDotConfig = {
1208
1258
  radius: 3.5,
1209
1259
  ring: RING_DEFAULTS,
1260
+ glow: null,
1210
1261
  show: true,
1211
1262
  color: undefined,
1263
+ trackWhileParked: false,
1212
1264
  };
1213
1265
 
1214
1266
  /**
@@ -1226,8 +1278,10 @@ export function resolveDot(
1226
1278
  return {
1227
1279
  radius: prop.radius ?? DOT_DEFAULTS.radius,
1228
1280
  ring: resolveDotRing(prop.ring),
1281
+ glow: resolveDotGlow(prop.glow),
1229
1282
  show: prop.show ?? DOT_DEFAULTS.show,
1230
1283
  color: prop.color,
1284
+ trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked,
1231
1285
  };
1232
1286
  }
1233
1287
 
@@ -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 {
@@ -27,8 +27,8 @@ export const DEFAULT_PADDING: ChartPadding = {
27
27
  * The pill body starts `tl` px to the right of the gutter left edge (= dot x),
28
28
  * so the tail spans the gap between the dot and the pill body.
29
29
  *
30
- * When `showTail` is false the tail spike is omitted and only the round cap
31
- * radius is returned, letting callers shrink the right gutter.
30
+ * When `showTail` is false there is no tail geometry at all, so the pill body
31
+ * starts flush at the gutter left edge (+ dotGap) and no cap inset is reserved.
32
32
  */
33
33
  export function badgeTailAndCap(
34
34
  fontSize: number,
@@ -36,8 +36,9 @@ export function badgeTailAndCap(
36
36
  badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
37
37
  ): number {
38
38
  "worklet";
39
+ if (!showTail) return 0;
39
40
  const pillH = fontSize + badge.padY * 2;
40
- return (showTail ? badge.tailLength : 0) + pillH / 2;
41
+ return badge.tailLength + pillH / 2;
41
42
  }
42
43
 
43
44
  /**
@@ -112,7 +113,8 @@ export function resolveAutoRight(
112
113
  showTail = true,
113
114
  badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
114
115
  ): number {
115
- if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
116
+ if (badge)
117
+ return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
116
118
  if (yAxis) return 44;
117
119
  return DEFAULT_PADDING.right;
118
120
  }
@@ -155,6 +157,15 @@ export function pulseRadialOutset(
155
157
  return Math.ceil(maxRadius + strokeWidth / 2);
156
158
  }
157
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
+
158
169
  /** Extra space beyond label width so the pulse ring does not touch glyphs. */
159
170
  const PULSE_Y_AXIS_LABEL_GAP = 8;
160
171
 
@@ -282,15 +293,18 @@ export function buildLinePoints(
282
293
  );
283
294
  }
284
295
  } else {
285
- // Min/max-per-pixel-column decimation: within each pixel column keep the
286
- // lowest- and highest-value samples (emitted in their original time order)
287
- // so the line's envelope and any volatility spikes survive at full vertical
288
- // 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.
289
303
  let curCol = -2147483648;
290
304
  let minIdx = startIdx;
291
305
  let maxIdx = startIdx;
292
306
  for (let i = startIdx; i < endIdx; i++) {
293
- const col = ((data[i].time - winStart) * xScale) | 0;
307
+ const col = Math.floor(data[i].time * xScale);
294
308
  if (col !== curCol) {
295
309
  if (curCol !== -2147483648) {
296
310
  const a = minIdx <= maxIdx ? minIdx : maxIdx;
@@ -4,6 +4,7 @@ import type { DerivedValue, SharedValue } from "react-native-reanimated";
4
4
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
5
5
  import { type ChartPadding } from "../draw/line";
6
6
  import { interpolateAtTime } from "../math/interpolate";
7
+ import { pickCandleAtTime } from "../math/pickCandle";
7
8
  import type { CandlePoint } from "../types";
8
9
 
9
10
  const TOOLTIP_PAD_X = 8;
@@ -290,6 +291,36 @@ export function computeScrubTime(
290
291
  return winStart + fraction * windowSecs;
291
292
  }
292
293
 
294
+ /**
295
+ * Quantizes a scrub X to the center of the candle whose time bucket contains
296
+ * it (`scrub.snapToCandles`) — the inverse of {@link computeScrubTime}
297
+ * followed by the forward mapping of the picked candle's center. Returns the
298
+ * raw X unchanged when the position falls in a gap between candles or when
299
+ * the plot has no horizontal extent / time window yet.
300
+ */
301
+ export function snapScrubXToCandleCenter(
302
+ x: number,
303
+ candles: CandlePoint[],
304
+ liveCandle: CandlePoint | null,
305
+ candleWidthSecs: number,
306
+ padding: ChartPadding,
307
+ canvasWidth: number,
308
+ timestamp: number,
309
+ windowSecs: number,
310
+ ): number {
311
+ "worklet";
312
+ const chartW = canvasWidth - padding.left - padding.right;
313
+ if (chartW <= 0 || windowSecs <= 0) return x;
314
+ const winStart = timestamp - windowSecs;
315
+ const t = winStart + ((x - padding.left) / chartW) * windowSecs;
316
+ const candle = pickCandleAtTime(candles, liveCandle, t, candleWidthSecs);
317
+ if (!candle) return x;
318
+ return (
319
+ padding.left +
320
+ ((candle.time + candleWidthSecs / 2 - winStart) / windowSecs) * chartW
321
+ );
322
+ }
323
+
293
324
  /**
294
325
  * Crosshair opacity: fades 1→0 over `fadeDistance` px as the crosshair
295
326
  * approaches the live dot at the right chart edge. A zero distance removes
@@ -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,7 +47,15 @@ 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;
49
- /** When false and badge uses the right gutter, omit BADGE_TAIL_LEN from the right padding. */
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;
55
+ /**
56
+ * When false and the badge uses the right gutter, omit the tail and
57
+ * round-cap inset from the right padding.
58
+ */
50
59
  badgeShowTail?: boolean;
51
60
  /** Multi-series dot radius — used to add spacing between dots and Y-axis labels. */
52
61
  multiSeriesDotRadius?: number;
@@ -117,15 +126,28 @@ export function resolveChartLayout(
117
126
  );
118
127
  }
119
128
 
120
- if (config.pulse && config.yAxis && config.insetsOverride?.right == null) {
121
- const outlet = pulseRadialOutset(
122
- config.pulse.maxRadius,
123
- config.pulse.strokeWidth,
124
- );
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
+
137
+ // A right-gutter badge uses its pill layout instead of the bare centered label
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.
141
+ if (
142
+ dotEffectOutset > 0 &&
143
+ config.yAxis &&
144
+ !badgeUsesRightGutter &&
145
+ config.insetsOverride?.right == null
146
+ ) {
125
147
  const labelW = measuredYAxisLabelWidth ?? 49;
126
148
  rightPad = Math.max(
127
149
  rightPad,
128
- minPaddingRightForYAxisWithPulse(outlet, labelW),
150
+ minPaddingRightForYAxisWithPulse(dotEffectOutset, labelW),
129
151
  );
130
152
  }
131
153
 
@@ -148,12 +170,8 @@ export function resolveChartLayout(
148
170
  );
149
171
 
150
172
  let padding: ChartPadding = { ...base, right: rightPad, left: leftPad };
151
- if (config.pulse) {
152
- const outlet = pulseRadialOutset(
153
- config.pulse.maxRadius,
154
- config.pulse.strokeWidth,
155
- );
156
- // 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
157
175
  // acts as a floor on the *auto* padding. An explicitly-provided inset always
158
176
  // wins, though — the caller opts into any clipping that causes (issue #128).
159
177
  // Every other auto-padding already respects an explicit inset (see the
@@ -161,10 +179,16 @@ export function resolveChartLayout(
161
179
  const ins = config.insetsOverride;
162
180
  padding = {
163
181
  ...padding,
164
- right: ins?.right != null ? padding.right : Math.max(padding.right, outlet),
165
- 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),
166
188
  bottom:
167
- ins?.bottom != null ? padding.bottom : Math.max(padding.bottom, outlet),
189
+ ins?.bottom != null
190
+ ? padding.bottom
191
+ : Math.max(padding.bottom, dotEffectOutset),
168
192
  };
169
193
  }
170
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(),