react-native-livechart 2.0.1 → 3.1.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.
- package/README.md +9 -3
- package/dist/components/AxisLabelOverlay.d.ts +29 -0
- package/dist/components/AxisLabelOverlay.d.ts.map +1 -0
- package/dist/components/CrosshairLine.d.ts +12 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +15 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/DegenParticlesOverlay.d.ts +17 -0
- package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/LoadingOverlay.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/SelectionDot.d.ts +32 -0
- package/dist/components/SelectionDot.d.ts.map +1 -0
- package/dist/components/ValueLineOverlay.d.ts.map +1 -1
- package/dist/components/XAxisOverlay.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +46 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +6 -0
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/draw/particleAtlas.d.ts +39 -0
- package/dist/draw/particleAtlas.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +10 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useBadge.d.ts +2 -2
- package/dist/hooks/useBadge.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +11 -11
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useChartColors.d.ts +5 -0
- package/dist/hooks/useChartColors.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +8 -7
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useChartReveal.d.ts +3 -1
- package/dist/hooks/useChartReveal.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +1 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useDegen.d.ts +3 -1
- package/dist/hooks/useDegen.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts +3 -1
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts +9 -8
- package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
- package/dist/hooks/usePathBuilder.d.ts +27 -0
- package/dist/hooks/usePathBuilder.d.ts.map +1 -0
- package/dist/hooks/useTradeStream.d.ts +3 -1
- package/dist/hooks/useTradeStream.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/spline.d.ts +10 -2
- package/dist/math/spline.d.ts.map +1 -1
- package/dist/types.d.ts +97 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/AxisLabelOverlay.tsx +133 -0
- package/src/components/CrosshairLine.tsx +27 -0
- package/src/components/CrosshairOverlay.tsx +50 -18
- package/src/components/DegenParticlesOverlay.tsx +93 -109
- package/src/components/LiveChart.tsx +66 -10
- package/src/components/LiveChartSeries.tsx +40 -0
- package/src/components/LoadingOverlay.tsx +15 -29
- package/src/components/MarkerOverlay.tsx +23 -28
- package/src/components/MultiSeriesValueLines.tsx +20 -40
- package/src/components/ReferenceLineOverlay.tsx +65 -107
- package/src/components/SelectionDot.tsx +92 -0
- package/src/components/ValueLineOverlay.tsx +9 -28
- package/src/components/XAxisOverlay.tsx +9 -29
- package/src/components/YAxisOverlay.tsx +6 -22
- package/src/core/resolveConfig.ts +98 -0
- package/src/core/useLiveChartEngine.ts +78 -27
- package/src/draw/markerAtlas.ts +9 -9
- package/src/draw/particleAtlas.ts +95 -0
- package/src/hooks/crosshairShared.ts +26 -0
- package/src/hooks/useBadge.ts +17 -32
- package/src/hooks/useCandlePaths.ts +27 -64
- package/src/hooks/useChartColors.ts +19 -0
- package/src/hooks/useChartPaths.ts +27 -39
- package/src/hooks/useChartReveal.ts +7 -4
- package/src/hooks/useCrosshair.ts +46 -7
- package/src/hooks/useCrosshairSeries.ts +47 -8
- package/src/hooks/useDegen.ts +6 -0
- package/src/hooks/useMarkers.ts +3 -0
- package/src/hooks/useMultiSeriesLinePaths.ts +26 -28
- package/src/hooks/usePathBuilder.ts +42 -0
- package/src/hooks/useTradeStream.ts +3 -0
- package/src/index.ts +4 -0
- package/src/math/spline.ts +22 -2
- package/src/types.ts +101 -0
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DashPathEffect,
|
|
3
|
-
Path,
|
|
4
|
-
Skia,
|
|
5
|
-
type SkPath,
|
|
6
|
-
} from "@shopify/react-native-skia";
|
|
7
|
-
import { useRef } from "react";
|
|
1
|
+
import { DashPathEffect, Path } from "@shopify/react-native-skia";
|
|
8
2
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
9
3
|
|
|
10
4
|
import type { ChartPadding } from "../draw/line";
|
|
11
5
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
6
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
9
|
* A dashed horizontal line that tracks the live display value — sitting at
|
|
@@ -29,30 +24,16 @@ export function ValueLineOverlay({
|
|
|
29
24
|
intervals: [number, number];
|
|
30
25
|
color: string;
|
|
31
26
|
}) {
|
|
32
|
-
|
|
33
|
-
const cacheRef = useRef<{
|
|
34
|
-
a: SkPath;
|
|
35
|
-
b: SkPath;
|
|
36
|
-
tick: boolean;
|
|
37
|
-
} | null>(null);
|
|
38
|
-
if (cacheRef.current === null) {
|
|
39
|
-
cacheRef.current = {
|
|
40
|
-
a: Skia.Path.Make(),
|
|
41
|
-
b: Skia.Path.Make(),
|
|
42
|
-
tick: false,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
27
|
+
const builder = usePathBuilder();
|
|
45
28
|
|
|
46
29
|
const path = useDerivedValue(() => {
|
|
47
|
-
const
|
|
48
|
-
cache.tick = !cache.tick;
|
|
49
|
-
const p = cache.tick ? cache.a : cache.b;
|
|
50
|
-
p.reset();
|
|
30
|
+
const b = builder.value;
|
|
51
31
|
const y = dotY.get();
|
|
52
|
-
if (y
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
32
|
+
if (y >= 0) {
|
|
33
|
+
b.moveTo(padding.left, y);
|
|
34
|
+
b.lineTo(engine.canvasWidth.get() - padding.right, y);
|
|
35
|
+
}
|
|
36
|
+
return b.detach();
|
|
56
37
|
});
|
|
57
38
|
|
|
58
39
|
return (
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Group,
|
|
3
|
-
Path,
|
|
4
|
-
Skia,
|
|
5
|
-
type SkFont,
|
|
6
|
-
type SkPath,
|
|
7
|
-
} from "@shopify/react-native-skia";
|
|
8
|
-
import { useRef } from "react";
|
|
1
|
+
import { Group, Path, type SkFont } from "@shopify/react-native-skia";
|
|
9
2
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
10
3
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
11
4
|
import type { ChartPadding } from "../draw/line";
|
|
12
5
|
import type { XAxisEntry } from "../hooks/useXAxis";
|
|
6
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
13
7
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
14
8
|
import type { LiveChartPalette } from "../types";
|
|
15
9
|
import { AnimatedLabel } from "./AnimatedLabel";
|
|
@@ -31,40 +25,26 @@ export function XAxisOverlay({
|
|
|
31
25
|
palette: LiveChartPalette;
|
|
32
26
|
font: SkFont;
|
|
33
27
|
}) {
|
|
34
|
-
const
|
|
35
|
-
a: SkPath;
|
|
36
|
-
b: SkPath;
|
|
37
|
-
tick: boolean;
|
|
38
|
-
} | null>(null);
|
|
39
|
-
if (axisCacheRef.current === null) {
|
|
40
|
-
axisCacheRef.current = {
|
|
41
|
-
a: Skia.Path.Make(),
|
|
42
|
-
b: Skia.Path.Make(),
|
|
43
|
-
tick: false,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
28
|
+
const axisBuilder = usePathBuilder();
|
|
46
29
|
|
|
47
30
|
const axisPath = useDerivedValue(() => {
|
|
48
31
|
"worklet";
|
|
49
|
-
const
|
|
50
|
-
axisCache.tick = !axisCache.tick;
|
|
51
|
-
const path = axisCache.tick ? axisCache.a : axisCache.b;
|
|
52
|
-
path.reset();
|
|
32
|
+
const b = axisBuilder.value;
|
|
53
33
|
const w = engine.canvasWidth.get();
|
|
54
34
|
const h = engine.canvasHeight.get();
|
|
55
35
|
const lineY = h - padding.bottom;
|
|
56
36
|
|
|
57
37
|
// Bottom axis line
|
|
58
|
-
|
|
59
|
-
|
|
38
|
+
b.moveTo(padding.left, lineY);
|
|
39
|
+
b.lineTo(w - padding.right, lineY);
|
|
60
40
|
|
|
61
41
|
// Tick marks
|
|
62
42
|
const items = entries.get();
|
|
63
43
|
for (let i = 0; i < items.length; i++) {
|
|
64
|
-
|
|
65
|
-
|
|
44
|
+
b.moveTo(items[i].x, lineY);
|
|
45
|
+
b.lineTo(items[i].x, lineY + TICK_HEIGHT);
|
|
66
46
|
}
|
|
67
|
-
return
|
|
47
|
+
return b.detach();
|
|
68
48
|
});
|
|
69
49
|
|
|
70
50
|
// Transform XAxisEntry[] into { x, y, label, alpha } for AnimatedLabel
|
|
@@ -2,11 +2,8 @@ import {
|
|
|
2
2
|
DashPathEffect,
|
|
3
3
|
Group,
|
|
4
4
|
Path,
|
|
5
|
-
Skia,
|
|
6
5
|
type SkFont,
|
|
7
|
-
type SkPath,
|
|
8
6
|
} from "@shopify/react-native-skia";
|
|
9
|
-
import { useRef } from "react";
|
|
10
7
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
11
8
|
import { BADGE_METRICS_DEFAULTS } from "../constants";
|
|
12
9
|
import type { ResolvedGridStyleConfig } from "../core/resolveConfig";
|
|
@@ -18,6 +15,7 @@ import {
|
|
|
18
15
|
pillTextLeftX,
|
|
19
16
|
type ChartPadding,
|
|
20
17
|
} from "../draw/line";
|
|
18
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
21
19
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
22
20
|
import type { BadgeMetrics, LiveChartPalette } from "../types";
|
|
23
21
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
@@ -57,31 +55,17 @@ export function YAxisOverlay({
|
|
|
57
55
|
const gridWidth = gridStyle?.strokeWidth ?? 1;
|
|
58
56
|
const gridIntervals = gridStyle?.intervals ?? [];
|
|
59
57
|
const gridOpacity = gridStyle?.opacity ?? 1;
|
|
60
|
-
const
|
|
61
|
-
a: SkPath;
|
|
62
|
-
b: SkPath;
|
|
63
|
-
tick: boolean;
|
|
64
|
-
} | null>(null);
|
|
65
|
-
if (gridCacheRef.current === null) {
|
|
66
|
-
gridCacheRef.current = {
|
|
67
|
-
a: Skia.Path.Make(),
|
|
68
|
-
b: Skia.Path.Make(),
|
|
69
|
-
tick: false,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
58
|
+
const gridBuilder = usePathBuilder();
|
|
72
59
|
|
|
73
60
|
const gridLinesPath = useDerivedValue(() => {
|
|
74
|
-
const
|
|
75
|
-
gridCache.tick = !gridCache.tick;
|
|
76
|
-
const path = gridCache.tick ? gridCache.a : gridCache.b;
|
|
77
|
-
path.reset();
|
|
61
|
+
const b = gridBuilder.value;
|
|
78
62
|
const items = entries.get();
|
|
79
63
|
const w = engine.canvasWidth.get();
|
|
80
64
|
for (let i = 0; i < items.length; i++) {
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
b.moveTo(padding.left, items[i].y);
|
|
66
|
+
b.lineTo(w - padding.right, items[i].y);
|
|
83
67
|
}
|
|
84
|
-
return
|
|
68
|
+
return b.detach();
|
|
85
69
|
});
|
|
86
70
|
|
|
87
71
|
const leftInset =
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AxisLabelConfig,
|
|
2
3
|
BadgeConfig,
|
|
3
4
|
BadgeVariant,
|
|
4
5
|
DegenOptions,
|
|
@@ -17,12 +18,16 @@ import type {
|
|
|
17
18
|
PulseConfig,
|
|
18
19
|
ReferenceLine,
|
|
19
20
|
ScrubConfig,
|
|
21
|
+
SelectionDotConfig,
|
|
22
|
+
SelectionDotProps,
|
|
23
|
+
SelectionDotRingConfig,
|
|
20
24
|
TradeEvent,
|
|
21
25
|
ValueLineConfig,
|
|
22
26
|
XAxisConfig,
|
|
23
27
|
YAxisConfig,
|
|
24
28
|
} from "../types";
|
|
25
29
|
|
|
30
|
+
import type { ComponentType, ReactElement } from "react";
|
|
26
31
|
import type { SharedValue } from "react-native-reanimated";
|
|
27
32
|
import {
|
|
28
33
|
BADGE_METRICS_DEFAULTS,
|
|
@@ -53,6 +58,16 @@ export interface ResolvedYAxisConfig {
|
|
|
53
58
|
minGap: number;
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
export interface ResolvedAxisLabelConfig {
|
|
62
|
+
/** undefined → use the chart's `formatValue` at render time. */
|
|
63
|
+
format?: (v: number) => string;
|
|
64
|
+
/** undefined → use the muted default label color at render time. */
|
|
65
|
+
color?: string;
|
|
66
|
+
position: "left" | "right";
|
|
67
|
+
/** When set, the built-in value label is replaced by this custom element. */
|
|
68
|
+
render?: () => ReactElement | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
export interface ResolvedXAxisConfig {
|
|
57
72
|
minGap: number;
|
|
58
73
|
}
|
|
@@ -80,6 +95,10 @@ export interface ResolvedGradientConfig {
|
|
|
80
95
|
topOpacity: number | undefined;
|
|
81
96
|
/** undefined → use palette.fillBottom (transparent) at render time */
|
|
82
97
|
bottomOpacity: number | undefined;
|
|
98
|
+
/** Explicit color stops (top → bottom); overrides the opacity stops. */
|
|
99
|
+
colors: string[] | undefined;
|
|
100
|
+
/** Stop positions (0..1) matching `colors` length. */
|
|
101
|
+
positions: number[] | undefined;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
export interface ResolvedPulseConfig {
|
|
@@ -217,6 +236,24 @@ export function resolveYAxis(
|
|
|
217
236
|
return resolveToggle(prop, Y_AXIS_DEFAULTS, false);
|
|
218
237
|
}
|
|
219
238
|
|
|
239
|
+
const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
|
|
240
|
+
format: undefined,
|
|
241
|
+
color: undefined,
|
|
242
|
+
position: "right",
|
|
243
|
+
render: undefined,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolves a `topLabel` / `bottomLabel` prop to a fully-typed config or null
|
|
248
|
+
* (no label). Opt-in, so `undefined`/`false` → null; `true` → the built-in
|
|
249
|
+
* value label with defaults; object → configured built-in (or a custom `render`).
|
|
250
|
+
*/
|
|
251
|
+
export function resolveAxisLabel(
|
|
252
|
+
prop: boolean | AxisLabelConfig | undefined,
|
|
253
|
+
): ResolvedAxisLabelConfig | null {
|
|
254
|
+
return resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
|
|
255
|
+
}
|
|
256
|
+
|
|
220
257
|
const X_AXIS_DEFAULTS: ResolvedXAxisConfig = {
|
|
221
258
|
minGap: 60,
|
|
222
259
|
};
|
|
@@ -255,6 +292,8 @@ export function resolveScrub(
|
|
|
255
292
|
const GRADIENT_DEFAULTS: ResolvedGradientConfig = {
|
|
256
293
|
topOpacity: undefined,
|
|
257
294
|
bottomOpacity: undefined,
|
|
295
|
+
colors: undefined,
|
|
296
|
+
positions: undefined,
|
|
258
297
|
};
|
|
259
298
|
|
|
260
299
|
/**
|
|
@@ -574,6 +613,65 @@ export function resolveMultiSeriesDot(
|
|
|
574
613
|
};
|
|
575
614
|
}
|
|
576
615
|
|
|
616
|
+
// ─── Selection dot ──────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
/** Resolved selection-dot ring. `color: undefined` → use the dot color. */
|
|
619
|
+
export interface ResolvedSelectionDotRingConfig {
|
|
620
|
+
color: string | undefined;
|
|
621
|
+
width: number;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const SELECTION_DOT_RING_DEFAULTS: ResolvedSelectionDotRingConfig = {
|
|
625
|
+
color: undefined,
|
|
626
|
+
width: 2,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/** `undefined`/`true` → ring defaults; `false` → null (no ring). */
|
|
630
|
+
export function resolveSelectionDotRing(
|
|
631
|
+
prop: boolean | SelectionDotRingConfig | undefined,
|
|
632
|
+
): ResolvedSelectionDotRingConfig | null {
|
|
633
|
+
return resolveToggle(prop, SELECTION_DOT_RING_DEFAULTS, true);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Fully-resolved selection-dot styling. */
|
|
637
|
+
export interface ResolvedSelectionDotConfig {
|
|
638
|
+
size: number;
|
|
639
|
+
/** undefined → use the line / leading-series color at render time. */
|
|
640
|
+
color: string | undefined;
|
|
641
|
+
ring: ResolvedSelectionDotRingConfig | null;
|
|
642
|
+
/** When set, the built-in size/color/ring knobs are ignored. */
|
|
643
|
+
component?: ComponentType<SelectionDotProps>;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const SELECTION_DOT_SIZE_DEFAULT = 4;
|
|
647
|
+
|
|
648
|
+
const SELECTION_DOT_DEFAULTS: ResolvedSelectionDotConfig = {
|
|
649
|
+
size: SELECTION_DOT_SIZE_DEFAULT,
|
|
650
|
+
color: undefined,
|
|
651
|
+
ring: SELECTION_DOT_RING_DEFAULTS,
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Resolves the `selectionDot` prop to a fully-typed config or null (hidden).
|
|
656
|
+
* Defaults to ON, so `undefined`/`true` yield the built-in dot.
|
|
657
|
+
* - `false` → `null` (no dot)
|
|
658
|
+
* - `undefined`/`true` → built-in dot with defaults
|
|
659
|
+
* - object → configured built-in dot, or the custom `component` when set
|
|
660
|
+
* (the size/color/ring knobs are still resolved but ignored by the slot)
|
|
661
|
+
*/
|
|
662
|
+
export function resolveSelectionDot(
|
|
663
|
+
prop: boolean | SelectionDotConfig | undefined,
|
|
664
|
+
): ResolvedSelectionDotConfig | null {
|
|
665
|
+
if (prop === false) return null;
|
|
666
|
+
if (prop == null || prop === true) return SELECTION_DOT_DEFAULTS;
|
|
667
|
+
return {
|
|
668
|
+
size: prop.size ?? SELECTION_DOT_DEFAULTS.size,
|
|
669
|
+
color: prop.color,
|
|
670
|
+
ring: resolveSelectionDotRing(prop.ring),
|
|
671
|
+
component: prop.component,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
577
675
|
// ─── Legend ───────────────────────────────────────────────────────────────────
|
|
578
676
|
|
|
579
677
|
export interface ResolvedLegendConfig {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useState } from "react";
|
|
9
9
|
import {
|
|
10
|
+
useAnimatedReaction,
|
|
10
11
|
useDerivedValue,
|
|
11
12
|
useFrameCallback,
|
|
12
13
|
useSharedValue,
|
|
@@ -31,6 +32,12 @@ export interface EngineConfig {
|
|
|
31
32
|
nowOverride?: number;
|
|
32
33
|
windowBuffer?: number;
|
|
33
34
|
paused?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Loop-free render: settle the display state once (smoothing forced to 1, no
|
|
37
|
+
* per-frame frame callback) for many small charts in a list. See
|
|
38
|
+
* {@link LiveChartProps.static}.
|
|
39
|
+
*/
|
|
40
|
+
static?: boolean;
|
|
34
41
|
mode?: "line" | "candle";
|
|
35
42
|
candles?: SharedValue<CandlePoint[]>;
|
|
36
43
|
liveCandle?: SharedValue<CandlePoint | null>;
|
|
@@ -143,7 +150,11 @@ export function applyLiveChartEngineFrame(
|
|
|
143
150
|
export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
|
|
144
151
|
// Low-frequency config → UI thread via useDerivedValue
|
|
145
152
|
const timeWindow = useDerivedValue(() => config.timeWindow);
|
|
146
|
-
|
|
153
|
+
// Static charts snap to their target in one tick (smoothing=1), so the single
|
|
154
|
+
// settle reaction below produces the final state with no per-frame easing.
|
|
155
|
+
const smoothing = useDerivedValue(() =>
|
|
156
|
+
config.static ? 1 : config.smoothing,
|
|
157
|
+
);
|
|
147
158
|
const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
|
|
148
159
|
const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
|
|
149
160
|
const referenceValue = useDerivedValue(() => config.referenceValue);
|
|
@@ -170,34 +181,74 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
|
|
|
170
181
|
// no useDerivedValue bridging, no closure serialization per tick.
|
|
171
182
|
const { data, value, candles, liveCandle } = config;
|
|
172
183
|
|
|
184
|
+
// Static charts run zero per-frame loops. `isStaticSV` gates both the
|
|
185
|
+
// frame-callback autostart and the one-shot settle reaction below.
|
|
186
|
+
const isStaticSV = useDerivedValue(() => config.static ?? false);
|
|
187
|
+
|
|
188
|
+
// Single refs object shared by the frame callback and the settle reaction so
|
|
189
|
+
// they can never drift out of sync.
|
|
190
|
+
const frameRefs: EngineFrameRefs = {
|
|
191
|
+
data,
|
|
192
|
+
value,
|
|
193
|
+
displayValue,
|
|
194
|
+
displayMin,
|
|
195
|
+
displayMax,
|
|
196
|
+
displayWindow,
|
|
197
|
+
timestamp,
|
|
198
|
+
canvasWidth,
|
|
199
|
+
canvasHeight,
|
|
200
|
+
timeWindow,
|
|
201
|
+
smoothing,
|
|
202
|
+
adaptiveSpeedBoostSV,
|
|
203
|
+
exaggerateSV,
|
|
204
|
+
referenceValue,
|
|
205
|
+
referenceValues,
|
|
206
|
+
nonNegativeSV,
|
|
207
|
+
maxValueSV,
|
|
208
|
+
nowOverrideSV,
|
|
209
|
+
windowBufferSV,
|
|
210
|
+
pausedSV,
|
|
211
|
+
modeSV,
|
|
212
|
+
candles,
|
|
213
|
+
liveCandle,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// `autostart=false` registers the frame callback without running it — the live
|
|
217
|
+
// loop is fully inert in static mode (the invariant that makes this worth it).
|
|
173
218
|
useFrameCallback((frameInfo) => {
|
|
174
219
|
"worklet";
|
|
175
|
-
applyLiveChartEngineFrame(frameInfo,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
applyLiveChartEngineFrame(frameInfo, frameRefs);
|
|
221
|
+
}, !config.static);
|
|
222
|
+
|
|
223
|
+
// One-shot settle for static charts: the fingerprint changes when the canvas
|
|
224
|
+
// lays out (width 0→real) or the framing inputs change, and the handler runs a
|
|
225
|
+
// single snap tick (smoothing=1). Inert when not static — `prepare` returns a
|
|
226
|
+
// constant so it never fires.
|
|
227
|
+
useAnimatedReaction(
|
|
228
|
+
() => {
|
|
229
|
+
if (!isStaticSV.get()) return 0; // inert when not static — never changes
|
|
230
|
+
const d = data.get();
|
|
231
|
+
const n = d.length;
|
|
232
|
+
return [
|
|
233
|
+
n,
|
|
234
|
+
n ? d[0].time : 0,
|
|
235
|
+
n ? d[0].value : 0,
|
|
236
|
+
n ? d[n - 1].time : 0,
|
|
237
|
+
n ? d[n - 1].value : 0,
|
|
238
|
+
value.get(),
|
|
239
|
+
canvasWidth.get(),
|
|
240
|
+
canvasHeight.get(),
|
|
241
|
+
timeWindow.get(),
|
|
242
|
+
].join(",");
|
|
243
|
+
},
|
|
244
|
+
(curr, prev) => {
|
|
245
|
+
if (!isStaticSV.get() || curr === prev) return;
|
|
246
|
+
applyLiveChartEngineFrame(
|
|
247
|
+
{ timeSincePreviousFrame: MS_PER_FRAME_60FPS },
|
|
248
|
+
frameRefs,
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
201
252
|
|
|
202
253
|
return {
|
|
203
254
|
data,
|
package/src/draw/markerAtlas.ts
CHANGED
|
@@ -207,17 +207,17 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
|
|
|
207
207
|
w: box,
|
|
208
208
|
h: box,
|
|
209
209
|
draw: (canvas, cx, cy) => {
|
|
210
|
-
const
|
|
210
|
+
const pb = Skia.PathBuilder.Make();
|
|
211
211
|
for (let k = 0; k < 10; k++) {
|
|
212
212
|
const ang = -Math.PI / 2 + (k * Math.PI) / 5;
|
|
213
213
|
const rad = k % 2 === 0 ? outer : inner;
|
|
214
214
|
const px = cx + rad * Math.cos(ang);
|
|
215
215
|
const py = cy + rad * Math.sin(ang);
|
|
216
|
-
if (k === 0)
|
|
217
|
-
else
|
|
216
|
+
if (k === 0) pb.moveTo(px, py);
|
|
217
|
+
else pb.lineTo(px, py);
|
|
218
218
|
}
|
|
219
|
-
|
|
220
|
-
canvas.drawPath(
|
|
219
|
+
pb.close();
|
|
220
|
+
canvas.drawPath(pb.detach(), fillPaint(color));
|
|
221
221
|
},
|
|
222
222
|
};
|
|
223
223
|
}
|
|
@@ -229,15 +229,15 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
|
|
|
229
229
|
w: box,
|
|
230
230
|
h: box,
|
|
231
231
|
draw: (canvas, cx, cy) => {
|
|
232
|
-
const
|
|
232
|
+
const pb = Skia.PathBuilder.Make();
|
|
233
233
|
for (let k = 0; k < 4; k++) {
|
|
234
234
|
const ang = (k * Math.PI) / 4;
|
|
235
235
|
const dx = L * Math.cos(ang);
|
|
236
236
|
const dy = L * Math.sin(ang);
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
pb.moveTo(cx - dx, cy - dy);
|
|
238
|
+
pb.lineTo(cx + dx, cy + dy);
|
|
239
239
|
}
|
|
240
|
-
canvas.drawPath(
|
|
240
|
+
canvas.drawPath(pb.detach(), strokePaint(color, 1.5, true));
|
|
241
241
|
},
|
|
242
242
|
};
|
|
243
243
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Skia,
|
|
3
|
+
PaintStyle,
|
|
4
|
+
drawAsImageFromPicture,
|
|
5
|
+
type SkImage,
|
|
6
|
+
} from "@shopify/react-native-skia";
|
|
7
|
+
import { DEGEN_STRIDE } from "../constants";
|
|
8
|
+
|
|
9
|
+
/** Reference radius of the rasterized white circle sprite (px). */
|
|
10
|
+
const SPRITE_RADIUS = 16;
|
|
11
|
+
/** Anti-alias breathing room baked around the circle so it never clips. */
|
|
12
|
+
const SPRITE_MARGIN = 2;
|
|
13
|
+
|
|
14
|
+
/** A rasterized white-circle sprite + its geometry. */
|
|
15
|
+
export interface ParticleSprite {
|
|
16
|
+
image: SkImage | null;
|
|
17
|
+
/** Side length of the square sprite box (px). */
|
|
18
|
+
size: number;
|
|
19
|
+
/** Radius of the white circle inside the box (px). */
|
|
20
|
+
radius: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rasterize ONE anti-aliased **white** filled circle into a square sprite, once
|
|
25
|
+
* (NOT per frame). Drawing it white lets each particle's per-sprite color
|
|
26
|
+
* modulate (multiply) the texture to its own tint + alpha via `drawAtlas`.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors the PictureRecorder pattern in `markerAtlas.ts`.
|
|
29
|
+
*/
|
|
30
|
+
export function buildParticleSprite(): ParticleSprite {
|
|
31
|
+
const R = SPRITE_RADIUS;
|
|
32
|
+
const S = 2 * (R + SPRITE_MARGIN);
|
|
33
|
+
const paint = Skia.Paint();
|
|
34
|
+
paint.setAntiAlias(true);
|
|
35
|
+
paint.setColor(Skia.Color("#ffffff"));
|
|
36
|
+
paint.setStyle(PaintStyle.Fill);
|
|
37
|
+
|
|
38
|
+
const recorder = Skia.PictureRecorder();
|
|
39
|
+
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, S, S));
|
|
40
|
+
canvas.drawCircle(S / 2, S / 2, R, paint);
|
|
41
|
+
const picture = recorder.finishRecordingAsPicture();
|
|
42
|
+
const image = drawAsImageFromPicture(picture, { width: S, height: S });
|
|
43
|
+
return { image, size: S, radius: R };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One particle ready to blit: world position + per-sprite scale/alpha/color. */
|
|
47
|
+
export interface ParticleInstance {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
scale: number;
|
|
51
|
+
alpha: number;
|
|
52
|
+
colorIndex: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pure, worklet-safe projection of the packed particle ring buffer into render
|
|
57
|
+
* instances. No Skia — plain numbers only — so it's directly unit-testable.
|
|
58
|
+
*
|
|
59
|
+
* Preserves the exact size/opacity formulas the old per-slot `<Circle>` used:
|
|
60
|
+
* life = max(0, 1 - (now - t0) / burstDur) (skip if dt = now - t0 < 0)
|
|
61
|
+
* r = size * (0.5 + life * 0.5), size = buf[base+6] || 1
|
|
62
|
+
* scale = r / spriteRadius
|
|
63
|
+
* alpha = life * particleOpacity
|
|
64
|
+
* Inactive (active <= 0.5), pre-spawn (dt < 0) and fully-faded (life <= 0)
|
|
65
|
+
* slots are skipped.
|
|
66
|
+
*/
|
|
67
|
+
export function buildParticleInstances(
|
|
68
|
+
buf: Float64Array,
|
|
69
|
+
slots: number,
|
|
70
|
+
now: number,
|
|
71
|
+
burstDur: number,
|
|
72
|
+
particleOpacity: number,
|
|
73
|
+
spriteRadius: number,
|
|
74
|
+
): ParticleInstance[] {
|
|
75
|
+
"worklet";
|
|
76
|
+
const out: ParticleInstance[] = [];
|
|
77
|
+
for (let i = 0; i < slots; i++) {
|
|
78
|
+
const base = i * DEGEN_STRIDE;
|
|
79
|
+
if (!(buf[base + 5] > 0.5)) continue;
|
|
80
|
+
const dt = now - buf[base + 4];
|
|
81
|
+
if (dt < 0) continue;
|
|
82
|
+
const life = Math.max(0, 1 - dt / burstDur);
|
|
83
|
+
if (life <= 0) continue;
|
|
84
|
+
const size = buf[base + 6] || 1;
|
|
85
|
+
const r = size * (0.5 + life * 0.5);
|
|
86
|
+
out.push({
|
|
87
|
+
x: buf[base + 0],
|
|
88
|
+
y: buf[base + 1],
|
|
89
|
+
scale: r / spriteRadius,
|
|
90
|
+
alpha: life * particleOpacity,
|
|
91
|
+
colorIndex: Math.max(0, Math.floor(buf[base + 7])),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
@@ -54,9 +54,35 @@ export interface CrosshairState {
|
|
|
54
54
|
scrubValue: SharedValue<number | null>;
|
|
55
55
|
crosshairOpacity: SharedValue<number>;
|
|
56
56
|
tooltipLayout: SharedValue<TooltipLayout>;
|
|
57
|
+
/** Scrub intersection Y in canvas px; -1 when there's no dot to draw
|
|
58
|
+
* (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
|
|
59
|
+
scrubDotY: SharedValue<number>;
|
|
57
60
|
gesture: ReturnType<typeof Gesture.Pan>;
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Maps a scrub value to its Y pixel at the crosshair intersection — the same
|
|
65
|
+
* mapping the live dot and tooltip use. Returns -1 when there is no dot to draw
|
|
66
|
+
* (value is null or the canvas isn't laid out); a degenerate (zero) range pins
|
|
67
|
+
* the dot to the vertical center of the plot.
|
|
68
|
+
*/
|
|
69
|
+
export function computeScrubDotY(
|
|
70
|
+
value: number | null,
|
|
71
|
+
displayMin: number,
|
|
72
|
+
displayMax: number,
|
|
73
|
+
canvasHeight: number,
|
|
74
|
+
padTop: number,
|
|
75
|
+
padBottom: number,
|
|
76
|
+
): number {
|
|
77
|
+
"worklet";
|
|
78
|
+
if (value === null) return -1;
|
|
79
|
+
const chartH = canvasHeight - padTop - padBottom;
|
|
80
|
+
if (chartH <= 0) return -1;
|
|
81
|
+
const valRange = displayMax - displayMin;
|
|
82
|
+
if (valRange === 0) return padTop + chartH / 2;
|
|
83
|
+
return padTop + ((displayMax - value) / valRange) * chartH;
|
|
84
|
+
}
|
|
85
|
+
|
|
60
86
|
/**
|
|
61
87
|
* Maps a scrub X position to a window timestamp.
|
|
62
88
|
* Returns -1 when inactive or when the canvas is not yet laid out.
|