react-native-livechart 4.11.0 → 4.13.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 +12 -2
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +12 -2
- package/dist/components/CrosshairOverlay.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/CustomTooltipOverlay.d.ts +5 -2
- package/dist/components/CustomTooltipOverlay.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 +21 -0
- package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +27 -6
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +8 -2
- 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 +45 -0
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/draw/grid.d.ts +13 -0
- package/dist/draw/grid.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 +55 -5
- 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 +13 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts +27 -3
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useCrosshairVisibleOpacity.d.ts +8 -0
- package/dist/hooks/useCrosshairVisibleOpacity.d.ts.map +1 -0
- 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/hooks/useReferenceLine.d.ts +10 -1
- package/dist/hooks/useReferenceLine.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 +207 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +39 -8
- package/src/components/CrosshairOverlay.tsx +49 -15
- package/src/components/CustomReferenceLineOverlay.tsx +42 -13
- package/src/components/CustomTooltipOverlay.tsx +15 -3
- package/src/components/DotOverlay.tsx +24 -8
- package/src/components/LiveChart.tsx +182 -39
- package/src/components/LiveChartSeries.tsx +195 -16
- package/src/components/PerSeriesTooltipOverlay.tsx +290 -0
- package/src/components/ReferenceLineOverlay.tsx +288 -141
- package/src/components/YAxisOverlay.tsx +39 -10
- package/src/core/liveIndicatorVisibility.ts +25 -0
- package/src/core/resolveConfig.ts +115 -0
- package/src/draw/grid.ts +33 -0
- package/src/hooks/crosshairSeries.ts +262 -33
- package/src/hooks/crosshairShared.ts +117 -6
- package/src/hooks/delayedPanGuard.ts +111 -6
- package/src/hooks/useChartPaths.ts +25 -15
- package/src/hooks/useCrosshair.ts +103 -32
- package/src/hooks/useCrosshairSeries.ts +126 -11
- package/src/hooks/useCrosshairVisibleOpacity.ts +57 -0
- package/src/hooks/useMomentum.ts +8 -1
- package/src/hooks/usePanScroll.ts +46 -5
- package/src/hooks/useReferenceLine.ts +26 -1
- 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 +215 -18
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TimeScrollConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve whether historical time-scroll views should suppress indicators that
|
|
5
|
+
* still point at the live price.
|
|
6
|
+
*/
|
|
7
|
+
export function resolveHideLiveOnScrollBack(
|
|
8
|
+
timeScroll: boolean | TimeScrollConfig | undefined,
|
|
9
|
+
followViewEdge: boolean,
|
|
10
|
+
): boolean {
|
|
11
|
+
const hide =
|
|
12
|
+
typeof timeScroll === "object"
|
|
13
|
+
? (timeScroll.hideLiveOnScrollBack ?? true)
|
|
14
|
+
: true;
|
|
15
|
+
return hide && !followViewEdge;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Worklet-safe opacity gate shared by the live badge, dot, and dashed value line. */
|
|
19
|
+
export function liveIndicatorScrollOpacity(
|
|
20
|
+
hideLiveOnScrollBack: boolean,
|
|
21
|
+
viewEnd: number | null,
|
|
22
|
+
): 0 | 1 {
|
|
23
|
+
"worklet";
|
|
24
|
+
return hideLiveOnScrollBack && viewEnd != null ? 0 : 1;
|
|
25
|
+
}
|
|
@@ -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,
|
|
@@ -88,6 +89,10 @@ export interface ResolvedYAxisConfig {
|
|
|
88
89
|
minGap: number;
|
|
89
90
|
/** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
|
|
90
91
|
count: number;
|
|
92
|
+
/** undefined → keep the default centered-gutter label placement. */
|
|
93
|
+
labelRightMargin: number | undefined;
|
|
94
|
+
/** undefined → no gap; inert unless labelRightMargin is set. */
|
|
95
|
+
gridEndGap: number | undefined;
|
|
91
96
|
/** Float the axis over a full-width plot (no reserved right gutter). */
|
|
92
97
|
float: boolean;
|
|
93
98
|
}
|
|
@@ -144,10 +149,22 @@ export interface ResolvedXAxisConfig {
|
|
|
144
149
|
|
|
145
150
|
export interface ResolvedScrubConfig {
|
|
146
151
|
tooltip: boolean;
|
|
152
|
+
/** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
|
|
153
|
+
seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
|
|
147
154
|
/** Opacity of content right of the crosshair while scrubbing (dstOut fade). */
|
|
148
155
|
dimOpacity: number;
|
|
149
156
|
/** undefined → palette.crosshairLine */
|
|
150
157
|
crosshairLineColor: string | undefined;
|
|
158
|
+
/** Vertical crosshair line width in px. */
|
|
159
|
+
crosshairStrokeWidth: number;
|
|
160
|
+
/** Extension past the plot's top and bottom edges in px. */
|
|
161
|
+
crosshairOvershoot: number;
|
|
162
|
+
/** Fade the crosshair near the live edge. */
|
|
163
|
+
crosshairFade: boolean;
|
|
164
|
+
/** Visible-crosshair fade distance near the live edge in px. */
|
|
165
|
+
crosshairFadeDistance: number;
|
|
166
|
+
/** undefined preserves the existing Skia line-cap default. */
|
|
167
|
+
crosshairLineCap: "butt" | "round" | "square" | undefined;
|
|
151
168
|
/** Dash intervals `[on, off, …]` for the crosshair line; undefined → solid. */
|
|
152
169
|
crosshairDash: number[] | undefined;
|
|
153
170
|
/** undefined → palette.crosshairDim */
|
|
@@ -172,6 +189,38 @@ export interface ResolvedScrubConfig {
|
|
|
172
189
|
panGestureDelay: number;
|
|
173
190
|
/** Fade markers + reference lines out while scrubbing. */
|
|
174
191
|
hideOverlaysOnScrub: boolean;
|
|
192
|
+
/** Reject outside plain-scrub starts and clamp active scrub X to the plot. */
|
|
193
|
+
clampToPlot: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ResolvedPerSeriesTooltipConfig {
|
|
197
|
+
alwaysShow: boolean;
|
|
198
|
+
bucketSeconds: number | undefined;
|
|
199
|
+
formatSeriesValue:
|
|
200
|
+
| ((value: number, seriesId: string) => string)
|
|
201
|
+
| undefined;
|
|
202
|
+
formatTimeRange: ((from: number, to: number) => string) | undefined;
|
|
203
|
+
maxLabelChars: number;
|
|
204
|
+
guideColor: string | undefined;
|
|
205
|
+
guideWidth: number;
|
|
206
|
+
guideDashPattern: number[] | undefined;
|
|
207
|
+
timePillBackground: string | undefined;
|
|
208
|
+
timePillColor: string | undefined;
|
|
209
|
+
timePillBorderColor: string | undefined;
|
|
210
|
+
timePillRadius: number;
|
|
211
|
+
timePillPaddingX: number;
|
|
212
|
+
timePillPaddingY: number;
|
|
213
|
+
seriesPillBackground: string | undefined;
|
|
214
|
+
seriesPillLabelColor: string | undefined;
|
|
215
|
+
seriesPillValueColor: string | undefined;
|
|
216
|
+
seriesPillBorderColor: string | undefined;
|
|
217
|
+
seriesPillRadius: number;
|
|
218
|
+
seriesPillPaddingX: number;
|
|
219
|
+
seriesPillPaddingY: number;
|
|
220
|
+
seriesPillDotSize: number;
|
|
221
|
+
seriesPillDotGap: number;
|
|
222
|
+
seriesPillLabelValueGap: number;
|
|
223
|
+
intersectionDotSize: number;
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
export interface ResolvedScrubActionConfig {
|
|
@@ -432,6 +481,8 @@ export function resolveBadge(
|
|
|
432
481
|
const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
|
|
433
482
|
minGap: 36,
|
|
434
483
|
count: 0,
|
|
484
|
+
labelRightMargin: undefined,
|
|
485
|
+
gridEndGap: undefined,
|
|
435
486
|
float: false,
|
|
436
487
|
};
|
|
437
488
|
|
|
@@ -628,8 +679,14 @@ export function resolveXAxis(
|
|
|
628
679
|
|
|
629
680
|
const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
630
681
|
tooltip: true,
|
|
682
|
+
seriesTooltip: null,
|
|
631
683
|
dimOpacity: 0.3,
|
|
632
684
|
crosshairLineColor: undefined,
|
|
685
|
+
crosshairStrokeWidth: 1,
|
|
686
|
+
crosshairOvershoot: 0,
|
|
687
|
+
crosshairFade: true,
|
|
688
|
+
crosshairFadeDistance: 4,
|
|
689
|
+
crosshairLineCap: undefined,
|
|
633
690
|
crosshairDash: undefined,
|
|
634
691
|
crosshairDimColor: undefined,
|
|
635
692
|
tooltipBackground: undefined,
|
|
@@ -642,8 +699,58 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
|
|
|
642
699
|
tooltipShowTime: true,
|
|
643
700
|
panGestureDelay: 0,
|
|
644
701
|
hideOverlaysOnScrub: false,
|
|
702
|
+
clampToPlot: false,
|
|
645
703
|
};
|
|
646
704
|
|
|
705
|
+
const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
|
|
706
|
+
alwaysShow: false,
|
|
707
|
+
bucketSeconds: undefined,
|
|
708
|
+
formatSeriesValue: undefined,
|
|
709
|
+
formatTimeRange: undefined,
|
|
710
|
+
maxLabelChars: 14,
|
|
711
|
+
guideColor: undefined,
|
|
712
|
+
guideWidth: 1,
|
|
713
|
+
guideDashPattern: [3, 3],
|
|
714
|
+
timePillBackground: undefined,
|
|
715
|
+
timePillColor: undefined,
|
|
716
|
+
timePillBorderColor: undefined,
|
|
717
|
+
timePillRadius: 6,
|
|
718
|
+
timePillPaddingX: 8,
|
|
719
|
+
timePillPaddingY: 4,
|
|
720
|
+
seriesPillBackground: undefined,
|
|
721
|
+
seriesPillLabelColor: undefined,
|
|
722
|
+
seriesPillValueColor: undefined,
|
|
723
|
+
seriesPillBorderColor: undefined,
|
|
724
|
+
seriesPillRadius: 6,
|
|
725
|
+
seriesPillPaddingX: 8,
|
|
726
|
+
seriesPillPaddingY: 4,
|
|
727
|
+
seriesPillDotSize: 8,
|
|
728
|
+
seriesPillDotGap: 6,
|
|
729
|
+
seriesPillLabelValueGap: 6,
|
|
730
|
+
intersectionDotSize: 8,
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
function resolvePerSeriesTooltip(
|
|
734
|
+
prop: boolean | PerSeriesTooltipConfig | undefined,
|
|
735
|
+
): ResolvedPerSeriesTooltipConfig | null {
|
|
736
|
+
const resolved = resolveToggle(
|
|
737
|
+
prop,
|
|
738
|
+
PER_SERIES_TOOLTIP_DEFAULTS,
|
|
739
|
+
false,
|
|
740
|
+
);
|
|
741
|
+
if (!resolved) return null;
|
|
742
|
+
const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
|
|
743
|
+
if (dash !== undefined) {
|
|
744
|
+
resolved.guideDashPattern = dash === true ? [3, 3] : dash || undefined;
|
|
745
|
+
}
|
|
746
|
+
resolved.maxLabelChars = Math.max(1, Math.floor(resolved.maxLabelChars));
|
|
747
|
+
resolved.bucketSeconds =
|
|
748
|
+
resolved.bucketSeconds !== undefined && resolved.bucketSeconds > 0
|
|
749
|
+
? resolved.bucketSeconds
|
|
750
|
+
: undefined;
|
|
751
|
+
return resolved;
|
|
752
|
+
}
|
|
753
|
+
|
|
647
754
|
/**
|
|
648
755
|
* Resolves `scrub` prop to a fully-typed config or null (disabled).
|
|
649
756
|
* `true` → defaults, object → merged with defaults, falsy → null.
|
|
@@ -657,6 +764,14 @@ export function resolveScrub(
|
|
|
657
764
|
// through, anything falsy → solid (undefined).
|
|
658
765
|
const dash = typeof prop === "object" ? prop.crosshairDash : undefined;
|
|
659
766
|
resolved.crosshairDash = dash === true ? [4, 4] : dash || undefined;
|
|
767
|
+
const seriesTooltip =
|
|
768
|
+
typeof prop === "object" ? prop.seriesTooltip : undefined;
|
|
769
|
+
resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
|
|
770
|
+
resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
|
|
771
|
+
resolved.crosshairFadeDistance = Math.max(
|
|
772
|
+
0,
|
|
773
|
+
resolved.crosshairFadeDistance,
|
|
774
|
+
);
|
|
660
775
|
}
|
|
661
776
|
return resolved;
|
|
662
777
|
}
|
package/src/draw/grid.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { GRID_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
|
|
2
|
+
import type { SkFont } from "@shopify/react-native-skia";
|
|
3
|
+
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
2
4
|
import { lerp } from "../math/lerp";
|
|
3
5
|
import type { GridMetrics } from "../types";
|
|
4
6
|
|
|
@@ -8,6 +10,37 @@ export interface YAxisEntry {
|
|
|
8
10
|
alpha: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
export interface RightAnchoredYAxisColumnLayout {
|
|
14
|
+
/** Shared left X for every label in the column. */
|
|
15
|
+
labelX: number;
|
|
16
|
+
/** Grid/reference-line end X immediately before the column gap. */
|
|
17
|
+
gridEndX: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Measure the widest Y-axis label and anchor the shared left-aligned column to
|
|
22
|
+
* a fixed canvas-edge margin. Shared by the axis and reference-line worklets so
|
|
23
|
+
* their right edges cannot drift apart.
|
|
24
|
+
*/
|
|
25
|
+
export function rightAnchoredYAxisColumnLayout(
|
|
26
|
+
canvasWidth: number,
|
|
27
|
+
entries: readonly YAxisEntry[],
|
|
28
|
+
font: SkFont,
|
|
29
|
+
labelRightMargin: number,
|
|
30
|
+
gridEndGap = 0,
|
|
31
|
+
): RightAnchoredYAxisColumnLayout {
|
|
32
|
+
"worklet";
|
|
33
|
+
let maxTextW = 0;
|
|
34
|
+
for (let i = 0; i < entries.length; i++) {
|
|
35
|
+
maxTextW = Math.max(
|
|
36
|
+
maxTextW,
|
|
37
|
+
measureFontTextWidth(font, entries[i].label),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const labelX = canvasWidth - labelRightMargin - maxTextW;
|
|
41
|
+
return { labelX, gridEndX: labelX - gridEndGap };
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
/**
|
|
12
45
|
* Fixed-count Y-axis: place exactly `count` price labels evenly **in pixels**
|
|
13
46
|
* across the plot band — top = `displayMax`, bottom = `displayMin`. Values track
|
|
@@ -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
|
}
|