react-native-livechart 4.8.0 → 4.9.1

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 (37) hide show
  1. package/dist/components/LiveChart.d.ts.map +1 -1
  2. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  3. package/dist/components/ThresholdLineOverlay.d.ts +4 -1
  4. package/dist/components/ThresholdLineOverlay.d.ts.map +1 -1
  5. package/dist/components/ThresholdSplitShader.d.ts +26 -0
  6. package/dist/components/ThresholdSplitShader.d.ts.map +1 -0
  7. package/dist/core/liveChartEngineTick.d.ts +9 -0
  8. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  9. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  10. package/dist/core/resolveConfig.d.ts +22 -5
  11. package/dist/core/resolveConfig.d.ts.map +1 -1
  12. package/dist/core/useLiveChartEngine.d.ts +14 -0
  13. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  14. package/dist/hooks/useChartPaths.d.ts +6 -1
  15. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  16. package/dist/hooks/useChartSkiaFont.d.ts +2 -1
  17. package/dist/hooks/useChartSkiaFont.d.ts.map +1 -1
  18. package/dist/hooks/useThreshold.d.ts +63 -6
  19. package/dist/hooks/useThreshold.d.ts.map +1 -1
  20. package/dist/math/threshold.d.ts +82 -0
  21. package/dist/math/threshold.d.ts.map +1 -1
  22. package/dist/types.d.ts +77 -18
  23. package/dist/types.d.ts.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/LiveChart.tsx +271 -42
  26. package/src/components/LiveChartSeries.tsx +5 -1
  27. package/src/components/ThresholdLineOverlay.tsx +35 -5
  28. package/src/components/ThresholdSplitShader.tsx +92 -0
  29. package/src/core/liveChartEngineTick.ts +45 -4
  30. package/src/core/liveChartSeriesEngineTick.ts +6 -0
  31. package/src/core/resolveConfig.ts +32 -6
  32. package/src/core/useLiveChartEngine.ts +35 -0
  33. package/src/hooks/useChartPaths.ts +51 -7
  34. package/src/hooks/useChartSkiaFont.ts +31 -5
  35. package/src/hooks/useThreshold.ts +267 -10
  36. package/src/math/threshold.ts +250 -0
  37. package/src/types.ts +78 -18
@@ -2,6 +2,10 @@ import type { CandlePoint, LiveChartPoint } from "../types";
2
2
 
3
3
  import { MOTION_METRICS_DEFAULTS } from "../constants";
4
4
  import { lerp } from "../math/lerp";
5
+ import { thresholdRangeMinMax } from "../math/threshold";
6
+
7
+ /** Scratch for the threshold range fold — only alive within one tick call. */
8
+ const THRESHOLD_RANGE_SCRATCH: [number, number] = [0, 0];
5
9
 
