react-native-livechart 4.18.0 → 4.19.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/dist/components/CrosshairOverlay.d.ts +3 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +4 -2
- package/dist/components/DotOverlay.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/MultiSeriesDots.d.ts +5 -3
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +17 -3
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/draw/candle.d.ts +21 -0
- package/dist/draw/candle.d.ts.map +1 -1
- package/dist/draw/line.d.ts +7 -1
- package/dist/draw/line.d.ts.map +1 -1
- package/dist/hooks/resolveChartLayout.d.ts +8 -0
- package/dist/hooks/resolveChartLayout.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/useChartPaths.d.ts +3 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useDegen.d.ts +1 -3
- package/dist/hooks/useDegen.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts +3 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
- package/dist/hooks/useSeriesIndicatorOpacity.d.ts +13 -0
- package/dist/hooks/useSeriesIndicatorOpacity.d.ts.map +1 -0
- 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/simplify.d.ts +20 -0
- package/dist/math/simplify.d.ts.map +1 -0
- package/dist/types.d.ts +77 -16
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairOverlay.tsx +14 -9
- package/src/components/DotOverlay.tsx +17 -1
- package/src/components/LiveChart.tsx +194 -54
- package/src/components/LiveChartSeries.tsx +28 -10
- package/src/components/MultiSeriesDots.tsx +22 -3
- package/src/constants.ts +7 -0
- package/src/core/resolveConfig.ts +61 -13
- package/src/core/useLiveChartEngine.ts +6 -1
- package/src/draw/candle.ts +78 -0
- package/src/draw/line.ts +20 -7
- package/src/hooks/resolveChartLayout.ts +30 -18
- package/src/hooks/useCandlePaths.ts +9 -1
- package/src/hooks/useChartPaths.ts +37 -7
- package/src/hooks/useDegen.ts +2 -6
- package/src/hooks/useMarkers.ts +6 -2
- package/src/hooks/useMultiSeriesLinePaths.ts +31 -4
- package/src/hooks/useSeriesIndicatorOpacity.ts +56 -0
- package/src/hooks/useTradeStream.ts +6 -1
- package/src/index.ts +1 -0
- package/src/math/simplify.ts +197 -0
- package/src/types.ts +78 -16
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
LoadingConfig,
|
|
19
19
|
MarkerClusterConfig,
|
|
20
20
|
DotConfig,
|
|
21
|
+
DotGlowConfig,
|
|
21
22
|
DotRingConfig,
|
|
22
23
|
MultiSeriesDotConfig,
|
|
23
24
|
PulseConfig,
|
|
@@ -40,7 +41,6 @@ import type {
|
|
|
40
41
|
YAxisConfig,
|
|
41
42
|
ZoomConfig,
|
|
42
43
|
} from "../types";
|
|
43
|
-
|
|
44
44
|
import type { ComponentType, ReactElement } from "react";
|
|
45
45
|
import type { SharedValue } from "react-native-reanimated";
|
|
46
46
|
import type { ResolvedMarkerCluster } from "../math/markerCluster";
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
LOADING_WAVE_SPEED,
|
|
55
55
|
MOTION_METRICS_DEFAULTS,
|
|
56
56
|
RETURN_TO_LIVE_MS,
|
|
57
|
+
SCRUB_CANDLE_DIM_FADE_MS,
|
|
57
58
|
} from "../constants";
|
|
58
59
|
|
|
59
60
|
// ─── Resolved types (all fields required, no optionals) ──────────────────────
|
|
@@ -152,8 +153,12 @@ export interface ResolvedScrubConfig {
|
|
|
152
153
|
tooltip: boolean;
|
|
153
154
|
/** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
|
|
154
155
|
seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
|
|
155
|
-
/**
|
|
156
|
+
/** Content affected by dimOpacity while scrubbing. */
|
|
157
|
+
dimTarget: "future" | "otherCandles";
|
|
158
|
+
/** Opacity of the content selected by dimTarget while scrubbing. */
|
|
156
159
|
dimOpacity: number;
|
|
160
|
+
/** Candle-focus dim transition duration in milliseconds. */
|
|
161
|
+
dimFadeMs: number;
|
|
157
162
|
/** undefined → palette.crosshairLine */
|
|
158
163
|
crosshairLineColor: string | undefined;
|
|
159
164
|
/** Vertical crosshair line width in px. */
|
|
@@ -199,9 +204,7 @@ export interface ResolvedScrubConfig {
|
|
|
199
204
|
export interface ResolvedPerSeriesTooltipConfig {
|
|
200
205
|
alwaysShow: boolean;
|
|
201
206
|
bucketSeconds: number | undefined;
|
|
202
|
-
formatSeriesValue:
|
|
203
|
-
| ((value: number, seriesId: string) => string)
|
|
204
|
-
| undefined;
|
|
207
|
+
formatSeriesValue: ((value: number, seriesId: string) => string) | undefined;
|
|
205
208
|
formatTimeRange: ((from: number, to: number) => string) | undefined;
|
|
206
209
|
maxLabelChars: number;
|
|
207
210
|
guideColor: string | undefined;
|
|
@@ -688,8 +691,7 @@ export function resolveAxisLabel(
|
|
|
688
691
|
): ResolvedAxisLabelConfig | null {
|
|
689
692
|
const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
|
|
690
693
|
if (!resolved) return null;
|
|
691
|
-
const connectorProp =
|
|
692
|
-
typeof prop === "object" ? prop.connector : undefined;
|
|
694
|
+
const connectorProp = typeof prop === "object" ? prop.connector : undefined;
|
|
693
695
|
return {
|
|
694
696
|
...resolved,
|
|
695
697
|
connector: resolveConnector(
|
|
@@ -716,7 +718,9 @@ export function resolveXAxis(
|
|
|
716
718
|
const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
717
719
|
tooltip: true,
|
|
718
720
|
seriesTooltip: null,
|
|
721
|
+
dimTarget: "future",
|
|
719
722
|
dimOpacity: 0.3,
|
|
723
|
+
dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS,
|
|
720
724
|
crosshairLineColor: undefined,
|
|
721
725
|
crosshairStrokeWidth: 1,
|
|
722
726
|
crosshairOvershoot: 0,
|
|
@@ -770,11 +774,7 @@ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
|
770
774
|
function resolvePerSeriesTooltip(
|
|
771
775
|
prop: boolean | PerSeriesTooltipConfig | undefined,
|
|
772
776
|
): ResolvedPerSeriesTooltipConfig | null {
|
|
773
|
-
const resolved = resolveToggle(
|
|
774
|
-
prop,
|
|
775
|
-
PER_SERIES_TOOLTIP_DEFAULTS,
|
|
776
|
-
false,
|
|
777
|
-
);
|
|
777
|
+
const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false);
|
|
778
778
|
if (!resolved) return null;
|
|
779
779
|
const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
|
|
780
780
|
if (dash !== undefined) {
|
|
@@ -804,6 +804,19 @@ export function resolveScrub(
|
|
|
804
804
|
const seriesTooltip =
|
|
805
805
|
typeof prop === "object" ? prop.seriesTooltip : undefined;
|
|
806
806
|
resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
|
|
807
|
+
// `resolveToggle` shallow-merges object props, so an explicitly undefined
|
|
808
|
+
// optional field replaces its default. Restore the public defaults before
|
|
809
|
+
// normalizing; otherwise the numeric clamps produce NaN and an undefined
|
|
810
|
+
// dimTarget selects neither candle scrub rendering path.
|
|
811
|
+
resolved.dimTarget = resolved.dimTarget ?? SCRUB_DEFAULTS.dimTarget;
|
|
812
|
+
resolved.dimOpacity = Math.max(
|
|
813
|
+
0,
|
|
814
|
+
Math.min(1, resolved.dimOpacity ?? SCRUB_DEFAULTS.dimOpacity),
|
|
815
|
+
);
|
|
816
|
+
resolved.dimFadeMs = Math.max(
|
|
817
|
+
0,
|
|
818
|
+
resolved.dimFadeMs ?? SCRUB_DEFAULTS.dimFadeMs,
|
|
819
|
+
);
|
|
807
820
|
resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
|
|
808
821
|
resolved.crosshairFadeDistance = Math.max(
|
|
809
822
|
0,
|
|
@@ -1181,7 +1194,39 @@ export function resolveTradeStream(
|
|
|
1181
1194
|
|
|
1182
1195
|
// ─── Dot (shared) ─────────────────────────────────────────────────────────────
|
|
1183
1196
|
|
|
1184
|
-
/** Resolved
|
|
1197
|
+
/** Resolved static dot glow. `color: undefined` means "use the dot color". */
|
|
1198
|
+
export interface ResolvedDotGlowConfig {
|
|
1199
|
+
color: string | undefined;
|
|
1200
|
+
radius: number;
|
|
1201
|
+
blur: number;
|
|
1202
|
+
opacity: number;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const DOT_GLOW_DEFAULTS: ResolvedDotGlowConfig = {
|
|
1206
|
+
color: undefined,
|
|
1207
|
+
radius: 7,
|
|
1208
|
+
blur: 5,
|
|
1209
|
+
opacity: 0.18,
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
/** `undefined`/`false` → off; `true` → restrained defaults; object → merged. */
|
|
1213
|
+
export function resolveDotGlow(
|
|
1214
|
+
prop: boolean | DotGlowConfig | undefined,
|
|
1215
|
+
): ResolvedDotGlowConfig | null {
|
|
1216
|
+
if (!prop) return null;
|
|
1217
|
+
const config = prop === true ? DOT_GLOW_DEFAULTS : prop;
|
|
1218
|
+
return {
|
|
1219
|
+
color: config.color,
|
|
1220
|
+
radius: Math.max(0, config.radius ?? DOT_GLOW_DEFAULTS.radius),
|
|
1221
|
+
blur: Math.max(0, config.blur ?? DOT_GLOW_DEFAULTS.blur),
|
|
1222
|
+
opacity: Math.max(
|
|
1223
|
+
0,
|
|
1224
|
+
Math.min(1, config.opacity ?? DOT_GLOW_DEFAULTS.opacity),
|
|
1225
|
+
),
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** Resolved backing ring. `color: undefined` means "use the theme `badgeOuterBg`". */
|
|
1185
1230
|
export interface ResolvedDotRingConfig {
|
|
1186
1231
|
color: string | undefined;
|
|
1187
1232
|
width: number;
|
|
@@ -1203,6 +1248,7 @@ export function resolveDotRing(
|
|
|
1203
1248
|
export interface ResolvedDotConfig {
|
|
1204
1249
|
radius: number;
|
|
1205
1250
|
ring: ResolvedDotRingConfig | null;
|
|
1251
|
+
glow: ResolvedDotGlowConfig | null;
|
|
1206
1252
|
show: boolean;
|
|
1207
1253
|
color: string | undefined;
|
|
1208
1254
|
trackWhileParked: boolean;
|
|
@@ -1211,6 +1257,7 @@ export interface ResolvedDotConfig {
|
|
|
1211
1257
|
const DOT_DEFAULTS: ResolvedDotConfig = {
|
|
1212
1258
|
radius: 3.5,
|
|
1213
1259
|
ring: RING_DEFAULTS,
|
|
1260
|
+
glow: null,
|
|
1214
1261
|
show: true,
|
|
1215
1262
|
color: undefined,
|
|
1216
1263
|
trackWhileParked: false,
|
|
@@ -1231,6 +1278,7 @@ export function resolveDot(
|
|
|
1231
1278
|
return {
|
|
1232
1279
|
radius: prop.radius ?? DOT_DEFAULTS.radius,
|
|
1233
1280
|
ring: resolveDotRing(prop.ring),
|
|
1281
|
+
glow: resolveDotGlow(prop.glow),
|
|
1234
1282
|
show: prop.show ?? DOT_DEFAULTS.show,
|
|
1235
1283
|
color: prop.color,
|
|
1236
1284
|
trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked,
|
|
@@ -560,10 +560,15 @@ export function useLiveChartEngine(
|
|
|
560
560
|
|
|
561
561
|
// `autostart=false` registers the frame callback without running it — the live
|
|
562
562
|
// loop is fully inert in static mode (the invariant that makes this worth it).
|
|
563
|
-
useFrameCallback((frameInfo) => {
|
|
563
|
+
const engineFrameCallback = useFrameCallback((frameInfo) => {
|
|
564
564
|
"worklet";
|
|
565
565
|
applyLiveChartEngineFrame(frameInfo, frameRefs, frameScratchRef.current!);
|
|
566
566
|
}, !config.static);
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
// Reanimated only seeds `isActive` from `autostart` on mount. Keep the
|
|
569
|
+
// imperative state aligned when a chart switches between static and live.
|
|
570
|
+
engineFrameCallback.setActive(!config.static);
|
|
571
|
+
}, [config.static, engineFrameCallback]);
|
|
567
572
|
|
|
568
573
|
// When time-scroll is disabled while scrolled back, return the window to the
|
|
569
574
|
// live edge. With a positive duration this glides: snapshot the frozen edge into
|
package/src/draw/candle.ts
CHANGED
|
@@ -22,6 +22,36 @@ export interface CandleGeometry {
|
|
|
22
22
|
wicks: CandleWick[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface CandleFocusClip {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable off-canvas clip used when no candle is focused. */
|
|
33
|
+
export const HIDDEN_CANDLE_FOCUS_CLIP: CandleFocusClip = {
|
|
34
|
+
x: -1,
|
|
35
|
+
y: 0,
|
|
36
|
+
width: 0,
|
|
37
|
+
height: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keep the full-strength candle pass visible while a candle is selected and
|
|
42
|
+
* while the shared dimmed batch is still returning to full opacity. The latter
|
|
43
|
+
* prevents the selected candle from briefly dropping to the dim opacity when a
|
|
44
|
+
* scrub ends or moves into a gap.
|
|
45
|
+
*/
|
|
46
|
+
export function computeCandleFocusPassOpacity(
|
|
47
|
+
scrubActive: boolean,
|
|
48
|
+
hasScrubCandle: boolean,
|
|
49
|
+
inactiveCandleOpacity: number,
|
|
50
|
+
): number {
|
|
51
|
+
"worklet";
|
|
52
|
+
return (scrubActive && hasScrubCandle) || inactiveCandleOpacity < 1 ? 1 : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
function candleTimeToX(
|
|
26
56
|
t: number,
|
|
27
57
|
padLeft: number,
|
|
@@ -44,6 +74,54 @@ function candleValueToY(
|
|
|
44
74
|
return padTop + ((displayMax - v) / valRange) * chartH;
|
|
45
75
|
}
|
|
46
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Return the visible screen-space time slot occupied by one candle bucket.
|
|
79
|
+
* The focus renderer clips the existing batched candle paths to this strip,
|
|
80
|
+
* repainting only the selected body and wick without building another SkPath.
|
|
81
|
+
*/
|
|
82
|
+
export function computeCandleFocusClip(
|
|
83
|
+
candle: CandlePoint | null,
|
|
84
|
+
padding: ChartPadding,
|
|
85
|
+
canvasW: number,
|
|
86
|
+
canvasH: number,
|
|
87
|
+
winStart: number,
|
|
88
|
+
windowSecs: number,
|
|
89
|
+
candleWidthSecs: number,
|
|
90
|
+
): CandleFocusClip {
|
|
91
|
+
"worklet";
|
|
92
|
+
if (!candle || windowSecs <= 0 || candleWidthSecs <= 0) {
|
|
93
|
+
return HIDDEN_CANDLE_FOCUS_CLIP;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const chartLeft = padding.left;
|
|
97
|
+
const chartRight = canvasW - padding.right;
|
|
98
|
+
const chartTop = padding.top;
|
|
99
|
+
const chartBottom = canvasH - padding.bottom;
|
|
100
|
+
const chartW = chartRight - chartLeft;
|
|
101
|
+
const chartH = chartBottom - chartTop;
|
|
102
|
+
if (chartW <= 0 || chartH <= 0) return HIDDEN_CANDLE_FOCUS_CLIP;
|
|
103
|
+
|
|
104
|
+
const bucketLeft = candleTimeToX(
|
|
105
|
+
candle.time,
|
|
106
|
+
padding.left,
|
|
107
|
+
winStart,
|
|
108
|
+
windowSecs,
|
|
109
|
+
chartW,
|
|
110
|
+
);
|
|
111
|
+
const bucketRight = candleTimeToX(
|
|
112
|
+
candle.time + candleWidthSecs,
|
|
113
|
+
padding.left,
|
|
114
|
+
winStart,
|
|
115
|
+
windowSecs,
|
|
116
|
+
chartW,
|
|
117
|
+
);
|
|
118
|
+
const x = Math.max(chartLeft, bucketLeft);
|
|
119
|
+
const right = Math.min(chartRight, bucketRight);
|
|
120
|
+
if (right <= x) return HIDDEN_CANDLE_FOCUS_CLIP;
|
|
121
|
+
|
|
122
|
+
return { x, y: chartTop, width: right - x, height: chartH };
|
|
123
|
+
}
|
|
124
|
+
|
|
47
125
|
/** One candle → bodies/wicks; top-level worklet (no nested closures). */
|
|
48
126
|
function appendCandleShapes(
|
|
49
127
|
c: CandlePoint,
|
package/src/draw/line.ts
CHANGED
|
@@ -5,7 +5,7 @@ export {
|
|
|
5
5
|
BADGE_MARGIN_RIGHT,
|
|
6
6
|
BADGE_PILL_PAD_X,
|
|
7
7
|
BADGE_PILL_PAD_Y,
|
|
8
|
-
BADGE_TAIL_LEN
|
|
8
|
+
BADGE_TAIL_LEN,
|
|
9
9
|
} from "../constants";
|
|
10
10
|
|
|
11
11
|
export interface ChartPadding {
|
|
@@ -113,7 +113,8 @@ export function resolveAutoRight(
|
|
|
113
113
|
showTail = true,
|
|
114
114
|
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
115
115
|
): number {
|
|
116
|
-
if (badge)
|
|
116
|
+
if (badge)
|
|
117
|
+
return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
|
|
117
118
|
if (yAxis) return 44;
|
|
118
119
|
return DEFAULT_PADDING.right;
|
|
119
120
|
}
|
|
@@ -156,6 +157,15 @@ export function pulseRadialOutset(
|
|
|
156
157
|
return Math.ceil(maxRadius + strokeWidth / 2);
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Conservative canvas footprint for a blurred live-dot glow. Two blur radii
|
|
162
|
+
* retain the visible low-opacity falloff without over-inflating chart gutters.
|
|
163
|
+
* Keep this in sync with the `Circle` + `BlurMask` rendering in the dot overlays.
|
|
164
|
+
*/
|
|
165
|
+
export function dotGlowRadialOutset(radius: number, blur: number): number {
|
|
166
|
+
return Math.ceil(Math.max(0, radius) + Math.max(0, blur) * 2);
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
/** Extra space beyond label width so the pulse ring does not touch glyphs. */
|
|
160
170
|
const PULSE_Y_AXIS_LABEL_GAP = 8;
|
|
161
171
|
|
|
@@ -283,15 +293,18 @@ export function buildLinePoints(
|
|
|
283
293
|
);
|
|
284
294
|
}
|
|
285
295
|
} else {
|
|
286
|
-
// Min/max-per-pixel-
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
296
|
+
// Min/max-per-pixel-width decimation: anchor buckets to absolute time, not
|
|
297
|
+
// the moving viewport origin. Existing samples then keep the same bucket as
|
|
298
|
+
// `now` advances, so interior history translates without being reshaped;
|
|
299
|
+
// only the buckets entering or leaving the viewport can change. Within each
|
|
300
|
+
// bucket keep the lowest- and highest-value samples (emitted in their
|
|
301
|
+
// original time order) so the line's envelope and volatility spikes survive
|
|
302
|
+
// at full vertical fidelity while the point count stays bounded by width.
|
|
290
303
|
let curCol = -2147483648;
|
|
291
304
|
let minIdx = startIdx;
|
|
292
305
|
let maxIdx = startIdx;
|
|
293
306
|
for (let i = startIdx; i < endIdx; i++) {
|
|
294
|
-
const col = (
|
|
307
|
+
const col = Math.floor(data[i].time * xScale);
|
|
295
308
|
if (col !== curCol) {
|
|
296
309
|
if (curCol !== -2147483648) {
|
|
297
310
|
const a = minIdx <= maxIdx ? minIdx : maxIdx;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SkFont } from "@shopify/react-native-skia";
|
|
2
2
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
3
3
|
import {
|
|
4
|
+
dotGlowRadialOutset,
|
|
4
5
|
minPaddingRightForBadgeYAxisAlign,
|
|
5
6
|
minPaddingRightForYAxisWithPulse,
|
|
6
7
|
pulseRadialOutset,
|
|
@@ -46,6 +47,11 @@ export interface ChartLayoutConfig {
|
|
|
46
47
|
* is not clipped. An explicit `insetsOverride` on a side wins over this floor.
|
|
47
48
|
*/
|
|
48
49
|
pulse?: { maxRadius: number; strokeWidth: number } | null;
|
|
50
|
+
/**
|
|
51
|
+
* Static live-dot glow; floors auto-padding so its blurred falloff is not
|
|
52
|
+
* clipped. Explicit side insets still win.
|
|
53
|
+
*/
|
|
54
|
+
dotGlow?: { radius: number; blur: number } | null;
|
|
49
55
|
/**
|
|
50
56
|
* When false and the badge uses the right gutter, omit the tail and
|
|
51
57
|
* round-cap inset from the right padding.
|
|
@@ -120,24 +126,28 @@ export function resolveChartLayout(
|
|
|
120
126
|
);
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
const pulseOutset = config.pulse
|
|
130
|
+
? pulseRadialOutset(config.pulse.maxRadius, config.pulse.strokeWidth)
|
|
131
|
+
: 0;
|
|
132
|
+
const glowOutset = config.dotGlow
|
|
133
|
+
? dotGlowRadialOutset(config.dotGlow.radius, config.dotGlow.blur)
|
|
134
|
+
: 0;
|
|
135
|
+
const dotEffectOutset = Math.max(pulseOutset, glowOutset);
|
|
136
|
+
|
|
123
137
|
// A right-gutter badge uses its pill layout instead of the bare centered label
|
|
124
|
-
// column, so the bare-axis
|
|
125
|
-
// The badge renders above the
|
|
126
|
-
// prevents canvas-edge clipping.
|
|
138
|
+
// column, so the bare-axis effect/label floor must not override badge sizing.
|
|
139
|
+
// The badge renders above the dot effects; the general outlet floor below
|
|
140
|
+
// still prevents canvas-edge clipping.
|
|
127
141
|
if (
|
|
128
|
-
|
|
142
|
+
dotEffectOutset > 0 &&
|
|
129
143
|
config.yAxis &&
|
|
130
144
|
!badgeUsesRightGutter &&
|
|
131
145
|
config.insetsOverride?.right == null
|
|
132
146
|
) {
|
|
133
|
-
const outlet = pulseRadialOutset(
|
|
134
|
-
config.pulse.maxRadius,
|
|
135
|
-
config.pulse.strokeWidth,
|
|
136
|
-
);
|
|
137
147
|
const labelW = measuredYAxisLabelWidth ?? 49;
|
|
138
148
|
rightPad = Math.max(
|
|
139
149
|
rightPad,
|
|
140
|
-
minPaddingRightForYAxisWithPulse(
|
|
150
|
+
minPaddingRightForYAxisWithPulse(dotEffectOutset, labelW),
|
|
141
151
|
);
|
|
142
152
|
}
|
|
143
153
|
|
|
@@ -160,12 +170,8 @@ export function resolveChartLayout(
|
|
|
160
170
|
);
|
|
161
171
|
|
|
162
172
|
let padding: ChartPadding = { ...base, right: rightPad, left: leftPad };
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
config.pulse.maxRadius,
|
|
166
|
-
config.pulse.strokeWidth,
|
|
167
|
-
);
|
|
168
|
-
// The pulse ring needs this much room or it clips at the canvas edge, so it
|
|
173
|
+
if (dotEffectOutset > 0) {
|
|
174
|
+
// The pulse/glow needs this much room or it clips at the canvas edge, so it
|
|
169
175
|
// acts as a floor on the *auto* padding. An explicitly-provided inset always
|
|
170
176
|
// wins, though — the caller opts into any clipping that causes (issue #128).
|
|
171
177
|
// Every other auto-padding already respects an explicit inset (see the
|
|
@@ -173,10 +179,16 @@ export function resolveChartLayout(
|
|
|
173
179
|
const ins = config.insetsOverride;
|
|
174
180
|
padding = {
|
|
175
181
|
...padding,
|
|
176
|
-
right:
|
|
177
|
-
|
|
182
|
+
right:
|
|
183
|
+
ins?.right != null
|
|
184
|
+
? padding.right
|
|
185
|
+
: Math.max(padding.right, dotEffectOutset),
|
|
186
|
+
top:
|
|
187
|
+
ins?.top != null ? padding.top : Math.max(padding.top, dotEffectOutset),
|
|
178
188
|
bottom:
|
|
179
|
-
ins?.bottom != null
|
|
189
|
+
ins?.bottom != null
|
|
190
|
+
? padding.bottom
|
|
191
|
+
: Math.max(padding.bottom, dotEffectOutset),
|
|
180
192
|
};
|
|
181
193
|
}
|
|
182
194
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Skia } from "@shopify/react-native-skia";
|
|
2
|
+
import { useEffect } from "react";
|
|
2
3
|
import {
|
|
3
4
|
useDerivedValue,
|
|
4
5
|
useFrameCallback,
|
|
@@ -35,6 +36,8 @@ export function useCandleWidthLerp(
|
|
|
35
36
|
candleLerpSpeed: number | undefined,
|
|
36
37
|
/** Static charts run no loops: register without starting. */
|
|
37
38
|
autostart: boolean,
|
|
39
|
+
/** Only advance the displayed width while candle mode is active. */
|
|
40
|
+
active: boolean,
|
|
38
41
|
): SharedValue<number> {
|
|
39
42
|
const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
|
|
40
43
|
const displayCandleWidth = useSharedValue(candleWidthSecs);
|
|
@@ -46,8 +49,9 @@ export function useCandleWidthLerp(
|
|
|
46
49
|
() => candleLerpSpeed ?? CANDLE_WIDTH_LERP_SPEED,
|
|
47
50
|
);
|
|
48
51
|
|
|
49
|
-
useFrameCallback((frameInfo) => {
|
|
52
|
+
const widthFrameCallback = useFrameCallback((frameInfo) => {
|
|
50
53
|
"worklet";
|
|
54
|
+
if (!active) return;
|
|
51
55
|
const dt = frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS;
|
|
52
56
|
displayCandleWidth.set(
|
|
53
57
|
lerp(
|
|
@@ -58,6 +62,10 @@ export function useCandleWidthLerp(
|
|
|
58
62
|
),
|
|
59
63
|
);
|
|
60
64
|
}, autostart);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
// `autostart` is an initial seed in Reanimated, not a reactive switch.
|
|
67
|
+
widthFrameCallback.setActive(autostart);
|
|
68
|
+
}, [autostart, widthFrameCallback]);
|
|
61
69
|
|
|
62
70
|
return displayCandleWidth;
|
|
63
71
|
}
|
|
@@ -7,6 +7,10 @@ import type {
|
|
|
7
7
|
SingleEngineState,
|
|
8
8
|
} from "../core/useLiveChartEngine";
|
|
9
9
|
import { buildLinePoints, type ChartPadding } from "../draw/line";
|
|
10
|
+
import {
|
|
11
|
+
makeLineSimplifyScratch,
|
|
12
|
+
simplifyLinePoints,
|
|
13
|
+
} from "../math/simplify";
|
|
10
14
|
import { drawSpline, makeSplineScratch } from "../math/spline";
|
|
11
15
|
import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
|
|
12
16
|
import { blendPtsY, squigglifyPts } from "../math/squiggly";
|
|
@@ -52,6 +56,8 @@ export function useChartPaths(
|
|
|
52
56
|
* `samples[]` (so band geometry matches the shader exactly). Takes precedence
|
|
53
57
|
* over `thresholdY`, the constant (horizontal) case. */
|
|
54
58
|
thresholdSamples?: SharedValue<number[]>,
|
|
59
|
+
/** Screen-space path simplification tolerance in pixels. `0` disables it. */
|
|
60
|
+
simplifyTolerance = 0,
|
|
55
61
|
) {
|
|
56
62
|
const lineBuilder = usePathBuilder();
|
|
57
63
|
const fillBuilder = usePathBuilder();
|
|
@@ -61,24 +67,28 @@ export function useChartPaths(
|
|
|
61
67
|
emptyPath: SkPath;
|
|
62
68
|
ptsA: number[];
|
|
63
69
|
ptsB: number[];
|
|
70
|
+
rawPts: number[];
|
|
64
71
|
ptsTick: boolean;
|
|
65
72
|
squigglePts: number[];
|
|
66
73
|
morphA: number[];
|
|
67
74
|
morphB: number[];
|
|
68
75
|
morphTick: boolean;
|
|
69
76
|
scratch: ReturnType<typeof makeSplineScratch>;
|
|
77
|
+
simplifyScratch: ReturnType<typeof makeLineSimplifyScratch>;
|
|
70
78
|
} | null>(null);
|
|
71
79
|
if (cacheRef.current === null) {
|
|
72
80
|
cacheRef.current = {
|
|
73
81
|
emptyPath: Skia.Path.Make(),
|
|
74
82
|
ptsA: [] as number[],
|
|
75
83
|
ptsB: [] as number[],
|
|
84
|
+
rawPts: [] as number[],
|
|
76
85
|
ptsTick: false,
|
|
77
86
|
squigglePts: [] as number[],
|
|
78
87
|
morphA: [] as number[],
|
|
79
88
|
morphB: [] as number[],
|
|
80
89
|
morphTick: false,
|
|
81
90
|
scratch: makeSplineScratch(),
|
|
91
|
+
simplifyScratch: makeLineSimplifyScratch(),
|
|
82
92
|
};
|
|
83
93
|
}
|
|
84
94
|
|
|
@@ -94,32 +104,52 @@ export function useChartPaths(
|
|
|
94
104
|
engine.edgeValue.get(),
|
|
95
105
|
engine.viewEnd.get(),
|
|
96
106
|
);
|
|
107
|
+
const simplify =
|
|
108
|
+
Number.isFinite(simplifyTolerance) && simplifyTolerance > 0
|
|
109
|
+
? simplifyTolerance
|
|
110
|
+
: 0;
|
|
111
|
+
const now = engine.timestamp.get();
|
|
112
|
+
const windowSecs = engine.displayWindow.get();
|
|
113
|
+
const canvasWidth = engine.canvasWidth.get();
|
|
97
114
|
const realPts = buildLinePoints(
|
|
98
115
|
engine.data.get(),
|
|
99
116
|
tipValue,
|
|
100
|
-
|
|
101
|
-
|
|
117
|
+
now,
|
|
118
|
+
windowSecs,
|
|
102
119
|
engine.displayMin.get(),
|
|
103
120
|
engine.displayMax.get(),
|
|
104
|
-
|
|
121
|
+
canvasWidth,
|
|
105
122
|
engine.canvasHeight.get(),
|
|
106
123
|
padding,
|
|
107
|
-
buf,
|
|
124
|
+
simplify > 0 ? cache.rawPts : buf,
|
|
108
125
|
// Only a live-following chart may extend the line to the right edge; a
|
|
109
126
|
// parked (scrolled-back / overscrolled) window ends at its last real
|
|
110
127
|
// point rather than fabricating a flat line into dataless space.
|
|
111
128
|
engine.viewEnd.get() != null,
|
|
112
129
|
);
|
|
130
|
+
const chartWidth = canvasWidth - padding.left - padding.right;
|
|
131
|
+
const absoluteXOffset =
|
|
132
|
+
windowSecs > 0 ? (now - windowSecs) * (chartWidth / windowSecs) : 0;
|
|
133
|
+
const renderPts =
|
|
134
|
+
simplify > 0
|
|
135
|
+
? simplifyLinePoints(
|
|
136
|
+
realPts,
|
|
137
|
+
simplify,
|
|
138
|
+
buf,
|
|
139
|
+
cache.simplifyScratch,
|
|
140
|
+
absoluteXOffset,
|
|
141
|
+
)
|
|
142
|
+
: realPts;
|
|
113
143
|
|
|
114
144
|
// Skip blending when fully revealed or no morphT provided
|
|
115
145
|
const t = morphT?.get() ?? 1;
|
|
116
|
-
if (t >= 1 ||
|
|
146
|
+
if (t >= 1 || renderPts.length === 0) return renderPts;
|
|
117
147
|
|
|
118
148
|
// Compute squiggly Y values at the same X positions as the real line
|
|
119
149
|
const centerY =
|
|
120
150
|
(engine.canvasHeight.get() - padding.bottom + padding.top) / 2;
|
|
121
151
|
const squigglyPts = squigglifyPts(
|
|
122
|
-
|
|
152
|
+
renderPts,
|
|
123
153
|
engine.timestamp.get(),
|
|
124
154
|
centerY,
|
|
125
155
|
squiggleAmplitude,
|
|
@@ -131,7 +161,7 @@ export function useChartPaths(
|
|
|
131
161
|
cache.morphTick = !cache.morphTick;
|
|
132
162
|
return blendPtsY(
|
|
133
163
|
squigglyPts,
|
|
134
|
-
|
|
164
|
+
renderPts,
|
|
135
165
|
t,
|
|
136
166
|
padding,
|
|
137
167
|
engine.canvasWidth.get(),
|
package/src/hooks/useDegen.ts
CHANGED
|
@@ -20,8 +20,6 @@ export function useDegen(
|
|
|
20
20
|
momentumSV: SharedValue<Momentum>,
|
|
21
21
|
cfg: ResolvedDegenConfig | null,
|
|
22
22
|
onShake?: (payload: DegenShakePayload) => void,
|
|
23
|
-
/** Static charts run no loops: register the frame callback without starting it. */
|
|
24
|
-
isStatic = false,
|
|
25
23
|
): {
|
|
26
24
|
pack: SharedValue<Float64Array<ArrayBuffer>>;
|
|
27
25
|
packRevision: SharedValue<number>;
|
|
@@ -154,6 +152,8 @@ export function useDegen(
|
|
|
154
152
|
speedMaxSV,
|
|
155
153
|
]);
|
|
156
154
|
|
|
155
|
+
// ChartWithDegen only mounts while the resolved effect is enabled; static
|
|
156
|
+
// charts force that config to null and unmount this callback entirely.
|
|
157
157
|
useFrameCallback(
|
|
158
158
|
/* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
|
|
159
159
|
"worklet";
|
|
@@ -250,10 +250,6 @@ export function useDegen(
|
|
|
250
250
|
}
|
|
251
251
|
prevActiveCount.set(activeCount);
|
|
252
252
|
},
|
|
253
|
-
// Static charts register the callback but never start it, so no degen frame
|
|
254
|
-
// loop runs. Live charts keep the default (autostart) loop unchanged. The
|
|
255
|
-
// controller also forces `cfg` to null in static, so degen is doubly off.
|
|
256
|
-
!isStatic,
|
|
257
253
|
);
|
|
258
254
|
|
|
259
255
|
const shakeTransform = useDerivedValue(() => {
|
package/src/hooks/useMarkers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
import { Gesture } from "react-native-gesture-handler";
|
|
3
3
|
import {
|
|
4
4
|
useFrameCallback,
|
|
@@ -77,7 +77,7 @@ export function useMarkers(
|
|
|
77
77
|
onMarkerPress?.(event);
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
-
useFrameCallback(
|
|
80
|
+
const markerFrameCallback = useFrameCallback(
|
|
81
81
|
/* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
|
|
82
82
|
"worklet";
|
|
83
83
|
const cache = cacheRef.current!;
|
|
@@ -111,6 +111,10 @@ export function useMarkers(
|
|
|
111
111
|
},
|
|
112
112
|
autostart,
|
|
113
113
|
);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
// Reanimated does not reactively apply later `autostart` values.
|
|
116
|
+
markerFrameCallback.setActive(autostart);
|
|
117
|
+
}, [autostart, markerFrameCallback]);
|
|
114
118
|
|
|
115
119
|
const tapGesture = Gesture.Tap().onEnd(
|
|
116
120
|
/* istanbul ignore next -- gesture worklet runs on UI thread, not in Jest */ (
|