react-native-livechart 4.11.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/dist/components/CrosshairLine.d.ts +3 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
- package/dist/components/CustomReferenceLineOverlay.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/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.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/YAxisOverlay.d.ts +3 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- 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/hooks/crosshairSeries.d.ts +10 -2
- package/dist/hooks/crosshairSeries.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +33 -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/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/types.d.ts +160 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +4 -1
- package/src/components/CustomReferenceLineOverlay.tsx +42 -13
- package/src/components/DotOverlay.tsx +24 -8
- package/src/components/LiveChart.tsx +157 -37
- package/src/components/LiveChartSeries.tsx +128 -16
- package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
- package/src/components/ReferenceLineOverlay.tsx +274 -141
- package/src/components/YAxisOverlay.tsx +5 -1
- package/src/core/liveIndicatorVisibility.ts +25 -0
- package/src/core/resolveConfig.ts +86 -0
- package/src/hooks/crosshairSeries.ts +262 -33
- package/src/hooks/crosshairShared.ts +36 -2
- package/src/hooks/delayedPanGuard.ts +111 -6
- package/src/hooks/useChartPaths.ts +25 -15
- package/src/hooks/useCrosshair.ts +57 -4
- package/src/hooks/useCrosshairSeries.ts +95 -7
- package/src/hooks/useMomentum.ts +8 -1
- package/src/hooks/usePanScroll.ts +46 -5
- package/src/index.ts +1 -0
- package/src/math/lerp.ts +7 -0
- package/src/math/momentum.ts +22 -5
- package/src/math/referenceLines.ts +38 -0
- package/src/types.ts +168 -18
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ReferenceLine,
|
|
25
25
|
ScrubActionConfig,
|
|
26
26
|
ScrubConfig,
|
|
27
|
+
PerSeriesTooltipConfig,
|
|
27
28
|
SelectionDotConfig,
|
|
28
29
|
SelectionDotProps,
|
|
29
30
|
SelectionDotRingConfig,
|
|
@@ -144,6 +145,8 @@ export interface ResolvedXAxisConfig {
|
|
|
144
145
|
|
|
145
146
|
export interface ResolvedScrubConfig {
|
|
146
147
|
tooltip: boolean;
|
|
148
|
+
/** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
|
|
149
|
+
seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
|
|
147
150
|
/** Opacity of content right of the crosshair while scrubbing (dstOut fade). */
|
|
148
151
|
dimOpacity: number;
|
|
149
152
|
/** undefined → palette.crosshairLine */
|
|
@@ -174,6 +177,36 @@ export interface ResolvedScrubConfig {
|
|
|
174
177
|
hideOverlaysOnScrub: boolean;
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
export interface ResolvedPerSeriesTooltipConfig {
|
|
181
|
+
alwaysShow: boolean;
|
|
182
|
+
bucketSeconds: number | undefined;
|
|
183
|
+
formatSeriesValue:
|
|
184
|
+
| ((value: number, seriesId: string) => string)
|
|
185
|
+
| undefined;
|
|
186
|
+
formatTimeRange: ((from: number, to: number) => string) | undefined;
|
|
187
|
+
maxLabelChars: number;
|
|
188
|
+
guideColor: string | undefined;
|
|
189
|
+
guideWidth: number;
|
|
190
|
+
guideDashPattern: number[] | undefined;
|
|
191
|
+
timePillBackground: string | undefined;
|
|
192
|
+
timePillColor: string | undefined;
|
|
193
|
+
timePillBorderColor: string | undefined;
|
|
194
|
+
timePillRadius: number;
|
|
195
|
+
timePillPaddingX: number;
|
|
196
|
+
timePillPaddingY: number;
|
|
197
|
+
seriesPillBackground: string | undefined;
|
|
198
|
+
seriesPillLabelColor: string | undefined;
|
|
199
|
+
seriesPillValueColor: string | undefined;
|
|
200
|
+
seriesPillBorderColor: string | undefined;
|
|
201
|
+
seriesPillRadius: number;
|
|
202
|
+
seriesPillPaddingX: number;
|
|
203
|
+
seriesPillPaddingY: number;
|
|
204
|
+
seriesPillDotSize: number;
|
|
205
|
+
seriesPillDotGap: number;
|
|
206
|
+
seriesPillLabelValueGap: number;
|
|
207
|
+
intersectionDotSize: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
export interface ResolvedScrubActionConfig {
|
|
178
211
|
/** Glyph drawn in the action badge. */
|
|
179
212
|
icon: string;
|
|
@@ -628,6 +661,7 @@ export function resolveXAxis(
|
|
|
628
661
|
|
|
629
662
|
const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
630
663
|
tooltip: true,
|
|
664
|
+
seriesTooltip: null,
|
|
631
665
|
dimOpacity: 0.3,
|
|
632
666
|
crosshairLineColor: undefined,
|
|
633
667
|
crosshairDash: undefined,
|
|
@@ -644,6 +678,55 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
|
644
678
|
hideOverlaysOnScrub: false,
|
|
645
679
|
};
|
|
646
680
|
|
|
681
|
+
const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
682
|
+
alwaysShow: false,
|
|
683
|
+
bucketSeconds: undefined,
|
|
684
|
+
formatSeriesValue: undefined,
|
|
685
|
+
formatTimeRange: undefined,
|
|
686
|
+
maxLabelChars: 14,
|
|
687
|
+
guideColor: undefined,
|
|
688
|
+
guideWidth: 1,
|
|
689
|
+
guideDashPattern: [3, 3],
|
|
690
|
+
timePillBackground: undefined,
|
|
691
|
+
timePillColor: undefined,
|
|
692
|
+
timePillBorderColor: undefined,
|
|
693
|
+
timePillRadius: 6,
|
|
694
|
+
timePillPaddingX: 8,
|
|
695
|
+
timePillPaddingY: 4,
|
|
696
|
+
seriesPillBackground: undefined,
|
|
697
|
+
seriesPillLabelColor: undefined,
|
|
698
|
+
seriesPillValueColor: undefined,
|
|
699
|
+
seriesPillBorderColor: undefined,
|
|
700
|
+
seriesPillRadius: 6,
|
|
701
|
+
seriesPillPaddingX: 8,
|
|
702
|
+
seriesPillPaddingY: 4,
|
|
703
|
+
seriesPillDotSize: 8,
|
|
704
|
+
seriesPillDotGap: 6,
|
|
705
|
+
seriesPillLabelValueGap: 6,
|
|
706
|
+
intersectionDotSize: 8,
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
function resolvePerSeriesTooltip(
|
|
710
|
+
prop: boolean | PerSeriesTooltipConfig | undefined,
|
|
711
|
+
): ResolvedPerSeriesTooltipConfig | null {
|
|
712
|
+
const resolved = resolveToggle(
|
|
713
|
+
prop,
|
|
714
|
+
PER_SERIES_TOOLTIP_DEFAULTS,
|
|
715
|
+
false,
|
|
716
|
+
);
|
|
717
|
+
if (!resolved) return null;
|
|
718
|
+
const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
|
|
719
|
+
if (dash !== undefined) {
|
|
720
|
+
resolved.guideDashPattern = dash === true ? [3, 3] : dash || undefined;
|
|
721
|
+
}
|
|
722
|
+
resolved.maxLabelChars = Math.max(1, Math.floor(resolved.maxLabelChars));
|
|
723
|
+
resolved.bucketSeconds =
|
|
724
|
+
resolved.bucketSeconds !== undefined && resolved.bucketSeconds > 0
|
|
725
|
+
? resolved.bucketSeconds
|
|
726
|
+
: undefined;
|
|
727
|
+
return resolved;
|
|
728
|
+
}
|
|
729
|
+
|
|
647
730
|
/**
|
|
648
731
|
* Resolves `scrub` prop to a fully-typed config or null (disabled).
|
|
649
732
|
* `true` → defaults, object → merged with defaults, falsy → null.
|
|
@@ -657,6 +740,9 @@ export function resolveScrub(
|
|
|
657
740
|
// through, anything falsy → solid (undefined).
|
|
658
741
|
const dash = typeof prop === "object" ? prop.crosshairDash : undefined;
|
|
659
742
|
resolved.crosshairDash = dash === true ? [4, 4] : dash || undefined;
|
|
743
|
+
const seriesTooltip =
|
|
744
|
+
typeof prop === "object" ? prop.seriesTooltip : undefined;
|
|
745
|
+
resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
|
|
660
746
|
}
|
|
661
747
|
return resolved;
|
|
662
748
|
}
|
|
@@ -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";
|
|
@@ -48,6 +48,37 @@ export interface TooltipLayout {
|
|
|
48
48
|
baselineY: number;
|
|
49
49
|
dim: boolean;
|
|
50
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
|
+
};
|
|
51
82
|
}
|
|
52
83
|
|
|
53
84
|
export const HIDDEN_TOOLTIP: TooltipLayout = {
|
|
@@ -62,6 +93,7 @@ export const HIDDEN_TOOLTIP: TooltipLayout = {
|
|
|
62
93
|
line1Y: 0,
|
|
63
94
|
line2Y: 0,
|
|
64
95
|
stackedLines: undefined,
|
|
96
|
+
perSeries: undefined,
|
|
65
97
|
};
|
|
66
98
|
|
|
67
99
|
export interface CrosshairState {
|
|
@@ -70,7 +102,9 @@ export interface CrosshairState {
|
|
|
70
102
|
scrubTime: SharedValue<number>;
|
|
71
103
|
scrubValue: SharedValue<number | null>;
|
|
72
104
|
crosshairOpacity: SharedValue<number>;
|
|
73
|
-
tooltipLayout:
|
|
105
|
+
tooltipLayout:
|
|
106
|
+
| SharedValue<TooltipLayout>
|
|
107
|
+
| DerivedValue<TooltipLayout>;
|
|
74
108
|
/** Scrub intersection Y in canvas px; -1 when there's no dot to draw
|
|
75
109
|
* (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
|
|
76
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
|
}
|