react-native-livechart 4.10.0 → 4.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/components/CrosshairLine.d.ts +5 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +3 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +19 -12
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +3 -5
- package/dist/components/DotOverlay.d.ts.map +1 -1
- package/dist/components/LeftEdgeFade.d.ts +3 -1
- package/dist/components/LeftEdgeFade.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/LoadingOverlay.d.ts +3 -1
- package/dist/components/LoadingOverlay.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +3 -1
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
- package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
- package/dist/components/MultiSeriesValueLines.d.ts +3 -1
- package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
- package/dist/components/PerSeriesTooltipOverlay.d.ts +19 -0
- package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +20 -6
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +3 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
- package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
- package/dist/core/liveIndicatorVisibility.d.ts +9 -0
- package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
- package/dist/core/resolveConfig.d.ts +29 -0
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +9 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/particleAtlas.d.ts +3 -2
- package/dist/draw/particleAtlas.d.ts.map +1 -1
- package/dist/hooks/crosshairSeries.d.ts +10 -2
- package/dist/hooks/crosshairSeries.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +37 -2
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/delayedPanGuard.d.ts +66 -8
- package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +5 -9
- 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/useCrosshairSeries.d.ts +22 -3
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useMomentum.d.ts +1 -1
- package/dist/hooks/useMomentum.d.ts.map +1 -1
- package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
- package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +15 -1
- package/dist/hooks/usePanScroll.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/lerp.d.ts.map +1 -1
- package/dist/math/momentum.d.ts +1 -1
- package/dist/math/momentum.d.ts.map +1 -1
- package/dist/math/referenceLines.d.ts +7 -0
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/math/squiggly.d.ts +6 -3
- package/dist/math/squiggly.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +2 -2
- package/dist/types.d.ts +184 -16
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +21 -1
- package/src/components/CrosshairOverlay.tsx +48 -35
- package/src/components/CustomReferenceLineOverlay.tsx +58 -20
- package/src/components/DegenParticlesOverlay.tsx +40 -5
- package/src/components/DotOverlay.tsx +24 -8
- package/src/components/LeftEdgeFade.tsx +12 -6
- package/src/components/LiveChart.tsx +186 -53
- package/src/components/LiveChartSeries.tsx +214 -29
- package/src/components/LoadingOverlay.tsx +26 -12
- package/src/components/MarkerOverlay.tsx +20 -3
- package/src/components/MultiSeriesDots.tsx +4 -2
- package/src/components/MultiSeriesTooltipStack.tsx +4 -2
- package/src/components/MultiSeriesValueLabels.tsx +4 -2
- package/src/components/MultiSeriesValueLines.tsx +4 -2
- package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
- package/src/components/ReferenceLineOverlay.tsx +274 -141
- package/src/components/ThresholdSplitShader.tsx +5 -37
- package/src/components/YAxisOverlay.tsx +5 -1
- package/src/components/thresholdSplitShaderSource.ts +70 -0
- package/src/core/liveIndicatorVisibility.ts +25 -0
- package/src/core/resolveConfig.ts +86 -0
- package/src/core/useLiveChartEngine.ts +110 -42
- package/src/core/useLiveChartSeriesEngine.ts +137 -70
- package/src/draw/particleAtlas.ts +21 -10
- package/src/hooks/crosshairSeries.ts +262 -33
- package/src/hooks/crosshairShared.ts +41 -2
- package/src/hooks/delayedPanGuard.ts +111 -6
- package/src/hooks/useChartPaths.ts +36 -15
- package/src/hooks/useCrosshair.ts +73 -14
- package/src/hooks/useCrosshairSeries.ts +106 -13
- package/src/hooks/useMomentum.ts +8 -1
- package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
- package/src/hooks/usePanScroll.ts +46 -5
- package/src/index.ts +2 -0
- package/src/math/lerp.ts +7 -0
- package/src/math/momentum.ts +22 -5
- package/src/math/referenceLines.ts +38 -0
- package/src/math/squiggly.ts +28 -11
- package/src/math/threshold.ts +2 -2
- package/src/types.ts +197 -17
|
@@ -62,7 +62,8 @@ export interface ParticleInstance {
|
|
|
62
62
|
* scale = r / spriteRadius
|
|
63
63
|
* alpha = life * particleOpacity
|
|
64
64
|
* Inactive (active <= 0.5), pre-spawn (dt < 0) and fully-faded (life <= 0)
|
|
65
|
-
* slots are skipped.
|
|
65
|
+
* slots are skipped. Pass distinct `out` and `pool` arrays to retain inactive
|
|
66
|
+
* instance objects for later bursts while returning only the active prefix.
|
|
66
67
|
*/
|
|
67
68
|
export function buildParticleInstances(
|
|
68
69
|
buf: Float64Array,
|
|
@@ -71,9 +72,13 @@ export function buildParticleInstances(
|
|
|
71
72
|
burstDur: number,
|
|
72
73
|
particleOpacity: number,
|
|
73
74
|
spriteRadius: number,
|
|
75
|
+
out?: ParticleInstance[],
|
|
76
|
+
pool?: ParticleInstance[],
|
|
74
77
|
): ParticleInstance[] {
|
|
75
78
|
"worklet";
|
|
76
|
-
const out
|
|
79
|
+
const target = out ?? [];
|
|
80
|
+
const instances = pool ?? target;
|
|
81
|
+
let count = 0;
|
|
77
82
|
for (let i = 0; i < slots; i++) {
|
|
78
83
|
const base = i * DEGEN_STRIDE;
|
|
79
84
|
if (!(buf[base + 5] > 0.5)) continue;
|
|
@@ -83,13 +88,19 @@ export function buildParticleInstances(
|
|
|
83
88
|
if (life <= 0) continue;
|
|
84
89
|
const size = buf[base + 6] || 1;
|
|
85
90
|
const r = size * (0.5 + life * 0.5);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
y:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
let instance = instances[count];
|
|
92
|
+
if (!instance) {
|
|
93
|
+
instance = { x: 0, y: 0, scale: 0, alpha: 0, colorIndex: 0 };
|
|
94
|
+
instances[count] = instance;
|
|
95
|
+
}
|
|
96
|
+
instance.x = buf[base + 0];
|
|
97
|
+
instance.y = buf[base + 1];
|
|
98
|
+
instance.scale = r / spriteRadius;
|
|
99
|
+
instance.alpha = life * particleOpacity;
|
|
100
|
+
instance.colorIndex = Math.max(0, Math.floor(buf[base + 7]));
|
|
101
|
+
target[count] = instance;
|
|
102
|
+
count += 1;
|
|
93
103
|
}
|
|
94
|
-
|
|
104
|
+
target.length = count;
|
|
105
|
+
return target;
|
|
95
106
|
}
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { type SkFont } from "@shopify/react-native-skia";
|
|
2
|
+
import { MAX_MULTI_SERIES } from "../constants";
|
|
3
|
+
import type { ResolvedPerSeriesTooltipConfig } from "../core/resolveConfig";
|
|
2
4
|
import { type ChartPadding } from "../draw/line";
|
|
5
|
+
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
3
6
|
import { interpolateAtTime } from "../math/interpolate";
|
|
4
7
|
import type { ScrubSeriesValue, SeriesConfig } from "../types";
|
|
5
8
|
import {
|
|
6
|
-
computeTooltipLayoutMulti,
|
|
7
9
|
HIDDEN_TOOLTIP,
|
|
8
10
|
type TooltipLayout,
|
|
9
11
|
} from "./crosshairShared";
|
|
10
12
|
|
|
13
|
+
const TOOLTIP_EDGE_GAP = 4;
|
|
14
|
+
const TOOLTIP_SIDE_GAP = 8;
|
|
15
|
+
const TOOLTIP_STACK_GAP = 4;
|
|
16
|
+
const TOOLTIP_TOP_GAP = 4;
|
|
17
|
+
|
|
18
|
+
function valueAtTime(series: SeriesConfig, time: number): number | null {
|
|
19
|
+
"worklet";
|
|
20
|
+
return interpolateAtTime(series.data, time);
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
export function interpolateSeriesAtTime(
|
|
12
24
|
series: SeriesConfig[],
|
|
13
25
|
time: number,
|
|
@@ -17,7 +29,7 @@ export function interpolateSeriesAtTime(
|
|
|
17
29
|
let primary: number | null = null;
|
|
18
30
|
for (let i = 0; i < series.length; i++) {
|
|
19
31
|
if (series[i].visible === false) continue;
|
|
20
|
-
const v =
|
|
32
|
+
const v = valueAtTime(series[i], time);
|
|
21
33
|
if (v === null) continue;
|
|
22
34
|
seriesValues.push({
|
|
23
35
|
id: series[i].id,
|
|
@@ -29,50 +41,267 @@ export function interpolateSeriesAtTime(
|
|
|
29
41
|
return { primary, seriesValues };
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
/**
|
|
33
|
-
export function
|
|
34
|
-
scrubActive: boolean,
|
|
35
|
-
scrubTime: number,
|
|
36
|
-
series: SeriesConfig[],
|
|
37
|
-
): number | null {
|
|
44
|
+
/** Infer a bucket width from the latest positive interval in a visible series. */
|
|
45
|
+
export function estimateSeriesBucketSeconds(series: SeriesConfig[]): number {
|
|
38
46
|
"worklet";
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
for (let i = 0; i < series.length; i++) {
|
|
48
|
+
if (series[i].visible === false) continue;
|
|
49
|
+
const points = series[i].data;
|
|
50
|
+
for (let j = points.length - 1; j > 0; j--) {
|
|
51
|
+
const delta = points[j].time - points[j - 1].time;
|
|
52
|
+
if (delta > 0) return delta;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Truncate a series label with the same ellipsis treatment as Morfi web. */
|
|
59
|
+
export function truncateSeriesTooltipLabel(
|
|
60
|
+
label: string,
|
|
61
|
+
maxChars: number,
|
|
62
|
+
): string {
|
|
63
|
+
"worklet";
|
|
64
|
+
if (label.length <= maxChars) return label;
|
|
65
|
+
return `${label.slice(0, Math.max(1, maxChars - 1))}…`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clamp(value: number, min: number, max: number): number {
|
|
69
|
+
"worklet";
|
|
70
|
+
if (max < min) return min;
|
|
71
|
+
return value < min ? min : value > max ? max : value;
|
|
41
72
|
}
|
|
42
73
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Morfi-style per-series scrub tooltip geometry. Pure/worklet-safe so the
|
|
76
|
+
* entire readout can be recomputed on the UI thread without React renders.
|
|
77
|
+
*/
|
|
78
|
+
export function computePerSeriesTooltipLayout(
|
|
45
79
|
scrubActive: boolean,
|
|
46
80
|
scrubX: number,
|
|
47
81
|
scrubTime: number,
|
|
48
82
|
series: SeriesConfig[],
|
|
83
|
+
displaySeriesValues: number[],
|
|
84
|
+
colors: string[],
|
|
85
|
+
displayMin: number,
|
|
86
|
+
displayMax: number,
|
|
49
87
|
padding: ChartPadding,
|
|
50
88
|
canvasWidth: number,
|
|
51
|
-
|
|
52
|
-
|
|
89
|
+
canvasHeight: number,
|
|
90
|
+
maxTime: number,
|
|
91
|
+
formatValue: (value: number) => string,
|
|
92
|
+
formatTime: (time: number) => string,
|
|
53
93
|
font: SkFont,
|
|
94
|
+
config: ResolvedPerSeriesTooltipConfig,
|
|
54
95
|
): TooltipLayout {
|
|
55
96
|
"worklet";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
97
|
+
const pinned = !scrubActive && config.alwaysShow;
|
|
98
|
+
if ((!scrubActive && !pinned) || canvasWidth <= 0 || canvasHeight <= 0) {
|
|
99
|
+
return HIDDEN_TOOLTIP;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const plotLeft = padding.left;
|
|
103
|
+
const plotRight = canvasWidth - padding.right;
|
|
104
|
+
const plotTop = padding.top;
|
|
105
|
+
const plotBottom = canvasHeight - padding.bottom;
|
|
106
|
+
if (plotRight <= plotLeft || plotBottom <= plotTop) return HIDDEN_TOOLTIP;
|
|
107
|
+
|
|
108
|
+
const anchorX = pinned
|
|
109
|
+
? plotRight
|
|
110
|
+
: clamp(scrubX, plotLeft, plotRight);
|
|
111
|
+
const activeTime = Math.min(scrubTime, maxTime);
|
|
112
|
+
if (!pinned && activeTime < 0) return HIDDEN_TOOLTIP;
|
|
113
|
+
|
|
114
|
+
const fm = font.getMetrics();
|
|
115
|
+
const lineH = Math.max(1, -fm.ascent + fm.descent);
|
|
116
|
+
const seriesPillH = lineH + config.seriesPillPaddingY * 2;
|
|
117
|
+
const maxPillW = Math.max(1, plotRight - plotLeft - TOOLTIP_EDGE_GAP * 2);
|
|
118
|
+
const valueFormatter = config.formatSeriesValue;
|
|
119
|
+
|
|
120
|
+
const pills: NonNullable<
|
|
121
|
+
NonNullable<TooltipLayout["perSeries"]>["pills"]
|
|
122
|
+
> = [];
|
|
123
|
+
const preferredY: number[] = [];
|
|
124
|
+
const renderedSeriesCount = Math.min(series.length, MAX_MULTI_SERIES);
|
|
125
|
+
for (let i = 0; i < renderedSeriesCount; i++) {
|
|
126
|
+
const item = series[i];
|
|
127
|
+
if (item.visible === false || item.data.length === 0) continue;
|
|
128
|
+
const value = pinned
|
|
129
|
+
? (displaySeriesValues[i] ?? item.value)
|
|
130
|
+
: valueAtTime(item, activeTime);
|
|
131
|
+
if (value === null || !Number.isFinite(value)) continue;
|
|
132
|
+
|
|
133
|
+
const label = truncateSeriesTooltipLabel(
|
|
134
|
+
item.label ?? item.id,
|
|
135
|
+
config.maxLabelChars,
|
|
136
|
+
);
|
|
137
|
+
const valueText = valueFormatter
|
|
138
|
+
? valueFormatter(value, item.id)
|
|
139
|
+
: formatValue(value);
|
|
140
|
+
const labelW = measureFontTextWidth(font, label);
|
|
141
|
+
const valueW = measureFontTextWidth(font, valueText);
|
|
142
|
+
const labelGap = label.length > 0 ? config.seriesPillLabelValueGap : 0;
|
|
143
|
+
const rawPillW =
|
|
144
|
+
config.seriesPillPaddingX * 2 +
|
|
145
|
+
config.seriesPillDotSize +
|
|
146
|
+
config.seriesPillDotGap +
|
|
147
|
+
labelW +
|
|
148
|
+
labelGap +
|
|
149
|
+
valueW;
|
|
150
|
+
const pillW = Math.min(rawPillW, maxPillW);
|
|
151
|
+
let pillX = anchorX + TOOLTIP_SIDE_GAP;
|
|
152
|
+
if (pillX + pillW > plotRight - TOOLTIP_EDGE_GAP) {
|
|
153
|
+
pillX = anchorX - pillW - TOOLTIP_SIDE_GAP;
|
|
154
|
+
}
|
|
155
|
+
pillX = clamp(
|
|
156
|
+
pillX,
|
|
157
|
+
plotLeft + TOOLTIP_EDGE_GAP,
|
|
158
|
+
plotRight - TOOLTIP_EDGE_GAP - pillW,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const chartH = plotBottom - plotTop;
|
|
162
|
+
const valRange = displayMax - displayMin;
|
|
163
|
+
const anchorY =
|
|
164
|
+
valRange === 0
|
|
165
|
+
? plotTop + chartH / 2
|
|
166
|
+
: clamp(
|
|
167
|
+
plotTop + ((displayMax - value) / valRange) * chartH,
|
|
168
|
+
plotTop,
|
|
169
|
+
plotBottom,
|
|
170
|
+
);
|
|
171
|
+
const dotX =
|
|
172
|
+
pillX + config.seriesPillPaddingX + config.seriesPillDotSize / 2;
|
|
173
|
+
const dotY = anchorY;
|
|
174
|
+
const labelX =
|
|
175
|
+
dotX + config.seriesPillDotSize / 2 + config.seriesPillDotGap;
|
|
176
|
+
const valueX = labelX + labelW + labelGap;
|
|
177
|
+
pills.push({
|
|
178
|
+
id: item.id,
|
|
179
|
+
color: colors[i] ?? item.color ?? "#ffffff",
|
|
180
|
+
anchorX,
|
|
181
|
+
anchorY,
|
|
182
|
+
x: pillX,
|
|
183
|
+
y: anchorY - seriesPillH / 2,
|
|
184
|
+
w: pillW,
|
|
185
|
+
h: seriesPillH,
|
|
186
|
+
dotX,
|
|
187
|
+
dotY,
|
|
188
|
+
label,
|
|
189
|
+
labelX,
|
|
190
|
+
value: valueText,
|
|
191
|
+
valueX,
|
|
192
|
+
baselineY: 0,
|
|
68
193
|
});
|
|
194
|
+
preferredY.push(anchorY - seriesPillH / 2);
|
|
69
195
|
}
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
196
|
+
if (pills.length === 0) return HIDDEN_TOOLTIP;
|
|
197
|
+
|
|
198
|
+
let timePill: NonNullable<
|
|
199
|
+
NonNullable<TooltipLayout["perSeries"]>["timePill"]
|
|
200
|
+
> | undefined;
|
|
201
|
+
if (!pinned) {
|
|
202
|
+
const bucketSeconds =
|
|
203
|
+
config.bucketSeconds ?? estimateSeriesBucketSeconds(series);
|
|
204
|
+
const from = activeTime;
|
|
205
|
+
const to = Math.max(from, Math.min(from + bucketSeconds, maxTime));
|
|
206
|
+
const timeText = config.formatTimeRange
|
|
207
|
+
? config.formatTimeRange(from, to)
|
|
208
|
+
: bucketSeconds > 0
|
|
209
|
+
? `${formatTime(from)} – ${formatTime(to)}`
|
|
210
|
+
: formatTime(from);
|
|
211
|
+
const timeW = Math.min(
|
|
212
|
+
measureFontTextWidth(font, timeText) + config.timePillPaddingX * 2,
|
|
213
|
+
maxPillW,
|
|
214
|
+
);
|
|
215
|
+
const timeH = lineH + config.timePillPaddingY * 2;
|
|
216
|
+
const timeX = clamp(
|
|
217
|
+
anchorX - timeW / 2,
|
|
218
|
+
plotLeft + TOOLTIP_EDGE_GAP,
|
|
219
|
+
plotRight - TOOLTIP_EDGE_GAP - timeW,
|
|
220
|
+
);
|
|
221
|
+
const timeY = plotTop + TOOLTIP_TOP_GAP;
|
|
222
|
+
timePill = {
|
|
223
|
+
x: timeX,
|
|
224
|
+
y: timeY,
|
|
225
|
+
w: timeW,
|
|
226
|
+
h: timeH,
|
|
227
|
+
text: timeText,
|
|
228
|
+
textX: timeX + config.timePillPaddingX,
|
|
229
|
+
baselineY: timeY + config.timePillPaddingY - fm.ascent,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Collision avoidance: sort by preferred position, push down, then pull the
|
|
234
|
+
// stack back up from the bottom. If the plot is extremely short, compress the
|
|
235
|
+
// inter-pill gap to zero before accepting overlap as the unavoidable fallback.
|
|
236
|
+
const topBound = Math.min(
|
|
237
|
+
plotBottom - seriesPillH,
|
|
238
|
+
timePill
|
|
239
|
+
? Math.max(plotTop, timePill.y + timePill.h + TOOLTIP_STACK_GAP)
|
|
240
|
+
: plotTop,
|
|
77
241
|
);
|
|
242
|
+
const bottomBound = plotBottom;
|
|
243
|
+
const order = pills.map((_, index) => index);
|
|
244
|
+
order.sort((a, b) => preferredY[a] - preferredY[b]);
|
|
245
|
+
|
|
246
|
+
let stackGap = TOOLTIP_STACK_GAP;
|
|
247
|
+
const availableH = Math.max(0, bottomBound - topBound);
|
|
248
|
+
const requiredWithoutGaps = pills.length * seriesPillH;
|
|
249
|
+
if (pills.length > 1 && requiredWithoutGaps + stackGap * (pills.length - 1) > availableH) {
|
|
250
|
+
stackGap = Math.max(
|
|
251
|
+
0,
|
|
252
|
+
(availableH - requiredWithoutGaps) / (pills.length - 1),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let cursor = topBound;
|
|
257
|
+
for (let i = 0; i < order.length; i++) {
|
|
258
|
+
const index = order[i];
|
|
259
|
+
const y = clamp(
|
|
260
|
+
Math.max(preferredY[index], cursor),
|
|
261
|
+
topBound,
|
|
262
|
+
Math.max(topBound, bottomBound - seriesPillH),
|
|
263
|
+
);
|
|
264
|
+
pills[index].y = y;
|
|
265
|
+
cursor = y + seriesPillH + stackGap;
|
|
266
|
+
}
|
|
267
|
+
for (let i = order.length - 2; i >= 0; i--) {
|
|
268
|
+
const index = order[i];
|
|
269
|
+
const next = pills[order[i + 1]];
|
|
270
|
+
pills[index].y = Math.max(
|
|
271
|
+
topBound,
|
|
272
|
+
Math.min(pills[index].y, next.y - seriesPillH - stackGap),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
for (let i = 0; i < pills.length; i++) {
|
|
276
|
+
pills[i].dotY = pills[i].y + pills[i].h / 2;
|
|
277
|
+
pills[i].baselineY =
|
|
278
|
+
pills[i].y + config.seriesPillPaddingY - fm.ascent;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const firstRect = timePill ?? pills[0];
|
|
282
|
+
return {
|
|
283
|
+
x: firstRect.x,
|
|
284
|
+
y: firstRect.y,
|
|
285
|
+
w: firstRect.w,
|
|
286
|
+
h: firstRect.h,
|
|
287
|
+
valueStr: "",
|
|
288
|
+
timeStr: "",
|
|
289
|
+
valueTextX: -400,
|
|
290
|
+
timeTextX: -400,
|
|
291
|
+
line1Y: 0,
|
|
292
|
+
line2Y: 0,
|
|
293
|
+
stackedLines: undefined,
|
|
294
|
+
perSeries: { pinned, timePill, pills },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Series-chart scrub primary value at window time — extracted for tests. */
|
|
299
|
+
export function deriveScrubValueSeries(
|
|
300
|
+
scrubActive: boolean,
|
|
301
|
+
scrubTime: number,
|
|
302
|
+
series: SeriesConfig[],
|
|
303
|
+
): number | null {
|
|
304
|
+
"worklet";
|
|
305
|
+
if (!scrubActive || scrubTime < 0) return null;
|
|
306
|
+
return interpolateSeriesAtTime(series, scrubTime).primary;
|
|
78
307
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SkFont } from "@shopify/react-native-skia";
|
|
2
2
|
import { type Gesture } from "react-native-gesture-handler";
|
|
3
|
-
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
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";
|
|
@@ -14,6 +14,11 @@ const TOOLTIP_EDGE_GAP = 4;
|
|
|
14
14
|
const TOOLTIP_TOP_MARGIN = 8;
|
|
15
15
|
const FADE_ZONE = 4;
|
|
16
16
|
|
|
17
|
+
/** Horizontal travel required before a plain scrub claims the touch. */
|
|
18
|
+
export const SCRUB_ACTIVATE_X_PX = 20;
|
|
19
|
+
/** Vertical travel that fails a plain scrub so a parent scroll gesture can win. */
|
|
20
|
+
export const SCRUB_FAIL_Y_PX = 10;
|
|
21
|
+
|
|
17
22
|
/** Measure rendered text so the pill and its content share the same centre. */
|
|
18
23
|
function measureTooltipTextWidth(
|
|
19
24
|
font: SkFont,
|
|
@@ -43,6 +48,37 @@ export interface TooltipLayout {
|
|
|
43
48
|
baselineY: number;
|
|
44
49
|
dim: boolean;
|
|
45
50
|
}[];
|
|
51
|
+
/** LiveChartSeries' opt-in Morfi-style time pill + per-series value pills. */
|
|
52
|
+
perSeries?: {
|
|
53
|
+
/** Idle endpoint mode: value pills only, with no guide or time pill. */
|
|
54
|
+
pinned: boolean;
|
|
55
|
+
timePill?: {
|
|
56
|
+
x: number;
|
|
57
|
+
y: number;
|
|
58
|
+
w: number;
|
|
59
|
+
h: number;
|
|
60
|
+
text: string;
|
|
61
|
+
textX: number;
|
|
62
|
+
baselineY: number;
|
|
63
|
+
};
|
|
64
|
+
pills: {
|
|
65
|
+
id: string;
|
|
66
|
+
color: string;
|
|
67
|
+
anchorX: number;
|
|
68
|
+
anchorY: number;
|
|
69
|
+
x: number;
|
|
70
|
+
y: number;
|
|
71
|
+
w: number;
|
|
72
|
+
h: number;
|
|
73
|
+
dotX: number;
|
|
74
|
+
dotY: number;
|
|
75
|
+
label: string;
|
|
76
|
+
labelX: number;
|
|
77
|
+
value: string;
|
|
78
|
+
valueX: number;
|
|
79
|
+
baselineY: number;
|
|
80
|
+
}[];
|
|
81
|
+
};
|
|
46
82
|
}
|
|
47
83
|
|
|
48
84
|
export const HIDDEN_TOOLTIP: TooltipLayout = {
|
|
@@ -57,6 +93,7 @@ export const HIDDEN_TOOLTIP: TooltipLayout = {
|
|
|
57
93
|
line1Y: 0,
|
|
58
94
|
line2Y: 0,
|
|
59
95
|
stackedLines: undefined,
|
|
96
|
+
perSeries: undefined,
|
|
60
97
|
};
|
|
61
98
|
|
|
62
99
|
export interface CrosshairState {
|
|
@@ -65,7 +102,9 @@ export interface CrosshairState {
|
|
|
65
102
|
scrubTime: SharedValue<number>;
|
|
66
103
|
scrubValue: SharedValue<number | null>;
|
|
67
104
|
crosshairOpacity: SharedValue<number>;
|
|
68
|
-
tooltipLayout:
|
|
105
|
+
tooltipLayout:
|
|
106
|
+
| SharedValue<TooltipLayout>
|
|
107
|
+
| DerivedValue<TooltipLayout>;
|
|
69
108
|
/** Scrub intersection Y in canvas px; -1 when there's no dot to draw
|
|
70
109
|
* (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
|
|
71
110
|
scrubDotY: SharedValue<number>;
|
|
@@ -1,22 +1,95 @@
|
|
|
1
1
|
import type { SharedValue } from "react-native-reanimated";
|
|
2
2
|
|
|
3
3
|
type BooleanSharedValue = Pick<SharedValue<boolean>, "get" | "set">;
|
|
4
|
+
type NumberSharedValue = Pick<SharedValue<number>, "get" | "set">;
|
|
5
|
+
|
|
6
|
+
type TouchPoint = { x: number; y: number };
|
|
4
7
|
|
|
5
8
|
type TouchCountEvent = {
|
|
6
9
|
numberOfTouches: number;
|
|
7
10
|
};
|
|
8
11
|
|
|
12
|
+
type TouchesEvent = TouchCountEvent & {
|
|
13
|
+
changedTouches: TouchPoint[];
|
|
14
|
+
allTouches: TouchPoint[];
|
|
15
|
+
};
|
|
16
|
+
|
|
9
17
|
type GestureStateManager = {
|
|
10
18
|
fail: () => void;
|
|
11
19
|
};
|
|
12
20
|
|
|
13
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* A pending hold-to-scrub pan must stay within this radius of its touch-down
|
|
23
|
+
* point to activate. Matches the platform long-press convention (RNGH
|
|
24
|
+
* LongPress `maxDist` / UIKit `allowableMovement` default 10) with a little
|
|
25
|
+
* slack for thumbs.
|
|
26
|
+
*/
|
|
27
|
+
export const HOLD_MAX_DRIFT_PX = 12;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An activation earlier than `delayMs - slack` after the current touch went
|
|
31
|
+
* down cannot be a real hold — it is a stale `activateAfterLongPress` timer
|
|
32
|
+
* carried over from a previous touch (see `shouldStartDelayedPan`).
|
|
33
|
+
*/
|
|
34
|
+
export const HOLD_TIMER_SLACK_MS = 50;
|
|
35
|
+
|
|
36
|
+
/** Record the first pointer of a delayed pan going down. Zero-delay pans are inert. */
|
|
14
37
|
export function delayedPanTouchDown(
|
|
15
38
|
delayMs: number,
|
|
39
|
+
event: TouchesEvent,
|
|
16
40
|
fingerDown: BooleanSharedValue,
|
|
41
|
+
downX: NumberSharedValue,
|
|
42
|
+
downY: NumberSharedValue,
|
|
43
|
+
downAtMs: NumberSharedValue,
|
|
44
|
+
holdBroken: BooleanSharedValue,
|
|
45
|
+
): void {
|
|
46
|
+
"worklet";
|
|
47
|
+
if (delayMs <= 0) return;
|
|
48
|
+
fingerDown.set(true);
|
|
49
|
+
const t = event.changedTouches[0];
|
|
50
|
+
if (t) {
|
|
51
|
+
downX.set(t.x);
|
|
52
|
+
downY.set(t.y);
|
|
53
|
+
}
|
|
54
|
+
downAtMs.set(Date.now());
|
|
55
|
+
holdBroken.set(false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enforce the stationary hold: while the delayed pan is still pending, any
|
|
60
|
+
* drift beyond HOLD_MAX_DRIFT_PX fails it outright AND latches `holdBroken`.
|
|
61
|
+
*
|
|
62
|
+
* `manager.fail()` alone is not enough. It only lands if the recognizer is
|
|
63
|
+
* still in a state that can fail, and — on iOS — the native
|
|
64
|
+
* `activateAfterLongPress` timer fires `handleGesture:inState:Active`
|
|
65
|
+
* unconditionally (RNPanHandler.m), so a timer that survived the fail still
|
|
66
|
+
* emits ACTIVE. The latch makes "the finger moved before the hold elapsed" a
|
|
67
|
+
* decision `shouldStartDelayedPan` can re-check at activation time, instead of
|
|
68
|
+
* trusting the native recognizer to have stayed failed.
|
|
69
|
+
*
|
|
70
|
+
* The latch also covers movement that returns to its origin: a finger that
|
|
71
|
+
* wanders 40px away and comes back is within the slop when the timer fires, but
|
|
72
|
+
* it was never a stationary hold.
|
|
73
|
+
*/
|
|
74
|
+
export function delayedPanTouchMove(
|
|
75
|
+
delayMs: number,
|
|
76
|
+
event: TouchesEvent,
|
|
77
|
+
manager: GestureStateManager,
|
|
78
|
+
panActivated: BooleanSharedValue,
|
|
79
|
+
downX: NumberSharedValue,
|
|
80
|
+
downY: NumberSharedValue,
|
|
81
|
+
holdBroken: BooleanSharedValue,
|
|
17
82
|
): void {
|
|
18
83
|
"worklet";
|
|
19
|
-
if (delayMs
|
|
84
|
+
if (delayMs <= 0 || panActivated.get()) return;
|
|
85
|
+
const t = event.allTouches[0];
|
|
86
|
+
if (!t) return;
|
|
87
|
+
const dx = t.x - downX.get();
|
|
88
|
+
const dy = t.y - downY.get();
|
|
89
|
+
if (dx * dx + dy * dy > HOLD_MAX_DRIFT_PX * HOLD_MAX_DRIFT_PX) {
|
|
90
|
+
holdBroken.set(true);
|
|
91
|
+
manager.fail();
|
|
92
|
+
}
|
|
20
93
|
}
|
|
21
94
|
|
|
22
95
|
/**
|
|
@@ -49,15 +122,37 @@ export function delayedPanTouchCancelled(
|
|
|
49
122
|
}
|
|
50
123
|
|
|
51
124
|
/**
|
|
52
|
-
* Record a real delayed-pan activation, or reject
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
125
|
+
* Record a real delayed-pan activation, or reject a spurious one. Two spurious
|
|
126
|
+
* shapes exist, both from RNGH's long-press timer surviving where it shouldn't:
|
|
127
|
+
*
|
|
128
|
+
* 1. The timer fires after the final pointer already lifted (`fingerDown`
|
|
129
|
+
* false) — the original touch-up race.
|
|
130
|
+
* 2. The timer fires DURING a later touch: fast successive taps can land a new
|
|
131
|
+
* pointer before the failed recognizer's native reset completes, so the
|
|
132
|
+
* previous tap's timer fires only `now - downAtMs < delayMs` into the new
|
|
133
|
+
* touch. A real hold can never activate early, so reject anything faster
|
|
134
|
+
* than `delayMs - HOLD_TIMER_SLACK_MS`.
|
|
135
|
+
*
|
|
136
|
+
* 3. The timer fires after the finger already moved off the touch-down point
|
|
137
|
+
* (`holdBroken`) — it was a drag, not a hold. See `delayedPanTouchMove`.
|
|
138
|
+
*
|
|
139
|
+
* 4. The timer fires after the time-scroll pan already took the touch
|
|
140
|
+
* (`scrollActive`) — the chart is mid-drag, so the hold lost the race. The
|
|
141
|
+
* two pans are composed with `Gesture.Race`, which in RNGH declares NO
|
|
142
|
+
* relation between them (`RaceGestureType = ComposedGestureType`), so
|
|
143
|
+
* nothing native stops a losing recognizer from activating later. This latch
|
|
144
|
+
* is the arbitration.
|
|
145
|
+
*
|
|
146
|
+
* All rejected paths deliberately keep `panActivated` false so a missing
|
|
147
|
+
* native FINALIZE cannot poison the next interaction.
|
|
56
148
|
*/
|
|
57
149
|
export function shouldStartDelayedPan(
|
|
58
150
|
delayMs: number,
|
|
59
151
|
fingerDown: BooleanSharedValue,
|
|
60
152
|
panActivated: BooleanSharedValue,
|
|
153
|
+
downAtMs: NumberSharedValue,
|
|
154
|
+
holdBroken: BooleanSharedValue,
|
|
155
|
+
scrollActive: BooleanSharedValue,
|
|
61
156
|
): boolean {
|
|
62
157
|
"worklet";
|
|
63
158
|
if (delayMs <= 0) return true;
|
|
@@ -65,6 +160,14 @@ export function shouldStartDelayedPan(
|
|
|
65
160
|
panActivated.set(false);
|
|
66
161
|
return false;
|
|
67
162
|
}
|
|
163
|
+
if (Date.now() - downAtMs.get() < delayMs - HOLD_TIMER_SLACK_MS) {
|
|
164
|
+
panActivated.set(false);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (holdBroken.get() || scrollActive.get()) {
|
|
168
|
+
panActivated.set(false);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
68
171
|
panActivated.set(true);
|
|
69
172
|
return true;
|
|
70
173
|
}
|
|
@@ -73,8 +176,10 @@ export function shouldStartDelayedPan(
|
|
|
73
176
|
export function resetDelayedPanGuard(
|
|
74
177
|
fingerDown: BooleanSharedValue,
|
|
75
178
|
panActivated: BooleanSharedValue,
|
|
179
|
+
holdBroken: BooleanSharedValue,
|
|
76
180
|
): void {
|
|
77
181
|
"worklet";
|
|
78
182
|
fingerDown.set(false);
|
|
79
183
|
panActivated.set(false);
|
|
184
|
+
holdBroken.set(false);
|
|
80
185
|
}
|