react-native-livechart 4.18.0 → 4.20.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 (70) 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 +2 -2
  6. package/dist/components/LiveChart.d.ts.map +1 -1
  7. package/dist/components/LiveChartSeries.d.ts +2 -2
  8. package/dist/components/LiveChartSeries.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 +19 -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/grid.d.ts +1 -1
  19. package/dist/draw/grid.d.ts.map +1 -1
  20. package/dist/draw/line.d.ts +7 -1
  21. package/dist/draw/line.d.ts.map +1 -1
  22. package/dist/hooks/resolveChartLayout.d.ts +8 -0
  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/useDegen.d.ts +1 -3
  29. package/dist/hooks/useDegen.d.ts.map +1 -1
  30. package/dist/hooks/useMarkers.d.ts.map +1 -1
  31. package/dist/hooks/useMultiSeriesLinePaths.d.ts +3 -1
  32. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  33. package/dist/hooks/usePinchZoom.d.ts +7 -0
  34. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  35. package/dist/hooks/useSeriesIndicatorOpacity.d.ts +13 -0
  36. package/dist/hooks/useSeriesIndicatorOpacity.d.ts.map +1 -0
  37. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  38. package/dist/hooks/useYAxis.d.ts +1 -1
  39. package/dist/hooks/useYAxis.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/simplify.d.ts +20 -0
  43. package/dist/math/simplify.d.ts.map +1 -0
  44. package/dist/types.d.ts +101 -16
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/components/CrosshairOverlay.tsx +14 -9
  48. package/src/components/DotOverlay.tsx +17 -1
  49. package/src/components/LiveChart.tsx +227 -67
  50. package/src/components/LiveChartSeries.tsx +56 -16
  51. package/src/components/MultiSeriesDots.tsx +22 -3
  52. package/src/constants.ts +7 -0
  53. package/src/core/resolveConfig.ts +72 -14
  54. package/src/core/useLiveChartEngine.ts +6 -1
  55. package/src/draw/candle.ts +78 -0
  56. package/src/draw/grid.ts +34 -4
  57. package/src/draw/line.ts +20 -7
  58. package/src/hooks/resolveChartLayout.ts +30 -18
  59. package/src/hooks/useCandlePaths.ts +9 -1
  60. package/src/hooks/useChartPaths.ts +37 -7
  61. package/src/hooks/useDegen.ts +2 -6
  62. package/src/hooks/useMarkers.ts +6 -2
  63. package/src/hooks/useMultiSeriesLinePaths.ts +31 -4
  64. package/src/hooks/usePinchZoom.ts +21 -0
  65. package/src/hooks/useSeriesIndicatorOpacity.ts +56 -0
  66. package/src/hooks/useTradeStream.ts +6 -1
  67. package/src/hooks/useYAxis.ts +2 -0
  68. package/src/index.ts +2 -0
  69. package/src/math/simplify.ts +197 -0
  70. package/src/types.ts +103 -16
@@ -1,8 +1,9 @@
1
- import { Circle, Group } from "@shopify/react-native-skia";
1
+ import { BlurMask, Circle, Group } from "@shopify/react-native-skia";
2
2
 
3
3
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
4
  import type { ChartPadding } from "../draw/line";
5
5
  import type {
6
+ ResolvedDotGlowConfig,
6
7
  ResolvedDotRingConfig,
7
8
  ResolvedPulseConfig,
8
9
  } from "../core/resolveConfig";
