react-native-livechart 3.0.0 → 3.2.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/SelectionDot.d.ts +32 -0
- package/dist/components/SelectionDot.d.ts.map +1 -0
- 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/useCandlePaths.d.ts +3 -1
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useCanvasLayout.d.ts +3 -2
- package/dist/hooks/useCanvasLayout.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/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/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/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- 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 +79 -13
- package/src/components/LiveChartSeries.tsx +40 -0
- package/src/components/SelectionDot.tsx +92 -0
- package/src/core/resolveConfig.ts +98 -0
- package/src/core/useLiveChartEngine.ts +78 -27
- package/src/draw/particleAtlas.ts +95 -0
- package/src/hooks/crosshairShared.ts +26 -0
- package/src/hooks/useCandlePaths.ts +3 -1
- package/src/hooks/useCanvasLayout.ts +5 -3
- package/src/hooks/useChartColors.ts +19 -0
- 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/useTradeStream.ts +3 -0
- package/src/index.ts +4 -0
- package/src/types.ts +106 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Circle, Group } from "@shopify/react-native-skia";
|
|
2
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
3
|
+
import type { ResolvedSelectionDotConfig } from "../core/resolveConfig";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Built-in selection dot: a filled circle at the scrub intersection plus an
|
|
7
|
+
* optional subtle outer ring (the `ring` config). Drawn only when `y >= 0` (the
|
|
8
|
+
* sentinel for "no dot") — an off-screen Y hides it naturally.
|
|
9
|
+
*/
|
|
10
|
+
export function DefaultSelectionDot({
|
|
11
|
+
x,
|
|
12
|
+
y,
|
|
13
|
+
opacity,
|
|
14
|
+
color,
|
|
15
|
+
size,
|
|
16
|
+
ring,
|
|
17
|
+
}: {
|
|
18
|
+
x: SharedValue<number>;
|
|
19
|
+
y: SharedValue<number>;
|
|
20
|
+
opacity: SharedValue<number>;
|
|
21
|
+
color: string;
|
|
22
|
+
size: number;
|
|
23
|
+
ring: ResolvedSelectionDotConfig["ring"];
|
|
24
|
+
}) {
|
|
25
|
+
// Push the dot off-screen when there's no value to mark (y < 0), so the
|
|
26
|
+
// sentinel never paints a stray dot at the top of the plot.
|
|
27
|
+
const cy = useDerivedValue(() => (y.get() < 0 ? -size * 4 : y.get()));
|
|
28
|
+
return (
|
|
29
|
+
<Group opacity={opacity}>
|
|
30
|
+
{ring && (
|
|
31
|
+
<Circle
|
|
32
|
+
cx={x}
|
|
33
|
+
cy={cy}
|
|
34
|
+
r={size + ring.width}
|
|
35
|
+
color={ring.color ?? color}
|
|
36
|
+
opacity={0.25}
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
<Circle cx={x} cy={cy} r={size} color={color} />
|
|
40
|
+
</Group>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves the selection-dot slot inside a crosshair overlay: renders nothing
|
|
46
|
+
* when `config` is null or the position is unavailable, the consumer's custom
|
|
47
|
+
* `component` when one is given, and the built-in dot otherwise.
|
|
48
|
+
*
|
|
49
|
+
* `color` is the resolved fallback (line / leading-series color) used when the
|
|
50
|
+
* config's own `color` is unset.
|
|
51
|
+
*/
|
|
52
|
+
export function SelectionDotSlot({
|
|
53
|
+
config,
|
|
54
|
+
x,
|
|
55
|
+
y,
|
|
56
|
+
active,
|
|
57
|
+
opacity,
|
|
58
|
+
color,
|
|
59
|
+
}: {
|
|
60
|
+
config?: ResolvedSelectionDotConfig | null;
|
|
61
|
+
x: SharedValue<number>;
|
|
62
|
+
y?: SharedValue<number>;
|
|
63
|
+
active?: SharedValue<number> | SharedValue<boolean>;
|
|
64
|
+
opacity: SharedValue<number>;
|
|
65
|
+
color: string;
|
|
66
|
+
}) {
|
|
67
|
+
if (!config || y === undefined || !active) return null;
|
|
68
|
+
const dotColor = config.color ?? color;
|
|
69
|
+
if (config.component) {
|
|
70
|
+
const Custom = config.component;
|
|
71
|
+
return (
|
|
72
|
+
<Custom
|
|
73
|
+
x={x}
|
|
74
|
+
y={y}
|
|
75
|
+
active={active as SharedValue<boolean>}
|
|
76
|
+
opacity={opacity}
|
|
77
|
+
color={dotColor}
|
|
78
|
+
size={config.size}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
<DefaultSelectionDot
|
|
84
|
+
x={x}
|
|
85
|
+
y={y}
|
|
86
|
+
opacity={opacity}
|
|
87
|
+
color={dotColor}
|
|
88
|
+
size={config.size}
|
|
89
|
+
ring={config.ring}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -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,
|
|
@@ -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.
|
|
@@ -29,6 +29,8 @@ export function useCandlePaths(
|
|
|
29
29
|
candleWidthSecs: number,
|
|
30
30
|
active: boolean,
|
|
31
31
|
candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
|
|
32
|
+
/** Static charts run no loops: register without starting. Default `true`. */
|
|
33
|
+
autostart = true,
|
|
32
34
|
) {
|
|
33
35
|
const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
|
|
34
36
|
const displayCandleWidth = useSharedValue(candleWidthSecs);
|
|
@@ -50,7 +52,7 @@ export function useCandlePaths(
|
|
|
50
52
|
dt,
|
|
51
53
|
),
|
|
52
54
|
);
|
|
53
|
-
});
|
|
55
|
+
}, autostart);
|
|
54
56
|
|
|
55
57
|
/* istanbul ignore next -- worklet */
|
|
56
58
|
const geometry = useDerivedValue(() => {
|
|
@@ -5,18 +5,20 @@ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Track canvas dimensions via `onLayout` and sync them into engine shared values.
|
|
8
|
-
* Returns `{ layoutHeight, onLayout }` — pass `onLayout` to the
|
|
9
|
-
* and use
|
|
8
|
+
* Returns `{ layoutWidth, layoutHeight, onLayout }` — pass `onLayout` to the
|
|
9
|
+
* container View and use dimensions for conditional rendering or gradient coordinates.
|
|
10
10
|
*/
|
|
11
11
|
export function useCanvasLayout(engine: ChartEngineLayout) {
|
|
12
|
+
const [layoutWidth, setLayoutWidth] = useState(0);
|
|
12
13
|
const [layoutHeight, setLayoutHeight] = useState(0);
|
|
13
14
|
|
|
14
15
|
const onLayout = (e: LayoutChangeEvent) => {
|
|
15
16
|
const { width, height } = e.nativeEvent.layout;
|
|
16
17
|
engine.canvasWidth.set(width);
|
|
17
18
|
engine.canvasHeight.set(height);
|
|
19
|
+
setLayoutWidth(width);
|
|
18
20
|
setLayoutHeight(height);
|
|
19
21
|
};
|
|
20
22
|
|
|
21
|
-
return { layoutHeight, onLayout } as const;
|
|
23
|
+
return { layoutWidth, layoutHeight, onLayout } as const;
|
|
22
24
|
}
|
|
@@ -12,6 +12,11 @@ export interface ChartColors {
|
|
|
12
12
|
gradientTopColor: string;
|
|
13
13
|
/** Bottom gradient stop — custom opacity or palette default. */
|
|
14
14
|
gradientBottomColor: string;
|
|
15
|
+
/** Gradient color stops (top → bottom). Custom `colors` when provided, else the
|
|
16
|
+
* 2-stop `[gradientTopColor, gradientBottomColor]` fallback. */
|
|
17
|
+
gradientColors: string[];
|
|
18
|
+
/** Stop positions matching `gradientColors`, or undefined for even spacing. */
|
|
19
|
+
gradientPositions: number[] | undefined;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/**
|
|
@@ -39,10 +44,24 @@ export function useChartColors(
|
|
|
39
44
|
? `rgba(${r}, ${g}, ${b}, ${gradientCfg.bottomOpacity})`
|
|
40
45
|
: palette.fillBottom;
|
|
41
46
|
|
|
47
|
+
const customColors = gradientCfg?.colors;
|
|
48
|
+
const hasCustomColors = Array.isArray(customColors) && customColors.length >= 2;
|
|
49
|
+
const gradientColors = hasCustomColors
|
|
50
|
+
? customColors
|
|
51
|
+
: [gradientTopColor, gradientBottomColor];
|
|
52
|
+
const gradientPositions =
|
|
53
|
+
hasCustomColors &&
|
|
54
|
+
gradientCfg?.positions !== undefined &&
|
|
55
|
+
gradientCfg.positions.length === customColors.length
|
|
56
|
+
? gradientCfg.positions
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
42
59
|
return {
|
|
43
60
|
backgroundColor,
|
|
44
61
|
gradientEnd,
|
|
45
62
|
gradientTopColor,
|
|
46
63
|
gradientBottomColor,
|
|
64
|
+
gradientColors,
|
|
65
|
+
gradientPositions,
|
|
47
66
|
};
|
|
48
67
|
}
|
|
@@ -58,6 +58,8 @@ export interface ChartRevealState {
|
|
|
58
58
|
export function useChartReveal(
|
|
59
59
|
loading: boolean,
|
|
60
60
|
hasData: SharedValue<boolean>,
|
|
61
|
+
/** Static charts skip the entry ramp: morphT snaps to its target, no `withTiming`. */
|
|
62
|
+
isStatic = false,
|
|
61
63
|
): ChartRevealState {
|
|
62
64
|
// Best-guess initial so the common case — a live chart that already has data
|
|
63
65
|
// and isn't loading — paints fully revealed with no flash. The reaction below
|
|
@@ -71,13 +73,14 @@ export function useChartReveal(
|
|
|
71
73
|
|
|
72
74
|
// The reaction reads presence/loading on the UI thread (a worklet, never during
|
|
73
75
|
// render — so no Reanimated "reading `value` during render" warning), seeds the
|
|
74
|
-
// exact reveal state on its first run, then animates on later changes.
|
|
76
|
+
// exact reveal state on its first run, then animates on later changes. Static
|
|
77
|
+
// charts always snap (no `withTiming` ramp) so no entry animation ever runs.
|
|
75
78
|
useAnimatedReaction(
|
|
76
79
|
() => !isLoading.get() && hasData.get(),
|
|
77
80
|
(chartVisible, prev) => {
|
|
78
81
|
"worklet";
|
|
79
|
-
if (prev === null || prev === undefined) {
|
|
80
|
-
//
|
|
82
|
+
if (isStatic || prev === null || prev === undefined) {
|
|
83
|
+
// Static, or first run: snap to the correct reveal state (no animation).
|
|
81
84
|
morphT.set(chartVisible ? 1 : 0);
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
@@ -90,7 +93,7 @@ export function useChartReveal(
|
|
|
90
93
|
);
|
|
91
94
|
}
|
|
92
95
|
},
|
|
93
|
-
[hasData],
|
|
96
|
+
[hasData, isStatic],
|
|
94
97
|
);
|
|
95
98
|
|
|
96
99
|
const isEmpty = useDerivedValue(() => !isLoading.get() && !hasData.get());
|