react-native-livechart 3.12.0 → 4.1.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/BadgeOverlay.d.ts +9 -1
- package/dist/components/BadgeOverlay.d.ts.map +1 -1
- package/dist/components/ChartOverlayLayer.d.ts +19 -0
- package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
- package/dist/components/CustomMarkerOverlay.d.ts +16 -6
- package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
- package/dist/components/CustomTooltipOverlay.d.ts +5 -2
- package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +7 -1
- package/dist/components/DotOverlay.d.ts.map +1 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts +6 -3
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +4 -1
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
- package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +25 -2
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/XAxisOverlay.d.ts +7 -1
- package/dist/components/XAxisOverlay.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +12 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/constants.d.ts +13 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +29 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +62 -2
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +58 -7
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/grid.d.ts +13 -1
- package/dist/draw/grid.d.ts.map +1 -1
- package/dist/draw/markerAtlas.d.ts +20 -2
- package/dist/draw/markerAtlas.d.ts.map +1 -1
- package/dist/draw/volume.d.ts +25 -0
- package/dist/draw/volume.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +12 -6
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/overlayScale.d.ts +22 -0
- package/dist/hooks/overlayScale.d.ts.map +1 -0
- package/dist/hooks/resolveChartLayout.d.ts +9 -0
- package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
- package/dist/hooks/useBadge.d.ts +22 -1
- package/dist/hooks/useBadge.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +6 -0
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useChartOverlayContext.d.ts +45 -0
- package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
- package/dist/hooks/useChartPaths.d.ts +8 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +9 -2
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useLiveDot.d.ts +6 -1
- package/dist/hooks/useLiveDot.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts +9 -4
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +82 -0
- package/dist/hooks/usePanScroll.d.ts.map +1 -0
- package/dist/hooks/usePinchZoom.d.ts +74 -0
- package/dist/hooks/usePinchZoom.d.ts.map +1 -0
- package/dist/hooks/useReferenceDrag.d.ts +24 -0
- package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
- package/dist/hooks/useReferenceLine.d.ts +23 -4
- package/dist/hooks/useReferenceLine.d.ts.map +1 -1
- package/dist/hooks/useReferenceLinePress.d.ts +5 -1
- package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
- package/dist/hooks/useVisibleRange.d.ts +39 -0
- package/dist/hooks/useVisibleRange.d.ts.map +1 -0
- package/dist/hooks/useYAxis.d.ts +1 -1
- package/dist/hooks/useYAxis.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/markerCluster.d.ts +47 -0
- package/dist/math/markerCluster.d.ts.map +1 -0
- package/dist/math/markers.d.ts +10 -0
- package/dist/math/markers.d.ts.map +1 -1
- package/dist/math/referenceDrag.d.ts +24 -0
- package/dist/math/referenceDrag.d.ts.map +1 -0
- package/dist/math/referenceGroup.d.ts +31 -0
- package/dist/math/referenceGroup.d.ts.map +1 -0
- package/dist/math/referenceLines.d.ts +33 -7
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/types.d.ts +524 -21
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/BadgeOverlay.tsx +26 -1
- package/src/components/ChartOverlayLayer.tsx +32 -0
- package/src/components/CustomMarkerOverlay.tsx +150 -57
- package/src/components/CustomReferenceLineOverlay.tsx +250 -0
- package/src/components/CustomTooltipOverlay.tsx +27 -7
- package/src/components/DotOverlay.tsx +9 -0
- package/src/components/LiveChart.tsx +550 -32
- package/src/components/LiveChartSeries.tsx +111 -5
- package/src/components/MarkerOverlay.tsx +92 -6
- package/src/components/MultiSeriesDots.tsx +12 -3
- package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
- package/src/components/ReferenceLineOverlay.tsx +105 -32
- package/src/components/XAxisOverlay.tsx +9 -2
- package/src/components/YAxisOverlay.tsx +49 -28
- package/src/constants.ts +17 -0
- package/src/core/liveChartEngineTick.ts +81 -8
- package/src/core/liveChartSeriesEngineTick.ts +50 -9
- package/src/core/resolveConfig.ts +133 -2
- package/src/core/useLiveChartEngine.ts +111 -11
- package/src/core/useLiveChartSeriesEngine.ts +26 -3
- package/src/draw/grid.ts +71 -2
- package/src/draw/markerAtlas.ts +117 -3
- package/src/draw/volume.ts +190 -0
- package/src/hooks/crosshairShared.ts +37 -9
- package/src/hooks/overlayScale.ts +59 -0
- package/src/hooks/resolveChartLayout.ts +33 -0
- package/src/hooks/useBadge.ts +89 -21
- package/src/hooks/useCandlePaths.ts +104 -7
- package/src/hooks/useChartOverlayContext.ts +93 -0
- package/src/hooks/useChartPaths.ts +14 -1
- package/src/hooks/useCrosshair.ts +33 -2
- package/src/hooks/useLiveDot.ts +16 -5
- package/src/hooks/useMarkers.ts +39 -11
- package/src/hooks/usePanScroll.ts +253 -0
- package/src/hooks/usePinchZoom.ts +189 -0
- package/src/hooks/useReferenceDrag.ts +254 -0
- package/src/hooks/useReferenceLine.ts +56 -9
- package/src/hooks/useReferenceLinePress.ts +16 -2
- package/src/hooks/useVisibleRange.ts +119 -0
- package/src/hooks/useYAxis.ts +2 -0
- package/src/index.ts +17 -1
- package/src/math/markerCluster.ts +214 -0
- package/src/math/markers.ts +35 -3
- package/src/math/referenceDrag.ts +66 -0
- package/src/math/referenceGroup.ts +65 -0
- package/src/math/referenceLines.ts +76 -19
- package/src/types.ts +544 -21
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
type SharedValue,
|
|
16
16
|
} from "react-native-reanimated";
|
|
17
17
|
import { scheduleOnRN } from "react-native-worklets";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_ACCENT_COLOR,
|
|
20
|
+
HOLD_TO_SCRUB_MS,
|
|
21
|
+
MAX_MULTI_SERIES,
|
|
22
|
+
} from "../constants";
|
|
19
23
|
import {
|
|
20
24
|
lineColorsSignatureFromArray,
|
|
21
25
|
lineStyleSignatureFromArray,
|
|
@@ -29,12 +33,14 @@ import {
|
|
|
29
33
|
resolveGridStyle,
|
|
30
34
|
resolveLeftEdgeFade,
|
|
31
35
|
resolveLegend,
|
|
36
|
+
resolveMarkerCluster,
|
|
32
37
|
resolveMetrics,
|
|
33
38
|
resolveMultiSeriesDot,
|
|
34
39
|
resolveScrub,
|
|
35
40
|
resolveSelectionDot,
|
|
36
41
|
resolveXAxis,
|
|
37
42
|
resolveYAxis,
|
|
43
|
+
resolveZoom,
|
|
38
44
|
} from "../core/resolveConfig";
|
|
39
45
|
import { useLiveChartSeriesEngine } from "../core/useLiveChartSeriesEngine";
|
|
40
46
|
import { pulseRadialOutset } from "../draw/line";
|
|
@@ -46,7 +52,10 @@ import { useCrosshairSeries } from "../hooks/useCrosshairSeries";
|
|
|
46
52
|
import { useMarkers } from "../hooks/useMarkers";
|
|
47
53
|
import { useMultiSeriesDegen } from "../hooks/useMultiSeriesDegen";
|
|
48
54
|
import { useMultiSeriesLinePaths } from "../hooks/useMultiSeriesLinePaths";
|
|
55
|
+
import { usePanScroll } from "../hooks/usePanScroll";
|
|
56
|
+
import { usePinchZoom } from "../hooks/usePinchZoom";
|
|
49
57
|
import { useMultiSeriesReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
|
|
58
|
+
import { useVisibleRange } from "../hooks/useVisibleRange";
|
|
50
59
|
import { useXAxis } from "../hooks/useXAxis";
|
|
51
60
|
import { useYAxis } from "../hooks/useYAxis";
|
|
52
61
|
import {
|
|
@@ -137,22 +146,28 @@ function useLiveChartSeriesController({
|
|
|
137
146
|
metrics,
|
|
138
147
|
scrub = true,
|
|
139
148
|
selectionDot,
|
|
149
|
+
timeScroll = false,
|
|
150
|
+
zoom = false,
|
|
140
151
|
onScrub,
|
|
141
152
|
onGestureStart,
|
|
142
153
|
onGestureEnd,
|
|
154
|
+
onVisibleRangeChange,
|
|
155
|
+
onReachStart,
|
|
143
156
|
onSeriesToggle,
|
|
144
157
|
dot: dotProp,
|
|
145
158
|
legend: legendProp,
|
|
146
159
|
degen,
|
|
147
160
|
onDegenShake,
|
|
148
161
|
markers,
|
|
149
|
-
|
|
162
|
+
onMarkerPress,
|
|
150
163
|
markerHitRadius = 16,
|
|
164
|
+
markerCluster,
|
|
151
165
|
renderMarker,
|
|
152
166
|
leftEdgeFade = true,
|
|
153
167
|
}: LiveChartSeriesProps) {
|
|
154
168
|
const emptyMarkers = useSharedValue<Marker[]>([]);
|
|
155
169
|
const markersSV = markers ?? emptyMarkers;
|
|
170
|
+
const markerClusterCfg = resolveMarkerCluster(markerCluster);
|
|
156
171
|
const markersActive = markers != null;
|
|
157
172
|
const yAxisCfg = resolveYAxis(yAxis);
|
|
158
173
|
const xAxisCfg = resolveXAxis(xAxis);
|
|
@@ -160,6 +175,27 @@ function useLiveChartSeriesController({
|
|
|
160
175
|
const bottomLabelCfg = resolveAxisLabel(bottomLabel);
|
|
161
176
|
const scrubCfg = resolveScrub(scrub);
|
|
162
177
|
const scrubEnabled = scrubCfg !== null;
|
|
178
|
+
|
|
179
|
+
// Time-scroll + pinch-zoom (mirrors LiveChart). LiveChartSeries has no static
|
|
180
|
+
// mode, so these gate on the props alone. `holdToScrub` requires the scrub
|
|
181
|
+
// gesture to wait for a press-and-hold so a quick drag scrolls instead — the
|
|
182
|
+
// hold delay is threaded into `useCrosshairSeries` below.
|
|
183
|
+
const timeScrollEnabled = Boolean(timeScroll);
|
|
184
|
+
const zoomCfg = resolveZoom(zoom);
|
|
185
|
+
const zoomEnabled = zoomCfg !== null;
|
|
186
|
+
const scrollGestureMode =
|
|
187
|
+
typeof timeScroll === "object"
|
|
188
|
+
? (timeScroll.gesture ?? "holdToScrub")
|
|
189
|
+
: "holdToScrub";
|
|
190
|
+
const timeScrollHoldMs =
|
|
191
|
+
typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
|
|
192
|
+
// Precedence: explicit scrubHoldMs, then scrub.panGestureDelay, then default.
|
|
193
|
+
// `||` (not `??`) skips the resolved panGestureDelay's 0 default.
|
|
194
|
+
const scrubHoldMs =
|
|
195
|
+
timeScrollEnabled && scrollGestureMode === "holdToScrub"
|
|
196
|
+
? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
|
|
197
|
+
: (scrubCfg?.panGestureDelay ?? 0);
|
|
198
|
+
|
|
163
199
|
const selectionDotCfg = resolveSelectionDot(selectionDot);
|
|
164
200
|
const gridStyleCfg = resolveGridStyle(gridStyle);
|
|
165
201
|
const dotCfg = resolveMultiSeriesDot(dotProp);
|
|
@@ -309,6 +345,7 @@ function useLiveChartSeriesController({
|
|
|
309
345
|
skiaFont,
|
|
310
346
|
yAxisCfg?.minGap ?? 36,
|
|
311
347
|
metricsCfg.grid,
|
|
348
|
+
yAxisCfg?.count ?? 0,
|
|
312
349
|
);
|
|
313
350
|
|
|
314
351
|
const { xAxisEntries } = useXAxis(
|
|
@@ -323,7 +360,7 @@ function useLiveChartSeriesController({
|
|
|
323
360
|
effectivePadding,
|
|
324
361
|
scrubEnabled,
|
|
325
362
|
onScrub,
|
|
326
|
-
|
|
363
|
+
scrubHoldMs,
|
|
327
364
|
onGestureStart,
|
|
328
365
|
onGestureEnd,
|
|
329
366
|
);
|
|
@@ -336,14 +373,77 @@ function useLiveChartSeriesController({
|
|
|
336
373
|
markersSV,
|
|
337
374
|
markersActive,
|
|
338
375
|
markerHitRadius,
|
|
339
|
-
|
|
376
|
+
onMarkerPress,
|
|
340
377
|
series,
|
|
378
|
+
undefined, // lineData — multi-series anchors by seriesId
|
|
379
|
+
true, // autostart
|
|
380
|
+
false, // lineLinear — per-series curve handled in projection
|
|
381
|
+
markerClusterCfg,
|
|
341
382
|
);
|
|
342
383
|
|
|
343
|
-
|
|
384
|
+
// Earliest retained time across all series — clamps how far the window pans
|
|
385
|
+
// back, and the `onReachStart` reference. Falls back to the live edge (no
|
|
386
|
+
// scrollable history) so panning is a no-op.
|
|
387
|
+
const scrollMinTime = useDerivedValue(() => {
|
|
388
|
+
const s = engine.series.get();
|
|
389
|
+
let min = Infinity;
|
|
390
|
+
for (let i = 0; i < s.length; i++) {
|
|
391
|
+
const d = s[i].data;
|
|
392
|
+
if (d.length > 0 && d[0].time < min) min = d[0].time;
|
|
393
|
+
}
|
|
394
|
+
return min === Infinity ? engine.liveEdge.get() : min;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const panScrollGesture = usePanScroll({
|
|
398
|
+
engine,
|
|
399
|
+
padding: effectivePadding,
|
|
400
|
+
minTime: scrollMinTime,
|
|
401
|
+
enabled: timeScrollEnabled,
|
|
402
|
+
mode: scrollGestureMode,
|
|
403
|
+
onScrollStart: () => {
|
|
404
|
+
"worklet";
|
|
405
|
+
crosshair.scrubActive.set(false);
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const pinchZoomGesture = usePinchZoom({
|
|
410
|
+
engine,
|
|
411
|
+
padding: effectivePadding,
|
|
412
|
+
minTime: scrollMinTime,
|
|
413
|
+
timeWindow,
|
|
414
|
+
enabled: zoomEnabled,
|
|
415
|
+
minTimeWindow: zoomCfg?.minTimeWindow,
|
|
416
|
+
maxTimeWindow: zoomCfg?.maxTimeWindow,
|
|
417
|
+
onZoomStart: () => {
|
|
418
|
+
"worklet";
|
|
419
|
+
crosshair.scrubActive.set(false);
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
useVisibleRange({
|
|
424
|
+
engine,
|
|
425
|
+
minTime: scrollMinTime,
|
|
426
|
+
onVisibleRangeChange,
|
|
427
|
+
onReachStart,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
let rootGesture = markersActive
|
|
344
431
|
? Gesture.Race(crosshair.gesture, markerTapGesture)
|
|
345
432
|
: crosshair.gesture;
|
|
346
433
|
|
|
434
|
+
// holdToScrub races the scrub (quick drag scrolls; press-hold scrubs);
|
|
435
|
+
// axisDrag goes first via Exclusive (fails fast outside the bottom band).
|
|
436
|
+
if (timeScrollEnabled) {
|
|
437
|
+
rootGesture =
|
|
438
|
+
scrollGestureMode === "axisDrag"
|
|
439
|
+
? Gesture.Exclusive(panScrollGesture, rootGesture)
|
|
440
|
+
: Gesture.Race(panScrollGesture, rootGesture);
|
|
441
|
+
}
|
|
442
|
+
// Pinch runs alongside (two-finger, disjoint from the one-finger gestures).
|
|
443
|
+
if (zoomEnabled) {
|
|
444
|
+
rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
|
|
445
|
+
}
|
|
446
|
+
|
|
347
447
|
const backgroundColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
|
|
348
448
|
|
|
349
449
|
return {
|
|
@@ -392,6 +492,7 @@ function useLiveChartSeriesController({
|
|
|
392
492
|
rootGesture,
|
|
393
493
|
markersActive,
|
|
394
494
|
markersSV,
|
|
495
|
+
markerClusterCfg,
|
|
395
496
|
renderMarker,
|
|
396
497
|
// selection dot: resolved config + fallback color (the leading series' color)
|
|
397
498
|
selectionDot: selectionDotCfg,
|
|
@@ -436,6 +537,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
|
|
|
436
537
|
degenPackRevision,
|
|
437
538
|
markersActive,
|
|
438
539
|
markersSV,
|
|
540
|
+
markerClusterCfg,
|
|
439
541
|
renderMarker,
|
|
440
542
|
series,
|
|
441
543
|
emptyText,
|
|
@@ -520,6 +622,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
|
|
|
520
622
|
ringColor={palette.badgeOuterBg}
|
|
521
623
|
color={dotCfg.color}
|
|
522
624
|
pulse={dotCfg.pulse}
|
|
625
|
+
viewEnd={engine.viewEnd}
|
|
523
626
|
/>
|
|
524
627
|
</Group>
|
|
525
628
|
)}
|
|
@@ -553,6 +656,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
|
|
|
553
656
|
font={skiaFont}
|
|
554
657
|
series={series}
|
|
555
658
|
renderMarker={renderMarker}
|
|
659
|
+
cluster={markerClusterCfg}
|
|
556
660
|
/>
|
|
557
661
|
</Group>
|
|
558
662
|
)}
|
|
@@ -665,6 +769,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
|
|
|
665
769
|
bottomConnector,
|
|
666
770
|
markersActive,
|
|
667
771
|
markersSV,
|
|
772
|
+
markerClusterCfg,
|
|
668
773
|
renderMarker,
|
|
669
774
|
} = model;
|
|
670
775
|
|
|
@@ -772,6 +877,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
|
|
|
772
877
|
engine={engine}
|
|
773
878
|
padding={effectivePadding}
|
|
774
879
|
series={series}
|
|
880
|
+
cluster={markerClusterCfg}
|
|
775
881
|
/>
|
|
776
882
|
)}
|
|
777
883
|
</View>
|
|
@@ -17,8 +17,12 @@ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
|
17
17
|
import type { ChartPadding } from "../draw/line";
|
|
18
18
|
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
19
19
|
import {
|
|
20
|
+
BADGE_TEXT_SCALE,
|
|
20
21
|
buildMarkerAtlas,
|
|
21
22
|
defaultMarkerColor,
|
|
23
|
+
groupBgSig,
|
|
24
|
+
groupCountText,
|
|
25
|
+
groupGlyphSig,
|
|
22
26
|
isConnectorMarker,
|
|
23
27
|
markerAppearanceSig,
|
|
24
28
|
type AtlasCell,
|
|
@@ -29,10 +33,15 @@ import {
|
|
|
29
33
|
projectPoint,
|
|
30
34
|
type ProjectedMarker,
|
|
31
35
|
} from "../math/markers";
|
|
36
|
+
import {
|
|
37
|
+
clusterMarkers,
|
|
38
|
+
type ResolvedMarkerCluster,
|
|
39
|
+
} from "../math/markerCluster";
|
|
32
40
|
import type {
|
|
33
41
|
LiveChartPalette,
|
|
34
42
|
LiveChartPoint,
|
|
35
43
|
Marker,
|
|
44
|
+
MarkerRenderContext,
|
|
36
45
|
SeriesConfig,
|
|
37
46
|
} from "../types";
|
|
38
47
|
|
|
@@ -153,6 +162,7 @@ export function MarkerOverlay({
|
|
|
153
162
|
lineData,
|
|
154
163
|
lineLinear,
|
|
155
164
|
renderMarker,
|
|
165
|
+
cluster,
|
|
156
166
|
}: {
|
|
157
167
|
markers: SharedValue<Marker[]>;
|
|
158
168
|
engine: ChartEngineLayout;
|
|
@@ -170,7 +180,9 @@ export function MarkerOverlay({
|
|
|
170
180
|
* `CustomMarkerOverlay` instead — so they're excluded from the atlas here to
|
|
171
181
|
* avoid a (blurry) glyph drawn behind the custom element.
|
|
172
182
|
*/
|
|
173
|
-
renderMarker?: (marker: Marker) => unknown;
|
|
183
|
+
renderMarker?: (marker: Marker, ctx: MarkerRenderContext) => unknown;
|
|
184
|
+
/** Collision config; `"stacked"` fans/collapses co-located markers. */
|
|
185
|
+
cluster: ResolvedMarkerCluster;
|
|
174
186
|
}) {
|
|
175
187
|
// Seed from the current markers at mount; the reaction below keeps it in sync.
|
|
176
188
|
const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
|
|
@@ -198,7 +210,15 @@ export function MarkerOverlay({
|
|
|
198
210
|
let k = "";
|
|
199
211
|
for (let i = 0; i < snapshot.length; i++) {
|
|
200
212
|
const m = snapshot[i];
|
|
201
|
-
|
|
213
|
+
// Detection ctx (an element-vs-null result must not depend on cluster
|
|
214
|
+
// state — see CustomMarkerOverlay); the live ctx is applied there.
|
|
215
|
+
const ctx: MarkerRenderContext = {
|
|
216
|
+
index: i,
|
|
217
|
+
isGrouped: false,
|
|
218
|
+
groupCount: 0,
|
|
219
|
+
side: m.side ?? "center",
|
|
220
|
+
};
|
|
221
|
+
if (renderMarker(m, ctx) != null) k += `${m.id}\x1f`;
|
|
202
222
|
}
|
|
203
223
|
return k;
|
|
204
224
|
}, [snapshot, renderMarker]);
|
|
@@ -225,13 +245,22 @@ export function MarkerOverlay({
|
|
|
225
245
|
// Rasterize at the screen's device-pixel ratio so sprites stay crisp on
|
|
226
246
|
// retina canvases instead of being upscaled from a logical-sized texture.
|
|
227
247
|
const dpr = PixelRatio.get();
|
|
248
|
+
const clusterStacked = cluster.mode === "stacked";
|
|
228
249
|
const atlas = useMemo(
|
|
229
|
-
() =>
|
|
250
|
+
() =>
|
|
251
|
+
buildMarkerAtlas(
|
|
252
|
+
snapshot.filter((m) => !customIds[m.id]),
|
|
253
|
+
palette,
|
|
254
|
+
font,
|
|
255
|
+
dpr,
|
|
256
|
+
clusterStacked,
|
|
257
|
+
),
|
|
230
258
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
|
|
231
|
-
[appearanceKey, paletteKey, font, dpr],
|
|
259
|
+
[appearanceKey, paletteKey, font, dpr, clusterStacked],
|
|
232
260
|
);
|
|
233
261
|
const cells: Record<string, AtlasCell> = atlas.cells;
|
|
234
262
|
const atlasImage = atlas.image;
|
|
263
|
+
const digitWidths = atlas.digitWidths;
|
|
235
264
|
// Inverse of the texture's device-pixel scale; shrinks each hi-res cell back
|
|
236
265
|
// to its logical on-canvas size in the per-frame blit.
|
|
237
266
|
const invScale = 1 / atlas.scale;
|
|
@@ -271,15 +300,59 @@ export function MarkerOverlay({
|
|
|
271
300
|
lineData: lineData?.get(),
|
|
272
301
|
lineLinear,
|
|
273
302
|
});
|
|
303
|
+
clusterMarkers(ms, buf, { config: cluster });
|
|
274
304
|
const transforms = [];
|
|
275
305
|
const sprites = [];
|
|
276
306
|
for (let i = 0; i < ms.length; i++) {
|
|
277
307
|
const pt = buf[i];
|
|
278
|
-
|
|
308
|
+
// Collapsed-cluster members fold into their representative's badge.
|
|
309
|
+
if (!pt.visible || pt.hidden) continue;
|
|
279
310
|
const m = ms[i];
|
|
280
311
|
if (isConnectorMarker(m)) continue;
|
|
281
312
|
// Custom-rendered markers are floated as RN views, not drawn here.
|
|
282
313
|
if (customIds[m.id]) continue;
|
|
314
|
+
// Collapsed cluster: draw a count badge (bg stadium + composed digits)
|
|
315
|
+
// instead of the marker's own glyph, all within this one `drawAtlas`.
|
|
316
|
+
if (pt.isGrouped) {
|
|
317
|
+
const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
|
|
318
|
+
const text = groupCountText(pt.groupCount);
|
|
319
|
+
const bg = cells[groupBgSig(repColor)];
|
|
320
|
+
if (bg) {
|
|
321
|
+
transforms.push(
|
|
322
|
+
Skia.RSXform(invScale, 0, pt.x - bg.w / 2, pt.y - bg.h / 2),
|
|
323
|
+
);
|
|
324
|
+
sprites.push(bg.rect);
|
|
325
|
+
}
|
|
326
|
+
// Lay the digits out proportionally (each by its own ink width), scaled
|
|
327
|
+
// down to fit the small round badge, and center the whole number. A
|
|
328
|
+
// negative kern (fraction of a digit) closes the font's side-bearing gap
|
|
329
|
+
// so "12" reads tight, not "1 2".
|
|
330
|
+
const S = BADGE_TEXT_SCALE;
|
|
331
|
+
let maxDW = 1;
|
|
332
|
+
for (let d = 0; d < 10; d++) {
|
|
333
|
+
const dw = digitWidths["" + d] ?? 0;
|
|
334
|
+
if (dw > maxDW) maxDW = dw;
|
|
335
|
+
}
|
|
336
|
+
const kern = -maxDW * S * 0.42;
|
|
337
|
+
let totalW = 0;
|
|
338
|
+
for (let c = 0; c < text.length; c++) {
|
|
339
|
+
totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
|
|
340
|
+
}
|
|
341
|
+
let dx = pt.x - totalW / 2;
|
|
342
|
+
for (let c = 0; c < text.length; c++) {
|
|
343
|
+
const w = (digitWidths[text[c]] ?? 0) * S;
|
|
344
|
+
const gc = cells[groupGlyphSig(text[c])];
|
|
345
|
+
if (gc) {
|
|
346
|
+
const cx = dx + w / 2;
|
|
347
|
+
transforms.push(
|
|
348
|
+
Skia.RSXform(invScale * S, 0, cx - (gc.w * S) / 2, pt.y - (gc.h * S) / 2),
|
|
349
|
+
);
|
|
350
|
+
sprites.push(gc.rect);
|
|
351
|
+
}
|
|
352
|
+
dx += w + kern;
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
283
356
|
const cell = cells[markerAppearanceSig(m)];
|
|
284
357
|
if (!cell) continue;
|
|
285
358
|
// Center the cell on the projected point. The cell's source rect is in
|
|
@@ -291,7 +364,20 @@ export function MarkerOverlay({
|
|
|
291
364
|
}
|
|
292
365
|
return { transforms, sprites };
|
|
293
366
|
},
|
|
294
|
-
[
|
|
367
|
+
[
|
|
368
|
+
cells,
|
|
369
|
+
customIds,
|
|
370
|
+
invScale,
|
|
371
|
+
digitWidths,
|
|
372
|
+
markers,
|
|
373
|
+
engine,
|
|
374
|
+
padding,
|
|
375
|
+
palette,
|
|
376
|
+
series,
|
|
377
|
+
lineData,
|
|
378
|
+
lineLinear,
|
|
379
|
+
cluster,
|
|
380
|
+
],
|
|
295
381
|
);
|
|
296
382
|
const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
|
|
297
383
|
const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Circle, Group } from "@shopify/react-native-skia";
|
|
2
2
|
|
|
3
|
-
import { useDerivedValue } from "react-native-reanimated";
|
|
3
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
4
4
|
import { MAX_MULTI_SERIES } from "../constants";
|
|
5
5
|
import type { ChartPadding } from "../draw/line";
|
|
6
6
|
import type {
|
|
@@ -21,6 +21,7 @@ function SeriesDotAtIndex({
|
|
|
21
21
|
ring,
|
|
22
22
|
ringColor,
|
|
23
23
|
pulse,
|
|
24
|
+
viewEnd,
|
|
24
25
|
}: {
|
|
25
26
|
index: number;
|
|
26
27
|
engine: MultiEngineState;
|
|
@@ -32,6 +33,8 @@ function SeriesDotAtIndex({
|
|
|
32
33
|
/** Fallback ring color when `ring.color` is unset (theme `badgeOuterBg`). */
|
|
33
34
|
ringColor: string;
|
|
34
35
|
pulse: ResolvedPulseConfig | null;
|
|
36
|
+
/** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */
|
|
37
|
+
viewEnd?: SharedValue<number | null>;
|
|
35
38
|
}) {
|
|
36
39
|
const dotX = useDerivedValue(() => {
|
|
37
40
|
const w = engine.canvasWidth.value;
|
|
@@ -62,7 +65,9 @@ function SeriesDotAtIndex({
|
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
const pulseRadius = useDerivedValue(() => {
|
|
65
|
-
|
|
68
|
+
// A live heartbeat on a frozen historical point is wrong (and `timestamp` is
|
|
69
|
+
// frozen, so it wouldn't animate) — suppress the pulse while scrolled back.
|
|
70
|
+
if (!pulse || viewEnd?.value != null) return 0;
|
|
66
71
|
const nowMs = engine.timestamp.value * 1000 + index * PULSE_STAGGER_MS;
|
|
67
72
|
const t = (nowMs % pulse.interval) / pulse.duration;
|
|
68
73
|
if (t >= 1) return 0;
|
|
@@ -70,7 +75,7 @@ function SeriesDotAtIndex({
|
|
|
70
75
|
});
|
|
71
76
|
|
|
72
77
|
const pulseOpacity = useDerivedValue(() => {
|
|
73
|
-
if (!pulse) return 0;
|
|
78
|
+
if (!pulse || viewEnd?.value != null) return 0;
|
|
74
79
|
const nowMs = engine.timestamp.value * 1000 + index * PULSE_STAGGER_MS;
|
|
75
80
|
const t = (nowMs % pulse.interval) / pulse.duration;
|
|
76
81
|
if (t >= 1) return 0;
|
|
@@ -112,6 +117,7 @@ export function MultiSeriesDots({
|
|
|
112
117
|
ringColor,
|
|
113
118
|
color,
|
|
114
119
|
pulse,
|
|
120
|
+
viewEnd,
|
|
115
121
|
}: {
|
|
116
122
|
engine: MultiEngineState;
|
|
117
123
|
padding: ChartPadding;
|
|
@@ -124,6 +130,8 @@ export function MultiSeriesDots({
|
|
|
124
130
|
/** Fill color override; falls back to each series' line color. */
|
|
125
131
|
color: string | undefined;
|
|
126
132
|
pulse: ResolvedPulseConfig | null;
|
|
133
|
+
/** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */
|
|
134
|
+
viewEnd?: SharedValue<number | null>;
|
|
127
135
|
}) {
|
|
128
136
|
return (
|
|
129
137
|
<Group>
|
|
@@ -138,6 +146,7 @@ export function MultiSeriesDots({
|
|
|
138
146
|
ring={ring}
|
|
139
147
|
ringColor={ringColor}
|
|
140
148
|
pulse={pulse}
|
|
149
|
+
viewEnd={viewEnd}
|
|
141
150
|
/>
|
|
142
151
|
))}
|
|
143
152
|
</Group>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Group,
|
|
3
|
+
RoundedRect,
|
|
4
|
+
Text as SkiaText,
|
|
5
|
+
type SkFont,
|
|
6
|
+
} from "@shopify/react-native-skia";
|
|
7
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
8
|
+
|
|
9
|
+
import type { ChartPadding } from "../draw/line";
|
|
10
|
+
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
11
|
+
import type { ReferenceGrouping } from "../math/referenceGroup";
|
|
12
|
+
import type { ResolvedReferenceGroupBadge } from "../math/referenceLines";
|
|
13
|
+
import type { LiveChartPalette } from "../types";
|
|
14
|
+
|
|
15
|
+
/** Max simultaneous group handles (a fixed Skia slot pool — the per-frame group
|
|
16
|
+
* count varies, so we draw a fixed set of slots and hide the unused ones). */
|
|
17
|
+
const MAX_GROUP_PILLS = 12;
|
|
18
|
+
/** Horizontal padding inside the count pill, in px. */
|
|
19
|
+
const PILL_PAD_X = 6;
|
|
20
|
+
/** Vertical padding inside the count pill, in px. */
|
|
21
|
+
const PILL_PAD_Y = 3;
|
|
22
|
+
/** Pill inset from the anchored plot edge, in px. */
|
|
23
|
+
const EDGE_INSET = 2;
|
|
24
|
+
|
|
25
|
+
interface PillLayout {
|
|
26
|
+
visible: boolean;
|
|
27
|
+
x: number;
|
|
28
|
+
top: number;
|
|
29
|
+
w: number;
|
|
30
|
+
text: string;
|
|
31
|
+
textX: number;
|
|
32
|
+
textY: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HIDDEN_PILL: PillLayout = {
|
|
36
|
+
visible: false,
|
|
37
|
+
x: 0,
|
|
38
|
+
top: -1,
|
|
39
|
+
w: 0,
|
|
40
|
+
text: "",
|
|
41
|
+
textX: 0,
|
|
42
|
+
textY: -1,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** One collapsed-group count pill (slot `index` of the fixed pool). Reads the
|
|
46
|
+
* cluster at `grouping.groups[index]`; hidden when fewer groups exist this frame. */
|
|
47
|
+
function GroupCountPill({
|
|
48
|
+
grouping,
|
|
49
|
+
index,
|
|
50
|
+
padding,
|
|
51
|
+
canvasWidth,
|
|
52
|
+
font,
|
|
53
|
+
position,
|
|
54
|
+
icon,
|
|
55
|
+
showText,
|
|
56
|
+
format,
|
|
57
|
+
baselineOffset,
|
|
58
|
+
pillH,
|
|
59
|
+
radius,
|
|
60
|
+
borderWidth,
|
|
61
|
+
color,
|
|
62
|
+
background,
|
|
63
|
+
textColor,
|
|
64
|
+
}: {
|
|
65
|
+
grouping: SharedValue<ReferenceGrouping>;
|
|
66
|
+
index: number;
|
|
67
|
+
padding: ChartPadding;
|
|
68
|
+
canvasWidth: SharedValue<number>;
|
|
69
|
+
font: SkFont;
|
|
70
|
+
position: "left" | "center" | "right";
|
|
71
|
+
icon: string;
|
|
72
|
+
showText: boolean;
|
|
73
|
+
format?: (count: number) => string;
|
|
74
|
+
baselineOffset: number;
|
|
75
|
+
pillH: number;
|
|
76
|
+
radius: number;
|
|
77
|
+
borderWidth: number;
|
|
78
|
+
color: string;
|
|
79
|
+
background: string;
|
|
80
|
+
textColor: string;
|
|
81
|
+
}) {
|
|
82
|
+
const layout = useDerivedValue<PillLayout>(() => {
|
|
83
|
+
const gs = grouping.get().groups;
|
|
84
|
+
if (index >= gs.length) return HIDDEN_PILL;
|
|
85
|
+
const g = gs[index];
|
|
86
|
+
const count = showText ? (format ? format(g.count) : String(g.count)) : "";
|
|
87
|
+
const text = icon ? (count ? `${icon} ${count}` : icon) : count;
|
|
88
|
+
const w = measureFontTextWidth(font, text) + PILL_PAD_X * 2;
|
|
89
|
+
// Anchor the pill horizontally to the chosen plot edge (or center).
|
|
90
|
+
const x1 = padding.left;
|
|
91
|
+
const x2 = canvasWidth.get() - padding.right;
|
|
92
|
+
let x: number;
|
|
93
|
+
if (position === "right") x = x2 - EDGE_INSET - w;
|
|
94
|
+
else if (position === "center") x = (x1 + x2) / 2 - w / 2;
|
|
95
|
+
else x = x1 + EDGE_INSET;
|
|
96
|
+
return {
|
|
97
|
+
visible: true,
|
|
98
|
+
x,
|
|
99
|
+
top: g.cy - pillH / 2,
|
|
100
|
+
w,
|
|
101
|
+
text,
|
|
102
|
+
textX: x + PILL_PAD_X,
|
|
103
|
+
textY: g.cy - baselineOffset,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const opacity = useDerivedValue(() => (layout.get().visible ? 1 : 0));
|
|
108
|
+
const x = useDerivedValue(() => layout.get().x);
|
|
109
|
+
const top = useDerivedValue(() => layout.get().top);
|
|
110
|
+
const w = useDerivedValue(() => layout.get().w);
|
|
111
|
+
const text = useDerivedValue(() => layout.get().text);
|
|
112
|
+
const textX = useDerivedValue(() => layout.get().textX);
|
|
113
|
+
const textY = useDerivedValue(() => layout.get().textY);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Group opacity={opacity}>
|
|
117
|
+
<RoundedRect
|
|
118
|
+
x={x}
|
|
119
|
+
y={top}
|
|
120
|
+
width={w}
|
|
121
|
+
height={pillH}
|
|
122
|
+
r={radius}
|
|
123
|
+
color={background}
|
|
124
|
+
/>
|
|
125
|
+
<RoundedRect
|
|
126
|
+
x={x}
|
|
127
|
+
y={top}
|
|
128
|
+
width={w}
|
|
129
|
+
height={pillH}
|
|
130
|
+
r={radius}
|
|
131
|
+
color={color}
|
|
132
|
+
style="stroke"
|
|
133
|
+
strokeWidth={borderWidth}
|
|
134
|
+
/>
|
|
135
|
+
<SkiaText x={textX} y={textY} text={text} font={font} color={textColor} />
|
|
136
|
+
</Group>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Draws the collapsed-group count handles (e.g. a "×3" pill) for reference-line
|
|
142
|
+
* grouping — one pill per multi-line cluster, pinned to the plot at the cluster
|
|
143
|
+
* centroid. The individual tags of clustered lines are suppressed upstream (via
|
|
144
|
+
* `groupHidden`), so a stack of nearby orders reads as one handle. The pill uses the
|
|
145
|
+
* same style/shape config as a per-line badge (`badge`): `position`, `icon`,
|
|
146
|
+
* `text` (showText), `background` / `borderColor` / `borderWidth`, `radius` (corner),
|
|
147
|
+
* `textColor`, per-badge `font`, and `offsetX` / `offsetY`; the `format` fn maps the
|
|
148
|
+
* count to the label. A fixed slot pool keeps the Skia tree stable while the
|
|
149
|
+
* per-frame group count varies.
|
|
150
|
+
*/
|
|
151
|
+
export function ReferenceLineGroupOverlay({
|
|
152
|
+
grouping,
|
|
153
|
+
padding,
|
|
154
|
+
canvasWidth,
|
|
155
|
+
palette,
|
|
156
|
+
font,
|
|
157
|
+
badge,
|
|
158
|
+
format,
|
|
159
|
+
}: {
|
|
160
|
+
grouping: SharedValue<ReferenceGrouping>;
|
|
161
|
+
padding: ChartPadding;
|
|
162
|
+
canvasWidth: SharedValue<number>;
|
|
163
|
+
palette: LiveChartPalette;
|
|
164
|
+
font: SkFont;
|
|
165
|
+
badge: ResolvedReferenceGroupBadge;
|
|
166
|
+
format?: (count: number) => string;
|
|
167
|
+
}) {
|
|
168
|
+
const fm = font.getMetrics();
|
|
169
|
+
const baselineOffset = (fm.ascent + fm.descent) / 2;
|
|
170
|
+
const pillH = fm.descent - fm.ascent + PILL_PAD_Y * 2;
|
|
171
|
+
|
|
172
|
+
// Theme defaults for the unset colors (mirrors the per-line badge resolution).
|
|
173
|
+
const background = badge.background ?? palette.tooltipBg;
|
|
174
|
+
const borderColor = badge.borderColor ?? palette.refLine;
|
|
175
|
+
const textColor = badge.textColor ?? palette.refLabel;
|
|
176
|
+
|
|
177
|
+
const transform =
|
|
178
|
+
badge.offsetX !== 0 || badge.offsetY !== 0
|
|
179
|
+
? [{ translateX: badge.offsetX }, { translateY: badge.offsetY }]
|
|
180
|
+
: undefined;
|
|
181
|
+
|
|
182
|
+
const slots: React.ReactElement[] = [];
|
|
183
|
+
for (let i = 0; i < MAX_GROUP_PILLS; i++) {
|
|
184
|
+
slots.push(
|
|
185
|
+
<GroupCountPill
|
|
186
|
+
key={i}
|
|
187
|
+
grouping={grouping}
|
|
188
|
+
index={i}
|
|
189
|
+
padding={padding}
|
|
190
|
+
canvasWidth={canvasWidth}
|
|
191
|
+
font={font}
|
|
192
|
+
position={badge.position}
|
|
193
|
+
icon={badge.icon}
|
|
194
|
+
showText={badge.showText}
|
|
195
|
+
format={format}
|
|
196
|
+
baselineOffset={baselineOffset}
|
|
197
|
+
pillH={pillH}
|
|
198
|
+
radius={badge.radius}
|
|
199
|
+
borderWidth={badge.borderWidth}
|
|
200
|
+
color={borderColor}
|
|
201
|
+
background={background}
|
|
202
|
+
textColor={textColor}
|
|
203
|
+
/>,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return <Group transform={transform}>{slots}</Group>;
|
|
207
|
+
}
|