@@ -18,6 +19,7 @@ function SeriesDotAtIndex({
18
19
  color,
19
20
  radius,
20
21
  ring,
22
+ glow = null,
21
23
  ringColor,
22
24
  pulse,
23
25
  viewEnd,
@@ -27,8 +29,10 @@ function SeriesDotAtIndex({
27
29
  padding: ChartPadding;
28
30
  color: string;
29
31
  radius: number;
30
- /** Outer halo ring, or `null` for a flat circle. */
32
+ /** Crisp outer backing ring, or `null` for a flat circle. */
31
33
  ring: ResolvedDotRingConfig | null;
34
+ /** Optional soft static glow; unlike `pulse`, it has no animation clock. */
35
+ glow?: ResolvedDotGlowConfig | null;
32
36
  /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
33
37
  ringColor: string;
34
38
  pulse: ResolvedPulseConfig | null;
@@ -94,6 +98,17 @@ function SeriesDotAtIndex({
94
98
  opacity={pulseOpacity}
95
99
  />
96
100
  )}
101
+ {glow && (
102
+ <Circle
103
+ cx={dotX}
104
+ cy={dotY}
105
+ r={glow.radius}
106
+ color={glow.color ?? color}
107
+ opacity={glow.opacity}
108
+ >
109
+ <BlurMask blur={glow.blur} style="normal" />
110
+ </Circle>
111
+ )}
97
112
  {ring && (
98
113
  <Circle
99
114
  cx={dotX}
@@ -113,6 +128,7 @@ export function MultiSeriesDots({
113
128
  colors,
114
129
  radius,
115
130
  ring,
131
+ glow = null,
116
132
  ringColor,
117
133
  color,
118
134
  pulse,
@@ -123,8 +139,10 @@ export function MultiSeriesDots({
123
139
  padding: ChartPadding;
124
140
  colors: string[];
125
141
  radius: number;
126
- /** Outer halo ring, or `null` for flat circles. */
142
+ /** Crisp outer backing ring, or `null` for flat circles. */
127
143
  ring: ResolvedDotRingConfig | null;
144
+ /** Optional soft static glow behind every series dot. */
145
+ glow?: ResolvedDotGlowConfig | null;
128
146
  /** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
129
147
  ringColor: string;
130
148
  /** Fill color override; falls back to each series' line color. */
@@ -146,6 +164,7 @@ export function MultiSeriesDots({
146
164
  color={color ?? colors[i] ?? "#ffffff"}
147
165
  radius={radius}
148
166
  ring={ring}
167
+ glow={glow}
149
168
  ringColor={ringColor}
150
169
  pulse={pulse}
151
170
  viewEnd={viewEnd}
package/src/constants.ts CHANGED
@@ -39,6 +39,13 @@ export const RETURN_TO_LIVE_MS = 450;
39
39
  */
40
40
  export const SCRUB_OVERLAY_FADE_MS = 150;
41
41
 
42
+ /**
43
+ * Duration (ms) of the candle-focus opacity transition used by
44
+ * `scrub.dimTarget: "otherCandles"`. The selected candle remains fully opaque;
45
+ * the shared candle batch eases toward `dimOpacity` and back to full strength.
46
+ */
47
+ export const SCRUB_CANDLE_DIM_FADE_MS = 60;
48
+
42
49
  /**
43
50
  * Base wave amplitude (px) of the breathing loading squiggle — it breathes
44
51
  * between `0.4×` and `1.0×` this. Overridable via `loading={{ amplitude }}`.
@@ -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) ──────────────────────
@@ -88,6 +89,8 @@ export interface ResolvedBadgeConfig {
88
89
 
89
90
  export interface ResolvedYAxisConfig {
90
91
  minGap: number;
92
+ /** Multiplier used to choose and align representable dynamic nice intervals. */
93
+ intervalScale: number;
91
94
  /** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
92
95
  count: number;
93
96
  /** undefined → keep the default centered-gutter label placement. */
@@ -152,8 +155,12 @@ export interface ResolvedScrubConfig {
152
155
  tooltip: boolean;
153
156
  /** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
154
157
  seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
155
- /** Opacity of content right of the crosshair while scrubbing (dstOut fade). */
158
+ /** Content affected by dimOpacity while scrubbing. */
159
+ dimTarget: "future" | "otherCandles";
160
+ /** Opacity of the content selected by dimTarget while scrubbing. */
156
161
  dimOpacity: number;
162
+ /** Candle-focus dim transition duration in milliseconds. */
163
+ dimFadeMs: number;
157
164
  /** undefined → palette.crosshairLine */
158
165
  crosshairLineColor: string | undefined;
159
166
  /** Vertical crosshair line width in px. */
@@ -199,9 +206,7 @@ export interface ResolvedScrubConfig {
199
206
  export interface ResolvedPerSeriesTooltipConfig {
200
207
  alwaysShow: boolean;
201
208
  bucketSeconds: number | undefined;
202
- formatSeriesValue:
203
- | ((value: number, seriesId: string) => string)
204
- | undefined;
209
+ formatSeriesValue: ((value: number, seriesId: string) => string) | undefined;
205
210
  formatTimeRange: ((from: number, to: number) => string) | undefined;
206
211
  maxLabelChars: number;
207
212
  guideColor: string | undefined;
@@ -483,6 +488,7 @@ export function resolveBadge(
483
488
 
484
489
  const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
485
490
  minGap: 36,
491
+ intervalScale: 1,
486
492
  count: 0,
487
493
  labelRightMargin: undefined,
488
494
  gridEndGap: undefined,
@@ -500,7 +506,14 @@ export function resolveYAxis(
500
506
  ): ResolvedYAxisConfig | null {
501
507
  const resolved = resolveToggle(prop, Y_AXIS_DEFAULTS, false);
502
508
  if (resolved === null) return null;
503
- return { ...resolved, count: Math.max(0, Math.floor(resolved.count)) };
509
+ return {
510
+ ...resolved,
511
+ count: Math.max(0, Math.floor(resolved.count)),
512
+ intervalScale:
513
+ Number.isFinite(resolved.intervalScale) && resolved.intervalScale > 0
514
+ ? resolved.intervalScale
515
+ : 1,
516
+ };
504
517
  }
505
518
 
506
519
  const VOLUME_DEFAULTS: ResolvedVolumeConfig = {
@@ -688,8 +701,7 @@ export function resolveAxisLabel(
688
701
  ): ResolvedAxisLabelConfig | null {
689
702
  const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
690
703
  if (!resolved) return null;
691
- const connectorProp =
692
- typeof prop === "object" ? prop.connector : undefined;
704
+ const connectorProp = typeof prop === "object" ? prop.connector : undefined;
693
705
  return {
694
706
  ...resolved,
695
707
  connector: resolveConnector(
@@ -716,7 +728,9 @@ export function resolveXAxis(
716
728
  const SCRUB_DEFAULTS: ResolvedScrubConfig = {
717
729
  tooltip: true,
718
730
  seriesTooltip: null,
731
+ dimTarget: "future",
719
732
  dimOpacity: 0.3,
733
+ dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS,
720
734
  crosshairLineColor: undefined,
721
735
  crosshairStrokeWidth: 1,
722
736
  crosshairOvershoot: 0,
@@ -770,11 +784,7 @@ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
770
784
  function resolvePerSeriesTooltip(
771
785
  prop: boolean | PerSeriesTooltipConfig | undefined,
772
786
  ): ResolvedPerSeriesTooltipConfig | null {
773
- const resolved = resolveToggle(
774
- prop,
775
- PER_SERIES_TOOLTIP_DEFAULTS,
776
- false,
777
- );
787
+ const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false);
778
788
  if (!resolved) return null;
779
789
  const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
780
790
  if (dash !== undefined) {
@@ -804,6 +814,19 @@ export function resolveScrub(
804
814
  const seriesTooltip =
805
815
  typeof prop === "object" ? prop.seriesTooltip : undefined;
806
816
  resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
817
+ // `resolveToggle` shallow-merges object props, so an explicitly undefined
818
+ // optional field replaces its default. Restore the public defaults before
819
+ // normalizing; otherwise the numeric clamps produce NaN and an undefined
820
+ // dimTarget selects neither candle scrub rendering path.
821
+ resolved.dimTarget = resolved.dimTarget ?? SCRUB_DEFAULTS.dimTarget;
822
+ resolved.dimOpacity = Math.max(
823
+ 0,
824
+ Math.min(1, resolved.dimOpacity ?? SCRUB_DEFAULTS.dimOpacity),
825
+ );
826
+ resolved.dimFadeMs = Math.max(
827
+ 0,
828
+ resolved.dimFadeMs ?? SCRUB_DEFAULTS.dimFadeMs,
829
+ );
807
830
  resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
808
831
  resolved.crosshairFadeDistance = Math.max(
809
832
  0,
@@ -1181,7 +1204,39 @@ export function resolveTradeStream(
1181
1204
 
1182
1205
  // ─── Dot (shared) ─────────────────────────────────────────────────────────────
1183
1206
 
1184
- /** Resolved halo ring. `color: undefined` means "use the theme `badgeOuterBg`". */
1207
+ /** Resolved static dot glow. `color: undefined` means "use the dot color". */
1208
+ export interface ResolvedDotGlowConfig {
1209
+ color: string | undefined;
1210
+ radius: number;
1211
+ blur: number;
1212
+ opacity: number;
1213
+ }
1214
+
1215
+ const DOT_GLOW_DEFAULTS: ResolvedDotGlowConfig = {
1216
+ color: undefined,
1217
+ radius: 7,
1218
+ blur: 5,
1219
+ opacity: 0.18,
1220
+ };
1221
+
1222
+ /** `undefined`/`false` → off; `true` → restrained defaults; object → merged. */
1223
+ export function resolveDotGlow(
1224
+ prop: boolean | DotGlowConfig | undefined,
1225
+ ): ResolvedDotGlowConfig | null {
1226
+ if (!prop) return null;
1227
+ const config = prop === true ? DOT_GLOW_DEFAULTS : prop;
1228
+ return {
1229
+ color: config.color,
1230
+ radius: Math.max(0, config.radius ?? DOT_GLOW_DEFAULTS.radius),
1231
+ blur: Math.max(0, config.blur ?? DOT_GLOW_DEFAULTS.blur),
1232
+ opacity: Math.max(
1233
+ 0,
1234
+ Math.min(1, config.opacity ?? DOT_GLOW_DEFAULTS.opacity),
1235
+ ),
1236
+ };
1237
+ }
1238
+
1239
+ /** Resolved backing ring. `color: undefined` means "use the theme `badgeOuterBg`". */
1185
1240
  export interface ResolvedDotRingConfig {
1186
1241
  color: string | undefined;
1187
1242
  width: number;
@@ -1203,6 +1258,7 @@ export function resolveDotRing(
1203
1258
  export interface ResolvedDotConfig {
1204
1259
  radius: number;
1205
1260
  ring: ResolvedDotRingConfig | null;
1261
+ glow: ResolvedDotGlowConfig | null;
1206
1262
  show: boolean;
1207
1263
  color: string | undefined;
1208
1264
  trackWhileParked: boolean;
@@ -1211,6 +1267,7 @@ export interface ResolvedDotConfig {
1211
1267
  const DOT_DEFAULTS: ResolvedDotConfig = {
1212
1268
  radius: 3.5,
1213
1269
  ring: RING_DEFAULTS,
1270
+ glow: null,
1214
1271
  show: true,
1215
1272
  color: undefined,
1216
1273
  trackWhileParked: false,
@@ -1231,6 +1288,7 @@ export function resolveDot(
1231
1288
  return {
1232
1289
  radius: prop.radius ?? DOT_DEFAULTS.radius,
1233
1290
  ring: resolveDotRing(prop.ring),
1291
+ glow: resolveDotGlow(prop.glow),
1234
1292
  show: prop.show ?? DOT_DEFAULTS.show,
1235
1293
  color: prop.color,
1236
1294
  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/grid.ts CHANGED
@@ -89,6 +89,7 @@ export function pickInterval(
89
89
  prev: number,
90
90
  ): number {
91
91
  "worklet";
92
+ if (!Number.isFinite(valRange) || !Number.isFinite(pxPerUnit)) return 0;
92
93
  if (prev > 0) {
93
94
  const px = prev * pxPerUnit;
94
95
  if (px >= minGap * 0.5 && px <= minGap * 4) return prev;
@@ -103,6 +104,9 @@ export function pickInterval(
103
104
  for (let s = 0; s < divisorSets.length; s++) {
104
105
  const divs = divisorSets[s];
105
106
  let span = Math.pow(10, Math.ceil(Math.log10(valRange)));
107
+ // `10 ** ceil(log10(Number.MAX_VALUE))` overflows even though the input is
108
+ // finite. Starting from the range itself preserves a finite upper bound.
109
+ if (!Number.isFinite(span) && valRange > 0) span = valRange;
106
110
  let i = 0;
107
111
  while ((span / divs[i % 3]) * pxPerUnit >= minGap) {
108
112
  span /= divs[i % 3];
@@ -165,6 +169,7 @@ export function computeGridEntries(
165
169
  minGap = 36,
166
170
  grid: GridMetrics = GRID_METRICS_DEFAULTS,
167
171
  count = 0,
172
+ intervalScale = 1,
168
173
  ): { entries: YAxisEntry[]; interval: number } {
169
174
  "worklet";
170
175
  const chartH = canvasHeight - padTop - padBottom;
@@ -194,7 +199,29 @@ export function computeGridEntries(
194
199
 
195
200
  const pxPerUnit = chartH / valRange;
196
201
 
197
- const coarse = pickInterval(valRange, pxPerUnit, minGap, prevInterval);
202
+ let appliedIntervalScale = intervalScale;
203
+ let scaledRange = valRange * appliedIntervalScale;
204
+ let pxPerScaledUnit = pxPerUnit / appliedIntervalScale;
205
+ // A positive finite multiplier can still overflow/underflow when combined
206
+ // with the live range. Fall back to source-unit interval selection so an
207
+ // unrepresentable display range cannot poison or freeze this UI worklet.
208
+ if (
209
+ !Number.isFinite(scaledRange) ||
210
+ scaledRange <= 0 ||
211
+ !Number.isFinite(pxPerScaledUnit) ||
212
+ pxPerScaledUnit <= 0
213
+ ) {
214
+ appliedIntervalScale = 1;
215
+ scaledRange = valRange;
216
+ pxPerScaledUnit = pxPerUnit;
217
+ }
218
+ const interval = pickInterval(
219
+ scaledRange,
220
+ pxPerScaledUnit,
221
+ minGap,
222
+ appliedIntervalScale === intervalScale ? prevInterval : 0,
223
+ );
224
+ const coarse = interval / appliedIntervalScale;
198
225
  const fine = coarse / 2;
199
226
  const finePx = fine * pxPerUnit;
200
227
  const fineTarget = fineLineTargetAlpha(finePx, minGap);
@@ -227,7 +254,10 @@ export function computeGridEntries(
227
254
  fromEdge >= fadeZone ? 1 : fromEdge <= 0 ? 0 : fromEdge / fadeZone;
228
255
 
229
256
  const target = (isCoarse ? 1 : fineTarget) * edgeAlpha;
230
- const key = Math.round(val * 1e10);
257
+ // Snap to the exact fine-grid multiple. Unlike a fixed decimal precision,
258
+ // this keeps adjacent sub-1e-10 source values distinct while remaining
259
+ // stable when the same line is recomputed on later frames.
260
+ const key = Math.round(val / fine) * fine;
231
261
  targets[key] = target;
232
262
  }
233
263
 
@@ -265,12 +295,12 @@ export function computeGridEntries(
265
295
  const key = Number(allKeys[i]);
266
296
  const alpha = labelAlphas[key];
267
297
  if (alpha < 0.02) continue;
268
- const val = key / 1e10;
298
+ const val = key;
269
299
  const y = gridValueToY(val, displayMax, valRange, padTop, chartH);
270
300
  /* istanbul ignore next -- vertical clip; y stays in-band when val comes from tracked keys */
271
301
  if (y < padTop - 10 || y > canvasHeight - padBottom + 10) continue;
272
302
  entries.push({ y, label: formatValue(val), alpha });
273
303
  }
274
304
 
275
- return { entries, interval: coarse };
305
+ return { entries, interval };
276
306
  }
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
  }