6
10
  export interface EngineTickMutable {
7
11
  displayValue: number;
@@ -48,6 +52,15 @@ export interface EngineTickInput {
48
52
  referenceValue: number | undefined;
49
53
  /** Additional reference values (lines + bands) folded into the Y range. */
50
54
  referenceValues?: number[];
55
+ /**
56
+ * Time-varying threshold series (`threshold.includeInRange`): its min/max over
57
+ * the visible window is folded into the Y range, like {@link referenceValues}
58
+ * but windowed each tick.
59
+ */
60
+ thresholdRangePoints?: LiveChartPoint[];
61
+ /** Whether {@link thresholdRangePoints} extends flat past its last point to
62
+ * "now" (`threshold.extendToNow`). Default `true`. */
63
+ thresholdRangeExtendToNow?: boolean;
51
64
  /** Clamp the computed lower bound at 0. */
52
65
  nonNegative?: boolean;
53
66
  /** Hard cap for the computed upper bound. */
@@ -222,7 +235,9 @@ export function tickLiveChartEngineFrame(
222
235
  }
223
236
  }
224
237
  const lc = input.liveCandle;
225
- if (lc) {
238
+ // Fold the in-progress candle in only while its bucket is visible — when
239
+ // scrolled back past it, the live candle must not stretch the Y range.
240
+ if (lc && (!scrolledBack || lc.time <= state.timestamp)) {
226
241
  /* istanbul ignore next -- trivial min/max */
227
242
  if (lc.low < tMin) {
228
243
  tMin = lc.low;
@@ -244,6 +259,11 @@ export function tickLiveChartEngineFrame(
244
259
  else hi = mid;
245
260
  }
246
261
  for (let i = lo; i < points.length; i++) {
262
+ // While scrolled back, stop at the frozen right edge (mirrors the candle
263
+ // scan) so newer points don't inflate the visible Y range. Following
264
+ // live, keep the tail inclusive — feed timestamps can run slightly ahead
265
+ // of the local clock and must not flicker out of the range.
266
+ if (scrolledBack && points[i].time > state.timestamp) break;
247
267
  const v = points[i].value;
248
268
  if (v < tMin) {
249
269
  tMin = v;
@@ -266,9 +286,15 @@ export function tickLiveChartEngineFrame(
266
286
  state.extremaMinTime = hasMin ? minTime : NaN;
267
287
  state.extremaMaxTime = hasMax ? maxTime : NaN;
268
288
 
269
- const cv = state.displayValue;
270
- if (cv < tMin) tMin = cv;
271
- if (cv > tMax) tMax = cv;
289
+ // Keep the animated live tip inside the range — but only while it's actually
290
+ // in the window. Scrolled back, the live value sits beyond the frozen right
291
+ // edge; folding it in would pin the Y range to the live price and stop the
292
+ // axis from adapting to the visible history.
293
+ if (!scrolledBack) {
294
+ const cv = state.displayValue;
295
+ if (cv < tMin) tMin = cv;
296
+ if (cv > tMax) tMax = cv;
297
+ }
272
298
 
273
299
  const ref = input.referenceValue;
274
300
  if (ref !== undefined) {
@@ -285,6 +311,21 @@ export function tickLiveChartEngineFrame(
285
311
  }
286
312
  }
287
313
 
314
+ const thrPts = input.thresholdRangePoints;
315
+ if (thrPts !== undefined && thrPts.length > 0) {
316
+ const mm = thresholdRangeMinMax(
317
+ thrPts,
318
+ state.timestamp,
319
+ state.displayWindow,
320
+ input.thresholdRangeExtendToNow ?? true,
321
+ THRESHOLD_RANGE_SCRATCH,
322
+ );
323
+ if (mm !== null) {
324
+ if (mm[0] < tMin) tMin = mm[0];
325
+ if (mm[1] > tMax) tMax = mm[1];
326
+ }
327
+ }
328
+
288
329
  const isExaggerate = input.exaggerate;
289
330
  if (tMin !== Infinity && tMax !== -Infinity) {
290
331
  const rawRange = tMax - tMin;
@@ -215,6 +215,12 @@ export function tickLiveChartSeriesEngineFrame(
215
215
  else hi = mid;
216
216
  }
217
217
  for (let j = lo; j < points.length; j++) {
218
+ // While scrolled back, stop at the frozen right edge so newer points
219
+ // don't inflate the visible Y range (the per-series tips folded below
220
+ // already track the edge value, so they stay in-range). Following live,
221
+ // keep the tail inclusive — feed timestamps can run slightly ahead of
222
+ // the local clock and must not flicker out of the range.
223
+ if (scrolledBack && points[j].time > state.timestamp) break;
218
224
  const v = points[j].value;
219
225
  /* istanbul ignore next -- trivial min/max */
220
226
  if (v < tMin) tMin = v;
@@ -14,6 +14,7 @@ import type {
14
14
  LineStyleConfig,
15
15
  LiveChartMetrics,
16
16
  LiveChartMetricsOverride,
17
+ LiveChartPoint,
17
18
  LoadingConfig,
18
19
  MarkerClusterConfig,
19
20
  DotConfig,
@@ -326,16 +327,31 @@ export interface ResolvedThresholdLineConfig {
326
327
  intervals: [number, number];
327
328
  strokeWidth: number;
328
329
  showValue: boolean;
330
+ /** undefined → fall back to `color`, then palette.refLabel at render time. */
331
+ labelColor: string | undefined;
329
332
  }
330
333
 
331
334
  export interface ResolvedThresholdConfig {
332
- /** The live split value (Y-axis units). Read on the UI thread each frame. */
333
- value: SharedValue<number>;
335
+ /**
336
+ * The split value (Y-axis units). A `SharedValue<number>` for a single live
337
+ * benchmark (read on the UI thread each frame), or a `LiveChartPoint[]` for a
338
+ * time-varying threshold the split follows point-for-point. `undefined` when
339
+ * {@link series} carries the threshold instead.
340
+ */
341
+ value: SharedValue<number> | LiveChartPoint[] | undefined;
342
+ /** Live time-varying threshold (`SharedValue<LiveChartPoint[]>`); wins over `value`. */
343
+ series: SharedValue<LiveChartPoint[]> | null;
334
344
  /** undefined → use palette.candleUp (up-green) at render time. */
335
345
  aboveColor: string | undefined;
336
346
  /** undefined → use palette.candleDown (down-red) at render time. */
337
347
  belowColor: string | undefined;
338
348
  fill: boolean;
349
+ /** Band opacity (0–1); {@link THRESHOLD_FILL_OPACITY_DEFAULT} unless tuned. */
350
+ fillOpacity: number;
351
+ /** Fold the threshold into the Y-axis range fit. */
352
+ includeInRange: boolean;
353
+ /** Series forms: extend flat past the last point to "now". */
354
+ extendToNow: boolean;
339
355
  line: ResolvedThresholdLineConfig | null;
340
356
  }
341
357
 
@@ -346,8 +362,12 @@ const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
346
362
  intervals: [4, 4],
347
363
  strokeWidth: 1,
348
364
  showValue: false,
365
+ labelColor: undefined,
349
366
  };
350
367
 
368
+ /** Default threshold band opacity — matches reference-line bands. */
369
+ export const THRESHOLD_FILL_OPACITY_DEFAULT = 0.16;
370
+
351
371
  /**
352
372
  * Resolves the `threshold.line` sub-prop to a config or null (no marker line).
353
373
  * `true` → dashed defaults, object → merged, falsy/undefined → null.
@@ -360,18 +380,24 @@ export function resolveThresholdLine(
360
380
 
361
381
  /**
362
382
  * Resolves the `threshold` prop to a fully-typed config or null (disabled).
363
- * Presence-gated (like `referenceLines`/`markers`): the required `value`
364
- * SharedValue means there is no boolean form — a config object enables it.
383
+ * Presence-gated (like `referenceLines`/`markers`): a config object with a
384
+ * `value` or `series` enables it — there is no boolean form.
365
385
  */
366
386
  export function resolveThreshold(
367
387
  prop: ThresholdConfig | undefined,
368
388
  ): ResolvedThresholdConfig | null {
369
- if (!prop) return null;
389
+ if (!prop || (prop.value == null && prop.series == null)) return null;
370
390
  return {
371
391
  value: prop.value,
392
+ series: prop.series ?? null,
372
393
  aboveColor: prop.aboveColor,
373
394
  belowColor: prop.belowColor,
374
- fill: prop.fill ?? false,
395
+ fill: !!prop.fill,
396
+ fillOpacity:
397
+ (typeof prop.fill === "object" ? prop.fill.opacity : undefined) ??
398
+ THRESHOLD_FILL_OPACITY_DEFAULT,
399
+ includeInRange: prop.includeInRange ?? false,
400
+ extendToNow: prop.extendToNow ?? true,
375
401
  line: resolveThresholdLine(prop.line),
376
402
  };
377
403
  }
@@ -37,6 +37,17 @@ export interface EngineConfig {
37
37
  * `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts.
38
38
  */
39
39
  liveReferenceValues?: SharedValue<number[]>;
40
+ /**
41
+ * Time-varying threshold series folded into the axis-range fit
42
+ * (`threshold.includeInRange`): each tick contributes the series' min/max over
43
+ * the visible window — like {@link referenceValues}, but windowed. A
44
+ * `SharedValue` for the live `threshold.series` form, a plain array for the
45
+ * `LiveChartPoint[]` form.
46
+ */
47
+ thresholdRangePoints?: SharedValue<LiveChartPoint[]> | LiveChartPoint[];
48
+ /** With {@link thresholdRangePoints}: whether the series extends flat past its
49
+ * last point to "now" (`threshold.extendToNow`). Default `true`. */
50
+ thresholdRangeExtendToNow?: boolean;
40
51
  nonNegative?: boolean;
41
52
  maxValue?: number;
42
53
  nowOverride?: number;
@@ -183,6 +194,9 @@ export interface EngineFrameRefs {
183
194
  exaggerateSV: SharedValue<boolean>;
184
195
  referenceValue: SharedValue<number | undefined>;
185
196
  referenceValues?: SharedValue<number[] | undefined>;
197
+ /** Series threshold folded into the range fit (`threshold.includeInRange`). */
198
+ thresholdRangePoints?: SharedValue<LiveChartPoint[] | undefined>;
199
+ thresholdRangeExtendToNow?: SharedValue<boolean>;
186
200
  nonNegativeSV?: SharedValue<boolean>;
187
201
  maxValueSV?: SharedValue<number | undefined>;
188
202
  nowOverrideSV?: SharedValue<number | undefined>;
@@ -251,6 +265,8 @@ export function applyLiveChartEngineFrame(
251
265
  exaggerate: sv.exaggerateSV.value,
252
266
  referenceValue: sv.referenceValue.value,
253
267
  referenceValues: sv.referenceValues?.value,
268
+ thresholdRangePoints: sv.thresholdRangePoints?.value,
269
+ thresholdRangeExtendToNow: sv.thresholdRangeExtendToNow?.value ?? true,
254
270
  nonNegative: sv.nonNegativeSV?.value ?? false,
255
271
  maxValue: sv.maxValueSV?.value,
256
272
  nowOverride: sv.nowOverrideSV?.value,
@@ -325,6 +341,23 @@ export function useLiveChartEngine(
325
341
  if (!live || live.length === 0) return base;
326
342
  return base && base.length > 0 ? base.concat(live) : live;
327
343
  });
344
+ // Threshold range fold (`threshold.includeInRange`, series forms). Captured
345
+ // directly (not via `config.`) like `liveReferenceValues`, so the live
346
+ // `threshold.series` SharedValue is tracked and the tick sees fresh points.
347
+ const thresholdRangeSourceSV =
348
+ config.thresholdRangePoints !== undefined &&
349
+ !Array.isArray(config.thresholdRangePoints)
350
+ ? config.thresholdRangePoints
351
+ : null;
352
+ const thresholdRangePoints = useDerivedValue<LiveChartPoint[] | undefined>(
353
+ () =>
354
+ thresholdRangeSourceSV
355
+ ? thresholdRangeSourceSV.value
356
+ : (config.thresholdRangePoints as LiveChartPoint[] | undefined),
357
+ );
358
+ const thresholdRangeExtendToNow = useDerivedValue(
359
+ () => config.thresholdRangeExtendToNow ?? true,
360
+ );
328
361
  const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
329
362
  const maxValueSV = useDerivedValue(() => config.maxValue);
330
363
  const nowOverrideSV = useDerivedValue(() => config.nowOverride);
@@ -412,6 +445,8 @@ export function useLiveChartEngine(
412
445
  exaggerateSV,
413
446
  referenceValue,
414
447
  referenceValues,
448
+ thresholdRangePoints,
449
+ thresholdRangeExtendToNow,
415
450
  nonNegativeSV,
416
451
  maxValueSV,
417
452
  nowOverrideSV,
@@ -4,6 +4,7 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
4
  import type { SingleEngineState } from "../core/useLiveChartEngine";
5
5
  import { buildLinePoints, type ChartPadding } from "../draw/line";
6
6
  import { drawSpline, makeSplineScratch } from "../math/spline";
7
+ import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
7
8
  import { blendPtsY, squigglifyPts } from "../math/squiggly";
8
9
  import { usePathBuilder } from "./usePathBuilder";
9
10
 
@@ -39,6 +40,11 @@ export function useChartPaths(
39
40
  squiggleAmplitude = 14,
40
41
  /** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
41
42
  squiggleSpeed = 1,
43
+ /** When set, build `thresholdFillPath` as the band between the line and this
44
+ * *time-varying* threshold — the split shader's evenly-spaced pixel-Y
45
+ * `samples[]` (so band geometry matches the shader exactly). Takes precedence
46
+ * over `thresholdY`, the constant (horizontal) case. */
47
+ thresholdSamples?: SharedValue<number[]>,
42
48
  ) {
43
49
  const lineBuilder = usePathBuilder();
44
50
  const fillBuilder = usePathBuilder();
@@ -134,17 +140,55 @@ export function useChartPaths(
134
140
  return b.detach();
135
141
  });
136
142
 
137
- // Threshold-anchored fill: the same spline, closed at `thresholdY` instead of the
138
- // baseline, so the band lies between the line and the threshold (the profit/loss
139
- // area). Painted with the hard-split vertical gradient, the part above the split
140
- // shows the above-color and the part below shows the below-color.
143
+ // Threshold-anchored fill: the same spline, closed along the threshold instead
144
+ // of the baseline, so the band lies between the line and the threshold (the
145
+ // profit/loss area). Painted with the split gradient/shader, the part above the
146
+ // split shows the above-color and the part below shows the below-color.
147
+ //
148
+ // `thresholdPts` (a time-varying series) closes the band along that polyline,
149
+ // right-to-left; otherwise `thresholdY` closes it at a single horizontal Y.
141
150
  const thresholdFillPath = useDerivedValue(() => {
142
151
  const cache = cacheRef.current!;
143
- if (!thresholdY) return cache.emptyPath;
144
- const yT = thresholdY.get();
145
152
  const pts = flatPts.get();
146
153
  const n = pts.length >> 1;
147
- if (n < 2 || !Number.isFinite(yT)) return cache.emptyPath;
154
+ if (n < 2) return cache.emptyPath;
155
+
156
+ const tsamples = thresholdSamples?.get();
157
+ if (tsamples && tsamples.length >= 2) {
158
+ const b = thresholdFillBuilder.value;
159
+ b.moveTo(pts[0], pts[1]);
160
+ drawSpline(b, pts, cache.scratch, linear);
161
+ // Band bottom = the SAMPLED threshold (identical to what the split shader
162
+ // reads), pinned to the LINE's x-range. Because the geometry and the shader
163
+ // use the same evenly-spaced, linearly-interpolated samples, a step riser
164
+ // ramps the same way in both — no green/red sliver bleeds through — and the
165
+ // x-range pin keeps the band closing with clean vertical sides (no wedge).
166
+ const leftX = pts[0];
167
+ const rightX = pts[(n - 1) * 2];
168
+ const count = tsamples.length;
169
+ // The samples live on the time-anchored, gliding grid — interpolate them
170
+ // across that span (same as the shader), not the static plot edges.
171
+ const [x0, x1] = thresholdSampleSpanX(
172
+ engine.timestamp.get(),
173
+ engine.displayWindow.get(),
174
+ padding.left,
175
+ engine.canvasWidth.get() - padding.right,
176
+ count,
177
+ );
178
+ const step = (x1 - x0) / (count - 1);
179
+ b.lineTo(rightX, sampleThresholdYAt(tsamples, x0, x1, rightX));
180
+ for (let i = count - 1; i >= 0; i--) {
181
+ const sx = x0 + step * i;
182
+ if (sx > leftX && sx < rightX) b.lineTo(sx, tsamples[i]);
183
+ }
184
+ b.lineTo(leftX, sampleThresholdYAt(tsamples, x0, x1, leftX));
185
+ b.close();
186
+ return b.detach();
187
+ }
188
+
189
+ if (!thresholdY) return cache.emptyPath;
190
+ const yT = thresholdY.get();
191
+ if (!Number.isFinite(yT)) return cache.emptyPath;
148
192
  const b = thresholdFillBuilder.value;
149
193
  b.moveTo(pts[0], pts[1]);
150
194
  drawSpline(b, pts, cache.scratch, linear);
@@ -3,14 +3,41 @@ import {
3
3
  Skia,
4
4
  useFont,
5
5
  type SkFont,
6
+ type SkFontMgr,
6
7
  } from "@shopify/react-native-skia";
7
8
 
8
9
  import { resolveFontConfig } from "../core/resolveConfig";
9
10
  import type { FontConfig } from "../types";
10
11
 
12
+ /**
13
+ * System-font match cache. `matchFont` walks the platform font manager to
14
+ * resolve a typeface — a few ms per call — and every chart resolves several
15
+ * fonts per render, so a screen full of sparklines would otherwise re-match
16
+ * the same `{family, size, weight}` dozens of times. `SkFont` instances are
17
+ * immutable and safe to share across canvases. Custom `fontManager`s bypass
18
+ * the cache (their identity isn't part of the key).
19
+ */
20
+ const systemFontCache = new Map<string, SkFont>();
21
+ let systemFontMgr: SkFontMgr | null = null;
22
+
23
+ function matchSystemFont(
24
+ fontFamily: string,
25
+ fontSize: number,
26
+ fontWeight: NonNullable<FontConfig["fontWeight"]>,
27
+ ): SkFont {
28
+ const key = `${fontFamily}|${fontSize}|${fontWeight}`;
29
+ const cached = systemFontCache.get(key);
30
+ if (cached) return cached;
31
+ systemFontMgr ??= Skia.FontMgr.System();
32
+ const font = matchFont({ fontFamily, fontSize, fontWeight }, systemFontMgr);
33
+ systemFontCache.set(key, font);
34
+ return font;
35
+ }
36
+
11
37
  /**
12
38
  * Resolves Skia text for charts: optional bundled `typeface` via `useFont`, otherwise
13
- * `matchFont` with optional custom `fontManager`.
39
+ * `matchFont` with optional custom `fontManager`. System-font matches are cached
40
+ * module-wide, so many charts sharing a family/size/weight resolve it once.
14
41
  */
15
42
  export function useChartSkiaFont(
16
43
  fontProp: FontConfig | undefined,
@@ -22,10 +49,9 @@ export function useChartSkiaFont(
22
49
  const typefaceSource = fontProp?.typeface ?? null;
23
50
  const customFont = useFont(typefaceSource, fontSize);
24
51
 
25
- const fallbackFont = matchFont(
26
- { fontFamily, fontSize, fontWeight },
27
- fontProp?.fontManager ?? Skia.FontMgr.System(),
28
- );
52
+ const fallbackFont = fontProp?.fontManager
53
+ ? matchFont({ fontFamily, fontSize, fontWeight }, fontProp.fontManager)
54
+ : matchSystemFont(fontFamily, fontSize, fontWeight);
29
55
 
30
56
  if (typefaceSource != null) {
31
57
  return customFont ?? fallbackFont;