react-native-livechart 4.17.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/CustomMarkerOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +10 -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/MarkerOverlay.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 +20 -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 +9 -3
- package/dist/draw/line.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +8 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/resolveChartLayout.d.ts +12 -1
- 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/useCrosshair.d.ts +7 -1
- package/dist/hooks/useCrosshair.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/useLiveDot.d.ts +8 -2
- package/dist/hooks/useLiveDot.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/markerCluster.d.ts +6 -0
- package/dist/math/markerCluster.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 +106 -17
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairOverlay.tsx +14 -9
- package/src/components/CustomMarkerOverlay.tsx +5 -1
- package/src/components/DotOverlay.tsx +26 -3
- package/src/components/LiveChart.tsx +210 -55
- package/src/components/LiveChartSeries.tsx +28 -10
- package/src/components/MarkerOverlay.tsx +5 -1
- package/src/components/MultiSeriesDots.tsx +22 -3
- package/src/constants.ts +7 -0
- package/src/core/resolveConfig.ts +67 -13
- package/src/core/useLiveChartEngine.ts +6 -1
- package/src/draw/candle.ts +78 -0
- package/src/draw/line.ts +24 -10
- package/src/hooks/crosshairShared.ts +31 -0
- package/src/hooks/resolveChartLayout.ts +40 -16
- package/src/hooks/useCandlePaths.ts +9 -1
- package/src/hooks/useChartPaths.ts +37 -7
- package/src/hooks/useCrosshair.ts +29 -2
- package/src/hooks/useDegen.ts +2 -6
- package/src/hooks/useLiveDot.ts +28 -3
- package/src/hooks/useMarkers.ts +11 -3
- 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/markerCluster.ts +15 -1
- package/src/math/simplify.ts +197 -0
- package/src/types.ts +107 -17
|
@@ -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. */
|
|
@@ -192,14 +197,14 @@ export interface ResolvedScrubConfig {
|
|
|
192
197
|
hideOverlaysOnScrub: boolean;
|
|
193
198
|
/** Reject outside plain-scrub starts and clamp active scrub X to the plot. */
|
|
194
199
|
clampToPlot: boolean;
|
|
200
|
+
/** Candle mode: quantize the scrub X to the hovered candle's center. */
|
|
201
|
+
snapToCandles: boolean;
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
export interface ResolvedPerSeriesTooltipConfig {
|
|
198
205
|
alwaysShow: boolean;
|
|
199
206
|
bucketSeconds: number | undefined;
|
|
200
|
-
formatSeriesValue:
|
|
201
|
-
| ((value: number, seriesId: string) => string)
|
|
202
|
-
| undefined;
|
|
207
|
+
formatSeriesValue: ((value: number, seriesId: string) => string) | undefined;
|
|
203
208
|
formatTimeRange: ((from: number, to: number) => string) | undefined;
|
|
204
209
|
maxLabelChars: number;
|
|
205
210
|
guideColor: string | undefined;
|
|
@@ -686,8 +691,7 @@ export function resolveAxisLabel(
|
|
|
686
691
|
): ResolvedAxisLabelConfig | null {
|
|
687
692
|
const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
|
|
688
693
|
if (!resolved) return null;
|
|
689
|
-
const connectorProp =
|
|
690
|
-
typeof prop === "object" ? prop.connector : undefined;
|
|
694
|
+
const connectorProp = typeof prop === "object" ? prop.connector : undefined;
|
|
691
695
|
return {
|
|
692
696
|
...resolved,
|
|
693
697
|
connector: resolveConnector(
|
|
@@ -714,7 +718,9 @@ export function resolveXAxis(
|
|
|
714
718
|
const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
715
719
|
tooltip: true,
|
|
716
720
|
seriesTooltip: null,
|
|
721
|
+
dimTarget: "future",
|
|
717
722
|
dimOpacity: 0.3,
|
|
723
|
+
dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS,
|
|
718
724
|
crosshairLineColor: undefined,
|
|
719
725
|
crosshairStrokeWidth: 1,
|
|
720
726
|
crosshairOvershoot: 0,
|
|
@@ -734,6 +740,7 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
|
734
740
|
panGestureDelay: 0,
|
|
735
741
|
hideOverlaysOnScrub: false,
|
|
736
742
|
clampToPlot: false,
|
|
743
|
+
snapToCandles: false,
|
|
737
744
|
};
|
|
738
745
|
|
|
739
746
|
const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
@@ -767,11 +774,7 @@ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
|
767
774
|
function resolvePerSeriesTooltip(
|
|
768
775
|
prop: boolean | PerSeriesTooltipConfig | undefined,
|
|
769
776
|
): ResolvedPerSeriesTooltipConfig | null {
|
|
770
|
-
const resolved = resolveToggle(
|
|
771
|
-
prop,
|
|
772
|
-
PER_SERIES_TOOLTIP_DEFAULTS,
|
|
773
|
-
false,
|
|
774
|
-
);
|
|
777
|
+
const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false);
|
|
775
778
|
if (!resolved) return null;
|
|
776
779
|
const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
|
|
777
780
|
if (dash !== undefined) {
|
|
@@ -801,6 +804,19 @@ export function resolveScrub(
|
|
|
801
804
|
const seriesTooltip =
|
|
802
805
|
typeof prop === "object" ? prop.seriesTooltip : undefined;
|
|
803
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
|
+
);
|
|
804
820
|
resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
|
|
805
821
|
resolved.crosshairFadeDistance = Math.max(
|
|
806
822
|
0,
|
|
@@ -1178,7 +1194,39 @@ export function resolveTradeStream(
|
|
|
1178
1194
|
|
|
1179
1195
|
// ─── Dot (shared) ─────────────────────────────────────────────────────────────
|
|
1180
1196
|
|
|
1181
|
-
/** 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`". */
|
|
1182
1230
|
export interface ResolvedDotRingConfig {
|
|
1183
1231
|
color: string | undefined;
|
|
1184
1232
|
width: number;
|
|
@@ -1200,15 +1248,19 @@ export function resolveDotRing(
|
|
|
1200
1248
|
export interface ResolvedDotConfig {
|
|
1201
1249
|
radius: number;
|
|
1202
1250
|
ring: ResolvedDotRingConfig | null;
|
|
1251
|
+
glow: ResolvedDotGlowConfig | null;
|
|
1203
1252
|
show: boolean;
|
|
1204
1253
|
color: string | undefined;
|
|
1254
|
+
trackWhileParked: boolean;
|
|
1205
1255
|
}
|
|
1206
1256
|
|
|
1207
1257
|
const DOT_DEFAULTS: ResolvedDotConfig = {
|
|
1208
1258
|
radius: 3.5,
|
|
1209
1259
|
ring: RING_DEFAULTS,
|
|
1260
|
+
glow: null,
|
|
1210
1261
|
show: true,
|
|
1211
1262
|
color: undefined,
|
|
1263
|
+
trackWhileParked: false,
|
|
1212
1264
|
};
|
|
1213
1265
|
|
|
1214
1266
|
/**
|
|
@@ -1226,8 +1278,10 @@ export function resolveDot(
|
|
|
1226
1278
|
return {
|
|
1227
1279
|
radius: prop.radius ?? DOT_DEFAULTS.radius,
|
|
1228
1280
|
ring: resolveDotRing(prop.ring),
|
|
1281
|
+
glow: resolveDotGlow(prop.glow),
|
|
1229
1282
|
show: prop.show ?? DOT_DEFAULTS.show,
|
|
1230
1283
|
color: prop.color,
|
|
1284
|
+
trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked,
|
|
1231
1285
|
};
|
|
1232
1286
|
}
|
|
1233
1287
|
|
|
@@ -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 {
|
|
@@ -27,8 +27,8 @@ export const DEFAULT_PADDING: ChartPadding = {
|
|
|
27
27
|
* The pill body starts `tl` px to the right of the gutter left edge (= dot x),
|
|
28
28
|
* so the tail spans the gap between the dot and the pill body.
|
|
29
29
|
*
|
|
30
|
-
* When `showTail` is false
|
|
31
|
-
*
|
|
30
|
+
* When `showTail` is false there is no tail geometry at all, so the pill body
|
|
31
|
+
* starts flush at the gutter left edge (+ dotGap) and no cap inset is reserved.
|
|
32
32
|
*/
|
|
33
33
|
export function badgeTailAndCap(
|
|
34
34
|
fontSize: number,
|
|
@@ -36,8 +36,9 @@ export function badgeTailAndCap(
|
|
|
36
36
|
badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
37
37
|
): number {
|
|
38
38
|
"worklet";
|
|
39
|
+
if (!showTail) return 0;
|
|
39
40
|
const pillH = fontSize + badge.padY * 2;
|
|
40
|
-
return
|
|
41
|
+
return badge.tailLength + pillH / 2;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -112,7 +113,8 @@ export function resolveAutoRight(
|
|
|
112
113
|
showTail = true,
|
|
113
114
|
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
114
115
|
): number {
|
|
115
|
-
if (badge)
|
|
116
|
+
if (badge)
|
|
117
|
+
return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
|
|
116
118
|
if (yAxis) return 44;
|
|
117
119
|
return DEFAULT_PADDING.right;
|
|
118
120
|
}
|
|
@@ -155,6 +157,15 @@ export function pulseRadialOutset(
|
|
|
155
157
|
return Math.ceil(maxRadius + strokeWidth / 2);
|
|
156
158
|
}
|
|
157
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
|
+
|
|
158
169
|
/** Extra space beyond label width so the pulse ring does not touch glyphs. */
|
|
159
170
|
const PULSE_Y_AXIS_LABEL_GAP = 8;
|
|
160
171
|
|
|
@@ -282,15 +293,18 @@ export function buildLinePoints(
|
|
|
282
293
|
);
|
|
283
294
|
}
|
|
284
295
|
} else {
|
|
285
|
-
// Min/max-per-pixel-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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.
|
|
289
303
|
let curCol = -2147483648;
|
|
290
304
|
let minIdx = startIdx;
|
|
291
305
|
let maxIdx = startIdx;
|
|
292
306
|
for (let i = startIdx; i < endIdx; i++) {
|
|
293
|
-
const col = (
|
|
307
|
+
const col = Math.floor(data[i].time * xScale);
|
|
294
308
|
if (col !== curCol) {
|
|
295
309
|
if (curCol !== -2147483648) {
|
|
296
310
|
const a = minIdx <= maxIdx ? minIdx : maxIdx;
|
|
@@ -4,6 +4,7 @@ import type { DerivedValue, SharedValue } from "react-native-reanimated";
|
|
|
4
4
|
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
5
5
|
import { type ChartPadding } from "../draw/line";
|
|
6
6
|
import { interpolateAtTime } from "../math/interpolate";
|
|
7
|
+
import { pickCandleAtTime } from "../math/pickCandle";
|
|
7
8
|
import type { CandlePoint } from "../types";
|
|
8
9
|
|
|
9
10
|
const TOOLTIP_PAD_X = 8;
|
|
@@ -290,6 +291,36 @@ export function computeScrubTime(
|
|
|
290
291
|
return winStart + fraction * windowSecs;
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Quantizes a scrub X to the center of the candle whose time bucket contains
|
|
296
|
+
* it (`scrub.snapToCandles`) — the inverse of {@link computeScrubTime}
|
|
297
|
+
* followed by the forward mapping of the picked candle's center. Returns the
|
|
298
|
+
* raw X unchanged when the position falls in a gap between candles or when
|
|
299
|
+
* the plot has no horizontal extent / time window yet.
|
|
300
|
+
*/
|
|
301
|
+
export function snapScrubXToCandleCenter(
|
|
302
|
+
x: number,
|
|
303
|
+
candles: CandlePoint[],
|
|
304
|
+
liveCandle: CandlePoint | null,
|
|
305
|
+
candleWidthSecs: number,
|
|
306
|
+
padding: ChartPadding,
|
|
307
|
+
canvasWidth: number,
|
|
308
|
+
timestamp: number,
|
|
309
|
+
windowSecs: number,
|
|
310
|
+
): number {
|
|
311
|
+
"worklet";
|
|
312
|
+
const chartW = canvasWidth - padding.left - padding.right;
|
|
313
|
+
if (chartW <= 0 || windowSecs <= 0) return x;
|
|
314
|
+
const winStart = timestamp - windowSecs;
|
|
315
|
+
const t = winStart + ((x - padding.left) / chartW) * windowSecs;
|
|
316
|
+
const candle = pickCandleAtTime(candles, liveCandle, t, candleWidthSecs);
|
|
317
|
+
if (!candle) return x;
|
|
318
|
+
return (
|
|
319
|
+
padding.left +
|
|
320
|
+
((candle.time + candleWidthSecs / 2 - winStart) / windowSecs) * chartW
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
293
324
|
/**
|
|
294
325
|
* Crosshair opacity: fades 1→0 over `fadeDistance` px as the crosshair
|
|
295
326
|
* approaches the live dot at the right chart edge. A zero distance removes
|
|
@@ -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,7 +47,15 @@ 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;
|
|
49
|
-
/**
|
|
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;
|
|
55
|
+
/**
|
|
56
|
+
* When false and the badge uses the right gutter, omit the tail and
|
|
57
|
+
* round-cap inset from the right padding.
|
|
58
|
+
*/
|
|
50
59
|
badgeShowTail?: boolean;
|
|
51
60
|
/** Multi-series dot radius — used to add spacing between dots and Y-axis labels. */
|
|
52
61
|
multiSeriesDotRadius?: number;
|
|
@@ -117,15 +126,28 @@ export function resolveChartLayout(
|
|
|
117
126
|
);
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
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
|
+
|
|
137
|
+
// A right-gutter badge uses its pill layout instead of the bare centered label
|
|
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.
|
|
141
|
+
if (
|
|
142
|
+
dotEffectOutset > 0 &&
|
|
143
|
+
config.yAxis &&
|
|
144
|
+
!badgeUsesRightGutter &&
|
|
145
|
+
config.insetsOverride?.right == null
|
|
146
|
+
) {
|
|
125
147
|
const labelW = measuredYAxisLabelWidth ?? 49;
|
|
126
148
|
rightPad = Math.max(
|
|
127
149
|
rightPad,
|
|
128
|
-
minPaddingRightForYAxisWithPulse(
|
|
150
|
+
minPaddingRightForYAxisWithPulse(dotEffectOutset, labelW),
|
|
129
151
|
);
|
|
130
152
|
}
|
|
131
153
|
|
|
@@ -148,12 +170,8 @@ export function resolveChartLayout(
|
|
|
148
170
|
);
|
|
149
171
|
|
|
150
172
|
let padding: ChartPadding = { ...base, right: rightPad, left: leftPad };
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
config.pulse.maxRadius,
|
|
154
|
-
config.pulse.strokeWidth,
|
|
155
|
-
);
|
|
156
|
-
// 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
|
|
157
175
|
// acts as a floor on the *auto* padding. An explicitly-provided inset always
|
|
158
176
|
// wins, though — the caller opts into any clipping that causes (issue #128).
|
|
159
177
|
// Every other auto-padding already respects an explicit inset (see the
|
|
@@ -161,10 +179,16 @@ export function resolveChartLayout(
|
|
|
161
179
|
const ins = config.insetsOverride;
|
|
162
180
|
padding = {
|
|
163
181
|
...padding,
|
|
164
|
-
right:
|
|
165
|
-
|
|
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),
|
|
166
188
|
bottom:
|
|
167
|
-
ins?.bottom != null
|
|
189
|
+
ins?.bottom != null
|
|
190
|
+
? padding.bottom
|
|
191
|
+
: Math.max(padding.bottom, dotEffectOutset),
|
|
168
192
|
};
|
|
169
193
|
}
|
|
170
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(),
|