react-native-livechart 3.0.0 → 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/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/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 +97 -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 +66 -10
- 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/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 +101 -0
|
@@ -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(() => {
|
|
@@ -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());
|
|
@@ -16,6 +16,7 @@ import type { CandlePoint, LiveChartPalette, ScrubPoint } from "../types";
|
|
|
16
16
|
import {
|
|
17
17
|
computeCandleTooltipLayout,
|
|
18
18
|
computeCrosshairOpacity,
|
|
19
|
+
computeScrubDotY,
|
|
19
20
|
computeScrubTime,
|
|
20
21
|
deriveCrosshairTooltipSingle,
|
|
21
22
|
type CrosshairState,
|
|
@@ -57,9 +58,14 @@ export function useCrosshair(
|
|
|
57
58
|
candleOpts?: CrosshairCandleOpts,
|
|
58
59
|
/** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
|
|
59
60
|
panGestureDelay = 0,
|
|
61
|
+
onGestureStart?: () => void,
|
|
62
|
+
onGestureEnd?: () => void,
|
|
60
63
|
): CrosshairState {
|
|
61
64
|
const scrubX = useSharedValue(-1);
|
|
62
65
|
const scrubActive = useSharedValue(false);
|
|
66
|
+
// Tracks whether the active scrub phase actually began, so a tap that never
|
|
67
|
+
// activates doesn't emit a spurious onGestureEnd.
|
|
68
|
+
const gestureStarted = useSharedValue(false);
|
|
63
69
|
|
|
64
70
|
const scrubTime = useDerivedValue(() =>
|
|
65
71
|
computeScrubTime(
|
|
@@ -112,6 +118,19 @@ export function useCrosshair(
|
|
|
112
118
|
),
|
|
113
119
|
);
|
|
114
120
|
|
|
121
|
+
// Y pixel of the scrub intersection — used by the selection dot. -1 when
|
|
122
|
+
// there's no value to mark.
|
|
123
|
+
const scrubDotY = useDerivedValue(() =>
|
|
124
|
+
computeScrubDotY(
|
|
125
|
+
scrubValue.get(),
|
|
126
|
+
engine.displayMin.get(),
|
|
127
|
+
engine.displayMax.get(),
|
|
128
|
+
engine.canvasHeight.get(),
|
|
129
|
+
padding.top,
|
|
130
|
+
padding.bottom,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
115
134
|
// Monospace advance width, measured once per render (not per scrub frame) so
|
|
116
135
|
// the tooltip layout worklet can size text by character count instead of a
|
|
117
136
|
// per-frame Skia measureText.
|
|
@@ -165,7 +184,19 @@ export function useCrosshair(
|
|
|
165
184
|
onScrub?.(null);
|
|
166
185
|
}
|
|
167
186
|
|
|
187
|
+
/* istanbul ignore next */
|
|
188
|
+
function handleGestureStart() {
|
|
189
|
+
onGestureStart?.();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* istanbul ignore next */
|
|
193
|
+
function handleGestureEnd() {
|
|
194
|
+
onGestureEnd?.();
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
const hasOnScrub = onScrub != null;
|
|
198
|
+
const hasOnGestureStart = onGestureStart != null;
|
|
199
|
+
const hasOnGestureEnd = onGestureEnd != null;
|
|
169
200
|
|
|
170
201
|
useAnimatedReaction(
|
|
171
202
|
() => {
|
|
@@ -178,13 +209,14 @@ export function useCrosshair(
|
|
|
178
209
|
if (val === null || time < 0) return "__pending__";
|
|
179
210
|
const chartW = engine.canvasWidth.get() - padding.left - padding.right;
|
|
180
211
|
if (chartW <= 0) return "__pending__";
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
212
|
+
const dotY = computeScrubDotY(
|
|
213
|
+
val,
|
|
214
|
+
engine.displayMin.get(),
|
|
215
|
+
engine.displayMax.get(),
|
|
216
|
+
engine.canvasHeight.get(),
|
|
217
|
+
padding.top,
|
|
218
|
+
padding.bottom,
|
|
219
|
+
);
|
|
188
220
|
let candleJson: string | null = null;
|
|
189
221
|
if (isCandleMode) {
|
|
190
222
|
const c = scrubCandle.get();
|
|
@@ -229,6 +261,8 @@ export function useCrosshair(
|
|
|
229
261
|
if (!enabled) return;
|
|
230
262
|
scrubX.set(e.x);
|
|
231
263
|
scrubActive.set(true);
|
|
264
|
+
gestureStarted.set(true);
|
|
265
|
+
if (hasOnGestureStart) scheduleOnRN(handleGestureStart);
|
|
232
266
|
},
|
|
233
267
|
)
|
|
234
268
|
.onUpdate(
|
|
@@ -243,6 +277,10 @@ export function useCrosshair(
|
|
|
243
277
|
"worklet";
|
|
244
278
|
scrubActive.set(false);
|
|
245
279
|
if (hasOnScrub) scheduleOnRN(handleScrubEnd);
|
|
280
|
+
if (gestureStarted.get()) {
|
|
281
|
+
gestureStarted.set(false);
|
|
282
|
+
if (hasOnGestureEnd) scheduleOnRN(handleGestureEnd);
|
|
283
|
+
}
|
|
246
284
|
},
|
|
247
285
|
);
|
|
248
286
|
|
|
@@ -258,6 +296,7 @@ export function useCrosshair(
|
|
|
258
296
|
scrubValue,
|
|
259
297
|
crosshairOpacity,
|
|
260
298
|
tooltipLayout,
|
|
299
|
+
scrubDotY,
|
|
261
300
|
gesture,
|
|
262
301
|
};
|
|
263
302
|
}
|