react-native-livechart 4.18.0 → 4.20.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 +2 -2
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts +2 -2
- 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 +19 -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/grid.d.ts +1 -1
- package/dist/draw/grid.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/usePinchZoom.d.ts +7 -0
- package/dist/hooks/usePinchZoom.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/hooks/useYAxis.d.ts +1 -1
- package/dist/hooks/useYAxis.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 +101 -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 +227 -67
- package/src/components/LiveChartSeries.tsx +56 -16
- package/src/components/MultiSeriesDots.tsx +22 -3
- package/src/constants.ts +7 -0
- package/src/core/resolveConfig.ts +72 -14
- package/src/core/useLiveChartEngine.ts +6 -1
- package/src/draw/candle.ts +78 -0
- package/src/draw/grid.ts +34 -4
- 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/usePinchZoom.ts +21 -0
- package/src/hooks/useSeriesIndicatorOpacity.ts +56 -0
- package/src/hooks/useTradeStream.ts +6 -1
- package/src/hooks/useYAxis.ts +2 -0
- package/src/index.ts +2 -0
- package/src/math/simplify.ts +197 -0
- package/src/types.ts +103 -16
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Circle, Group } from "@shopify/react-native-skia";
|
|
1
|
+
import { BlurMask, Circle, Group } from "@shopify/react-native-skia";
|
|
2
2
|
|
|
3
3
|
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
4
4
|
import type { ChartPadding } from "../draw/line";
|
|
5
5
|
import type {
|
|
6
|
+
ResolvedDotGlowConfig,
|
|
6
7
|
ResolvedDotRingConfig,
|
|
7
8
|
ResolvedPulseConfig,
|
|
8
9
|
} from "../core/resolveConfig";
|
|
@@ -18,6 +19,7 @@ function SeriesDotAtIndex({
|
|
|
18
19
|
color,
|
|
19
20
|
radius,
|
|
20
21
|
ring,
|
|
22
|
+
glow = null,
|
|
21
23
|
ringColor,
|
|
22
24
|
pulse,
|
|
23
25
|
viewEnd,
|
|
@@ -27,8 +29,10 @@ function SeriesDotAtIndex({
|
|
|
27
29
|
padding: ChartPadding;
|
|
28
30
|
color: string;
|
|
29
31
|
radius: number;
|
|
30
|
-
/**
|
|
32
|
+
/** Crisp outer backing ring, or `null` for a flat circle. */
|
|
31
33
|
ring: ResolvedDotRingConfig | null;
|
|
34
|
+
/** Optional soft static glow; unlike `pulse`, it has no animation clock. */
|
|
35
|
+
glow?: ResolvedDotGlowConfig | null;
|
|
32
36
|
/** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
|
|
33
37
|
ringColor: string;
|
|
34
38
|
pulse: ResolvedPulseConfig | null;
|
|
@@ -94,6 +98,17 @@ function SeriesDotAtIndex({
|
|
|
94
98
|
opacity={pulseOpacity}
|
|
95
99
|
/>
|
|
96
100
|
)}
|
|
101
|
+
{glow && (
|
|
102
|
+
<Circle
|
|
103
|
+
cx={dotX}
|
|
104
|
+
cy={dotY}
|
|
105
|
+
r={glow.radius}
|
|
106
|
+
color={glow.color ?? color}
|
|
107
|
+
opacity={glow.opacity}
|
|
108
|
+
>
|
|
109
|
+
<BlurMask blur={glow.blur} style="normal" />
|
|
110
|
+
</Circle>
|
|
111
|
+
)}
|
|
97
112
|
{ring && (
|
|
98
113
|
<Circle
|
|
99
114
|
cx={dotX}
|
|
@@ -113,6 +128,7 @@ export function MultiSeriesDots({
|
|
|
113
128
|
colors,
|
|
114
129
|
radius,
|
|
115
130
|
ring,
|
|
131
|
+
glow = null,
|
|
116
132
|
ringColor,
|
|
117
133
|
color,
|
|
118
134
|
pulse,
|
|
@@ -123,8 +139,10 @@ export function MultiSeriesDots({
|
|
|
123
139
|
padding: ChartPadding;
|
|
124
140
|
colors: string[];
|
|
125
141
|
radius: number;
|
|
126
|
-
/**
|
|
142
|
+
/** Crisp outer backing ring, or `null` for flat circles. */
|
|
127
143
|
ring: ResolvedDotRingConfig | null;
|
|
144
|
+
/** Optional soft static glow behind every series dot. */
|
|
145
|
+
glow?: ResolvedDotGlowConfig | null;
|
|
128
146
|
/** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
|
|
129
147
|
ringColor: string;
|
|
130
148
|
/** Fill color override; falls back to each series' line color. */
|
|
@@ -146,6 +164,7 @@ export function MultiSeriesDots({
|
|
|
146
164
|
color={color ?? colors[i] ?? "#ffffff"}
|
|
147
165
|
radius={radius}
|
|
148
166
|
ring={ring}
|
|
167
|
+
glow={glow}
|
|
149
168
|
ringColor={ringColor}
|
|
150
169
|
pulse={pulse}
|
|
151
170
|
viewEnd={viewEnd}
|
package/src/constants.ts
CHANGED
|
@@ -39,6 +39,13 @@ export const RETURN_TO_LIVE_MS = 450;
|
|
|
39
39
|
*/
|
|
40
40
|
export const SCRUB_OVERLAY_FADE_MS = 150;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Duration (ms) of the candle-focus opacity transition used by
|
|
44
|
+
* `scrub.dimTarget: "otherCandles"`. The selected candle remains fully opaque;
|
|
45
|
+
* the shared candle batch eases toward `dimOpacity` and back to full strength.
|
|
46
|
+
*/
|
|
47
|
+
export const SCRUB_CANDLE_DIM_FADE_MS = 60;
|
|
48
|
+
|
|
42
49
|
/**
|
|
43
50
|
* Base wave amplitude (px) of the breathing loading squiggle — it breathes
|
|
44
51
|
* between `0.4×` and `1.0×` this. Overridable via `loading={{ amplitude }}`.
|
|
@@ -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) ──────────────────────
|
|
@@ -88,6 +89,8 @@ export interface ResolvedBadgeConfig {
|
|
|
88
89
|
|
|
89
90
|
export interface ResolvedYAxisConfig {
|
|
90
91
|
minGap: number;
|
|
92
|
+
/** Multiplier used to choose and align representable dynamic nice intervals. */
|
|
93
|
+
intervalScale: number;
|
|
91
94
|
/** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
|
|
92
95
|
count: number;
|
|
93
96
|
/** undefined → keep the default centered-gutter label placement. */
|
|
@@ -152,8 +155,12 @@ export interface ResolvedScrubConfig {
|
|
|
152
155
|
tooltip: boolean;
|
|
153
156
|
/** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
|
|
154
157
|
seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
|
|
155
|
-
/**
|
|
158
|
+
/** Content affected by dimOpacity while scrubbing. */
|
|
159
|
+
dimTarget: "future" | "otherCandles";
|
|
160
|
+
/** Opacity of the content selected by dimTarget while scrubbing. */
|
|
156
161
|
dimOpacity: number;
|
|
162
|
+
/** Candle-focus dim transition duration in milliseconds. */
|
|
163
|
+
dimFadeMs: number;
|
|
157
164
|
/** undefined → palette.crosshairLine */
|
|
158
165
|
crosshairLineColor: string | undefined;
|
|
159
166
|
/** Vertical crosshair line width in px. */
|
|
@@ -199,9 +206,7 @@ export interface ResolvedScrubConfig {
|
|
|
199
206
|
export interface ResolvedPerSeriesTooltipConfig {
|
|
200
207
|
alwaysShow: boolean;
|
|
201
208
|
bucketSeconds: number | undefined;
|
|
202
|
-
formatSeriesValue:
|
|
203
|
-
| ((value: number, seriesId: string) => string)
|
|
204
|
-
| undefined;
|
|
209
|
+
formatSeriesValue: ((value: number, seriesId: string) => string) | undefined;
|
|
205
210
|
formatTimeRange: ((from: number, to: number) => string) | undefined;
|
|
206
211
|
maxLabelChars: number;
|
|
207
212
|
guideColor: string | undefined;
|
|
@@ -483,6 +488,7 @@ export function resolveBadge(
|
|
|
483
488
|
|
|
484
489
|
const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
|
|
485
490
|
minGap: 36,
|
|
491
|
+
intervalScale: 1,
|
|
486
492
|
count: 0,
|
|
487
493
|
labelRightMargin: undefined,
|
|
488
494
|
gridEndGap: undefined,
|
|
@@ -500,7 +506,14 @@ export function resolveYAxis(
|
|
|
500
506
|
): ResolvedYAxisConfig | null {
|
|
501
507
|
const resolved = resolveToggle(prop, Y_AXIS_DEFAULTS, false);
|
|
502
508
|
if (resolved === null) return null;
|
|
503
|
-
return {
|
|
509
|
+
return {
|
|
510
|
+
...resolved,
|
|
511
|
+
count: Math.max(0, Math.floor(resolved.count)),
|
|
512
|
+
intervalScale:
|
|
513
|
+
Number.isFinite(resolved.intervalScale) && resolved.intervalScale > 0
|
|
514
|
+
? resolved.intervalScale
|
|
515
|
+
: 1,
|
|
516
|
+
};
|
|
504
517
|
}
|
|
505
518
|
|
|
506
519
|
const VOLUME_DEFAULTS: ResolvedVolumeConfig = {
|
|
@@ -688,8 +701,7 @@ export function resolveAxisLabel(
|
|
|
688
701
|
): ResolvedAxisLabelConfig | null {
|
|
689
702
|
const resolved = resolveToggle(prop, AXIS_LABEL_DEFAULTS, false);
|
|
690
703
|
if (!resolved) return null;
|
|
691
|
-
const connectorProp =
|
|
692
|
-
typeof prop === "object" ? prop.connector : undefined;
|
|
704
|
+
const connectorProp = typeof prop === "object" ? prop.connector : undefined;
|
|
693
705
|
return {
|
|
694
706
|
...resolved,
|
|
695
707
|
connector: resolveConnector(
|
|
@@ -716,7 +728,9 @@ export function resolveXAxis(
|
|
|
716
728
|
const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
717
729
|
tooltip: true,
|
|
718
730
|
seriesTooltip: null,
|
|
731
|
+
dimTarget: "future",
|
|
719
732
|
dimOpacity: 0.3,
|
|
733
|
+
dimFadeMs: SCRUB_CANDLE_DIM_FADE_MS,
|
|
720
734
|
crosshairLineColor: undefined,
|
|
721
735
|
crosshairStrokeWidth: 1,
|
|
722
736
|
crosshairOvershoot: 0,
|
|
@@ -770,11 +784,7 @@ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
|
770
784
|
function resolvePerSeriesTooltip(
|
|
771
785
|
prop: boolean | PerSeriesTooltipConfig | undefined,
|
|
772
786
|
): ResolvedPerSeriesTooltipConfig | null {
|
|
773
|
-
const resolved = resolveToggle(
|
|
774
|
-
prop,
|
|
775
|
-
PER_SERIES_TOOLTIP_DEFAULTS,
|
|
776
|
-
false,
|
|
777
|
-
);
|
|
787
|
+
const resolved = resolveToggle(prop, PER_SERIES_TOOLTIP_DEFAULTS, false);
|
|
778
788
|
if (!resolved) return null;
|
|
779
789
|
const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
|
|
780
790
|
if (dash !== undefined) {
|
|
@@ -804,6 +814,19 @@ export function resolveScrub(
|
|
|
804
814
|
const seriesTooltip =
|
|
805
815
|
typeof prop === "object" ? prop.seriesTooltip : undefined;
|
|
806
816
|
resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
|
|
817
|
+
// `resolveToggle` shallow-merges object props, so an explicitly undefined
|
|
818
|
+
// optional field replaces its default. Restore the public defaults before
|
|
819
|
+
// normalizing; otherwise the numeric clamps produce NaN and an undefined
|
|
820
|
+
// dimTarget selects neither candle scrub rendering path.
|
|
821
|
+
resolved.dimTarget = resolved.dimTarget ?? SCRUB_DEFAULTS.dimTarget;
|
|
822
|
+
resolved.dimOpacity = Math.max(
|
|
823
|
+
0,
|
|
824
|
+
Math.min(1, resolved.dimOpacity ?? SCRUB_DEFAULTS.dimOpacity),
|
|
825
|
+
);
|
|
826
|
+
resolved.dimFadeMs = Math.max(
|
|
827
|
+
0,
|
|
828
|
+
resolved.dimFadeMs ?? SCRUB_DEFAULTS.dimFadeMs,
|
|
829
|
+
);
|
|
807
830
|
resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
|
|
808
831
|
resolved.crosshairFadeDistance = Math.max(
|
|
809
832
|
0,
|
|
@@ -1181,7 +1204,39 @@ export function resolveTradeStream(
|
|
|
1181
1204
|
|
|
1182
1205
|
// ─── Dot (shared) ─────────────────────────────────────────────────────────────
|
|
1183
1206
|
|
|
1184
|
-
/** Resolved
|
|
1207
|
+
/** Resolved static dot glow. `color: undefined` means "use the dot color". */
|
|
1208
|
+
export interface ResolvedDotGlowConfig {
|
|
1209
|
+
color: string | undefined;
|
|
1210
|
+
radius: number;
|
|
1211
|
+
blur: number;
|
|
1212
|
+
opacity: number;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const DOT_GLOW_DEFAULTS: ResolvedDotGlowConfig = {
|
|
1216
|
+
color: undefined,
|
|
1217
|
+
radius: 7,
|
|
1218
|
+
blur: 5,
|
|
1219
|
+
opacity: 0.18,
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
/** `undefined`/`false` → off; `true` → restrained defaults; object → merged. */
|
|
1223
|
+
export function resolveDotGlow(
|
|
1224
|
+
prop: boolean | DotGlowConfig | undefined,
|
|
1225
|
+
): ResolvedDotGlowConfig | null {
|
|
1226
|
+
if (!prop) return null;
|
|
1227
|
+
const config = prop === true ? DOT_GLOW_DEFAULTS : prop;
|
|
1228
|
+
return {
|
|
1229
|
+
color: config.color,
|
|
1230
|
+
radius: Math.max(0, config.radius ?? DOT_GLOW_DEFAULTS.radius),
|
|
1231
|
+
blur: Math.max(0, config.blur ?? DOT_GLOW_DEFAULTS.blur),
|
|
1232
|
+
opacity: Math.max(
|
|
1233
|
+
0,
|
|
1234
|
+
Math.min(1, config.opacity ?? DOT_GLOW_DEFAULTS.opacity),
|
|
1235
|
+
),
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Resolved backing ring. `color: undefined` means "use the theme `badgeOuterBg`". */
|
|
1185
1240
|
export interface ResolvedDotRingConfig {
|
|
1186
1241
|
color: string | undefined;
|
|
1187
1242
|
width: number;
|
|
@@ -1203,6 +1258,7 @@ export function resolveDotRing(
|
|
|
1203
1258
|
export interface ResolvedDotConfig {
|
|
1204
1259
|
radius: number;
|
|
1205
1260
|
ring: ResolvedDotRingConfig | null;
|
|
1261
|
+
glow: ResolvedDotGlowConfig | null;
|
|
1206
1262
|
show: boolean;
|
|
1207
1263
|
color: string | undefined;
|
|
1208
1264
|
trackWhileParked: boolean;
|
|
@@ -1211,6 +1267,7 @@ export interface ResolvedDotConfig {
|
|
|
1211
1267
|
const DOT_DEFAULTS: ResolvedDotConfig = {
|
|
1212
1268
|
radius: 3.5,
|
|
1213
1269
|
ring: RING_DEFAULTS,
|
|
1270
|
+
glow: null,
|
|
1214
1271
|
show: true,
|
|
1215
1272
|
color: undefined,
|
|
1216
1273
|
trackWhileParked: false,
|
|
@@ -1231,6 +1288,7 @@ export function resolveDot(
|
|
|
1231
1288
|
return {
|
|
1232
1289
|
radius: prop.radius ?? DOT_DEFAULTS.radius,
|
|
1233
1290
|
ring: resolveDotRing(prop.ring),
|
|
1291
|
+
glow: resolveDotGlow(prop.glow),
|
|
1234
1292
|
show: prop.show ?? DOT_DEFAULTS.show,
|
|
1235
1293
|
color: prop.color,
|
|
1236
1294
|
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/grid.ts
CHANGED
|
@@ -89,6 +89,7 @@ export function pickInterval(
|
|
|
89
89
|
prev: number,
|
|
90
90
|
): number {
|
|
91
91
|
"worklet";
|
|
92
|
+
if (!Number.isFinite(valRange) || !Number.isFinite(pxPerUnit)) return 0;
|
|
92
93
|
if (prev > 0) {
|
|
93
94
|
const px = prev * pxPerUnit;
|
|
94
95
|
if (px >= minGap * 0.5 && px <= minGap * 4) return prev;
|
|
@@ -103,6 +104,9 @@ export function pickInterval(
|
|
|
103
104
|
for (let s = 0; s < divisorSets.length; s++) {
|
|
104
105
|
const divs = divisorSets[s];
|
|
105
106
|
let span = Math.pow(10, Math.ceil(Math.log10(valRange)));
|
|
107
|
+
// `10 ** ceil(log10(Number.MAX_VALUE))` overflows even though the input is
|
|
108
|
+
// finite. Starting from the range itself preserves a finite upper bound.
|
|
109
|
+
if (!Number.isFinite(span) && valRange > 0) span = valRange;
|
|
106
110
|
let i = 0;
|
|
107
111
|
while ((span / divs[i % 3]) * pxPerUnit >= minGap) {
|
|
108
112
|
span /= divs[i % 3];
|
|
@@ -165,6 +169,7 @@ export function computeGridEntries(
|
|
|
165
169
|
minGap = 36,
|
|
166
170
|
grid: GridMetrics = GRID_METRICS_DEFAULTS,
|
|
167
171
|
count = 0,
|
|
172
|
+
intervalScale = 1,
|
|
168
173
|
): { entries: YAxisEntry[]; interval: number } {
|
|
169
174
|
"worklet";
|
|
170
175
|
const chartH = canvasHeight - padTop - padBottom;
|
|
@@ -194,7 +199,29 @@ export function computeGridEntries(
|
|
|
194
199
|
|
|
195
200
|
const pxPerUnit = chartH / valRange;
|
|
196
201
|
|
|
197
|
-
|
|
202
|
+
let appliedIntervalScale = intervalScale;
|
|
203
|
+
let scaledRange = valRange * appliedIntervalScale;
|
|
204
|
+
let pxPerScaledUnit = pxPerUnit / appliedIntervalScale;
|
|
205
|
+
// A positive finite multiplier can still overflow/underflow when combined
|
|
206
|
+
// with the live range. Fall back to source-unit interval selection so an
|
|
207
|
+
// unrepresentable display range cannot poison or freeze this UI worklet.
|
|
208
|
+
if (
|
|
209
|
+
!Number.isFinite(scaledRange) ||
|
|
210
|
+
scaledRange <= 0 ||
|
|
211
|
+
!Number.isFinite(pxPerScaledUnit) ||
|
|
212
|
+
pxPerScaledUnit <= 0
|
|
213
|
+
) {
|
|
214
|
+
appliedIntervalScale = 1;
|
|
215
|
+
scaledRange = valRange;
|
|
216
|
+
pxPerScaledUnit = pxPerUnit;
|
|
217
|
+
}
|
|
218
|
+
const interval = pickInterval(
|
|
219
|
+
scaledRange,
|
|
220
|
+
pxPerScaledUnit,
|
|
221
|
+
minGap,
|
|
222
|
+
appliedIntervalScale === intervalScale ? prevInterval : 0,
|
|
223
|
+
);
|
|
224
|
+
const coarse = interval / appliedIntervalScale;
|
|
198
225
|
const fine = coarse / 2;
|
|
199
226
|
const finePx = fine * pxPerUnit;
|
|
200
227
|
const fineTarget = fineLineTargetAlpha(finePx, minGap);
|
|
@@ -227,7 +254,10 @@ export function computeGridEntries(
|
|
|
227
254
|
fromEdge >= fadeZone ? 1 : fromEdge <= 0 ? 0 : fromEdge / fadeZone;
|
|
228
255
|
|
|
229
256
|
const target = (isCoarse ? 1 : fineTarget) * edgeAlpha;
|
|
230
|
-
|
|
257
|
+
// Snap to the exact fine-grid multiple. Unlike a fixed decimal precision,
|
|
258
|
+
// this keeps adjacent sub-1e-10 source values distinct while remaining
|
|
259
|
+
// stable when the same line is recomputed on later frames.
|
|
260
|
+
const key = Math.round(val / fine) * fine;
|
|
231
261
|
targets[key] = target;
|
|
232
262
|
}
|
|
233
263
|
|
|
@@ -265,12 +295,12 @@ export function computeGridEntries(
|
|
|
265
295
|
const key = Number(allKeys[i]);
|
|
266
296
|
const alpha = labelAlphas[key];
|
|
267
297
|
if (alpha < 0.02) continue;
|
|
268
|
-
const val = key
|
|
298
|
+
const val = key;
|
|
269
299
|
const y = gridValueToY(val, displayMax, valRange, padTop, chartH);
|
|
270
300
|
/* istanbul ignore next -- vertical clip; y stays in-band when val comes from tracked keys */
|
|
271
301
|
if (y < padTop - 10 || y > canvasHeight - padBottom + 10) continue;
|
|
272
302
|
entries.push({ y, label: formatValue(val), alpha });
|
|
273
303
|
}
|
|
274
304
|
|
|
275
|
-
return { entries, interval
|
|
305
|
+
return { entries, interval };
|
|
276
306
|
}
|
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
|
}
|