react-native-livechart 3.5.1 → 3.7.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.
@@ -0,0 +1,168 @@
1
+ import { useState } from "react";
2
+ import { StyleSheet, View, type LayoutChangeEvent } from "react-native";
3
+ import Animated, {
4
+ useAnimatedReaction,
5
+ useAnimatedStyle,
6
+ useDerivedValue,
7
+ useSharedValue,
8
+ type SharedValue,
9
+ } from "react-native-reanimated";
10
+ import { scheduleOnRN } from "react-native-worklets";
11
+
12
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
13
+ import type { ChartPadding } from "../draw/line";
14
+ import { markersSignature, projectPoint } from "../math/markers";
15
+ import type { LiveChartPoint, Marker, SeriesConfig } from "../types";
16
+
17
+ /**
18
+ * One custom-rendered marker: a React Native element floated over the canvas and
19
+ * pinned to the marker's live `(time, value)` position. Each glyph projects its
20
+ * OWN marker every frame (mirrors `ConnectorGlyph`) so reordering the array can't
21
+ * make it flash another marker's spot, and the transform runs on the UI thread —
22
+ * no JS re-render as the chart scrolls/rescales.
23
+ *
24
+ * The element is auto-centered on the point: its size is measured via `onLayout`
25
+ * into a SharedValue, and the animated transform offsets by half that size.
26
+ * `pointerEvents="box-none"` lets empty space fall through to the scrub gesture
27
+ * while still allowing an interactive leaf inside the custom element to be tapped.
28
+ */
29
+ function CustomMarkerView({
30
+ marker,
31
+ element,
32
+ engine,
33
+ padding,
34
+ seriesSV,
35
+ lineDataSV,
36
+ }: {
37
+ marker: Marker;
38
+ element: React.ReactElement;
39
+ engine: ChartEngineLayout;
40
+ padding: ChartPadding;
41
+ seriesSV?: SharedValue<SeriesConfig[]>;
42
+ lineDataSV?: SharedValue<LiveChartPoint[]>;
43
+ }) {
44
+ const time = marker.time;
45
+ const value = marker.value;
46
+ const seriesId = marker.seriesId;
47
+
48
+ const layout = useDerivedValue(() =>
49
+ projectPoint(time, value, seriesId, {
50
+ canvasWidth: engine.canvasWidth.get(),
51
+ canvasHeight: engine.canvasHeight.get(),
52
+ padTop: padding.top,
53
+ padBottom: padding.bottom,
54
+ padLeft: padding.left,
55
+ padRight: padding.right,
56
+ timestamp: engine.timestamp.get(),
57
+ displayWindow: engine.displayWindow.get(),
58
+ displayMin: engine.displayMin.get(),
59
+ displayMax: engine.displayMax.get(),
60
+ series: seriesSV?.get(),
61
+ lineData: lineDataSV?.get(),
62
+ }),
63
+ );
64
+
65
+ // Measured element size, so the transform can center it on the point.
66
+ const size = useSharedValue({ width: 0, height: 0 });
67
+ const onLayout = (e: LayoutChangeEvent) => {
68
+ const { width, height } = e.nativeEvent.layout;
69
+ size.value = { width, height };
70
+ };
71
+
72
+ const animatedStyle = useAnimatedStyle(() => {
73
+ const l = layout.get();
74
+ const s = size.get();
75
+ return {
76
+ opacity: l.visible ? 1 : 0,
77
+ transform: [
78
+ { translateX: l.x - s.width / 2 },
79
+ { translateY: l.y - s.height / 2 },
80
+ ],
81
+ };
82
+ });
83
+
84
+ return (
85
+ <Animated.View
86
+ pointerEvents="box-none"
87
+ onLayout={onLayout}
88
+ style={[styles.anchor, animatedStyle]}
89
+ >
90
+ {element}
91
+ </Animated.View>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * React Native overlay (NOT Skia) that floats `renderMarker` elements over the
97
+ * Skia canvas, one per marker the consumer chooses to customize. Rendered as a
98
+ * sibling of `<Canvas>` (like {@link AxisLabelOverlay}) so the elements can be
99
+ * any RN view — including non-Skia effects (e.g. a glass `BlurView`) — and stay
100
+ * crisp at native resolution instead of being rasterized into the marker atlas.
101
+ *
102
+ * The set of markers is mirrored to JS state via `markersSignature` (same as
103
+ * `MarkerOverlay`); `renderMarker` is then called per marker on the JS thread.
104
+ * Markers it returns an element for are excluded from the Skia atlas upstream, so
105
+ * there's no double-draw.
106
+ */
107
+ export function CustomMarkerOverlay({
108
+ markers,
109
+ renderMarker,
110
+ engine,
111
+ padding,
112
+ series,
113
+ lineData,
114
+ }: {
115
+ markers: SharedValue<Marker[]>;
116
+ renderMarker: (marker: Marker) => React.ReactElement | null | undefined;
117
+ engine: ChartEngineLayout;
118
+ padding: ChartPadding;
119
+ /** Multi-series data, used to anchor markers by `seriesId`. */
120
+ series?: SharedValue<SeriesConfig[]>;
121
+ /** Single-series line data; anchors markers that omit `value`. */
122
+ lineData?: SharedValue<LiveChartPoint[]>;
123
+ }) {
124
+ const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
125
+
126
+ const pull = () => {
127
+ setSnapshot(markers.get().slice());
128
+ };
129
+
130
+ useAnimatedReaction(
131
+ () => markersSignature(markers.get()),
132
+ /* istanbul ignore next -- scheduleOnRN from UI-thread reaction */
133
+ (sig, prev) => {
134
+ if (sig !== prev) scheduleOnRN(pull);
135
+ },
136
+ [markers, pull],
137
+ );
138
+
139
+ // Resolve each marker's custom element once per snapshot on the JS thread.
140
+ const custom: { marker: Marker; element: React.ReactElement }[] = [];
141
+ for (let i = 0; i < snapshot.length; i++) {
142
+ const m = snapshot[i];
143
+ const element = renderMarker(m);
144
+ if (element != null) custom.push({ marker: m, element });
145
+ }
146
+ if (custom.length === 0) return null;
147
+
148
+ return (
149
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
150
+ {custom.map(({ marker, element }) => (
151
+ <CustomMarkerView
152
+ key={marker.id}
153
+ marker={marker}
154
+ element={element}
155
+ engine={engine}
156
+ padding={padding}
157
+ seriesSV={series}
158
+ lineDataSV={lineData}
159
+ />
160
+ ))}
161
+ </View>
162
+ );
163
+ }
164
+
165
+ const styles = StyleSheet.create({
166
+ // top/left 0 + translate so the measured element can be centered on the point.
167
+ anchor: { position: "absolute", top: 0, left: 0 },
168
+ });
@@ -0,0 +1,135 @@
1
+ import { StyleSheet, View, type LayoutChangeEvent } from "react-native";
2
+ import Animated, {
3
+ useAnimatedStyle,
4
+ useDerivedValue,
5
+ useSharedValue,
6
+ type SharedValue,
7
+ } from "react-native-reanimated";
8
+
9
+ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
10
+ import type { ChartPadding } from "../draw/line";
11
+ import type { TooltipLayout } from "../hooks/crosshairShared";
12
+ import type { TooltipRenderProps } from "../types";
13
+
14
+ // Mirror the Skia tooltip's offsets (see crosshairShared.ts) so a custom pill
15
+ // lines up with where the built-in one would sit. The vertical edge gap is
16
+ // configurable via `scrub.tooltipMargin` (passed as `margin`).
17
+ const OFFSET_X = 12;
18
+ const EDGE_GAP = 4;
19
+
20
+ /**
21
+ * React Native overlay (NOT Skia) that floats a custom `renderTooltip` element
22
+ * over the Skia canvas while scrubbing. A sibling of `<Canvas>` — like
23
+ * {@link AxisLabelOverlay} and `CustomMarkerOverlay` — so the element can be any
24
+ * RN view (e.g. a glass `BlurView`) and stays crisp at native resolution.
25
+ *
26
+ * The element is positioned entirely on the UI thread: a `useAnimatedStyle`
27
+ * transform tracks `scrubX` + the resolved `tooltipPlacement`, measuring the
28
+ * element's own size via `onLayout` so it can flip/clamp/center correctly — so
29
+ * movement stays smooth without JS re-renders, unlike rebuilding the tooltip
30
+ * from the JS-thread `onScrub`. The consumer binds the {@link TooltipRenderProps}
31
+ * SharedValues to animated text for the value/date to update on the UI thread.
32
+ *
33
+ * `pointerEvents="box-none"` lets the scrub pan gesture pass through the empty
34
+ * area (and any non-interactive tooltip body) while still allowing an
35
+ * interactive leaf inside the custom element to be tapped.
36
+ */
37
+ export function CustomTooltipOverlay({
38
+ renderTooltip,
39
+ scrubX,
40
+ scrubValue,
41
+ scrubTime,
42
+ scrubActive,
43
+ crosshairOpacity,
44
+ tooltipLayout,
45
+ engine,
46
+ padding,
47
+ placement,
48
+ margin = 8,
49
+ }: {
50
+ renderTooltip: (ctx: TooltipRenderProps) => React.ReactElement | null | undefined;
51
+ scrubX: SharedValue<number>;
52
+ scrubValue: SharedValue<number | null>;
53
+ scrubTime: SharedValue<number>;
54
+ scrubActive: SharedValue<boolean>;
55
+ crosshairOpacity: SharedValue<number>;
56
+ tooltipLayout: SharedValue<TooltipLayout>;
57
+ engine: ChartEngineLayout;
58
+ padding: ChartPadding;
59
+ placement: "side" | "top" | "bottom";
60
+ /** Gap (px) between the pill and the plot edge it's pinned to. Default 8. */
61
+ margin?: number;
62
+ }) {
63
+ // Formatted strings come ready-made off the layout (formatted UI-side in
64
+ // computeTooltipLayout), so consumers don't need a worklet-safe formatter.
65
+ const valueStr = useDerivedValue(() => tooltipLayout.get().valueStr);
66
+ const timeStr = useDerivedValue(() => tooltipLayout.get().timeStr);
67
+
68
+ const ctx: TooltipRenderProps = {
69
+ value: scrubValue,
70
+ time: scrubTime,
71
+ valueStr,
72
+ timeStr,
73
+ active: scrubActive,
74
+ };
75
+ const element = renderTooltip(ctx);
76
+
77
+ // Measured element size, so the transform can flip/center/clamp the pill.
78
+ const size = useSharedValue({ width: 0, height: 0 });
79
+ const onLayout = (e: LayoutChangeEvent) => {
80
+ const { width, height } = e.nativeEvent.layout;
81
+ size.value = { width, height };
82
+ };
83
+
84
+ const animatedStyle = useAnimatedStyle(() => {
85
+ const active = scrubActive.get();
86
+ const sx = scrubX.get();
87
+ const cw = engine.canvasWidth.get();
88
+ const ch = engine.canvasHeight.get();
89
+ const s = size.get();
90
+ const rightEdge = cw - padding.right;
91
+
92
+ let x: number;
93
+ let y: number;
94
+ if (placement === "side") {
95
+ x = sx + OFFSET_X;
96
+ if (x + s.width > rightEdge - EDGE_GAP) x = sx - s.width - OFFSET_X;
97
+ y = padding.top + margin;
98
+ } else {
99
+ const leftBound = padding.left + EDGE_GAP;
100
+ const rightBound = rightEdge - EDGE_GAP - s.width;
101
+ x = Math.min(
102
+ Math.max(sx - s.width / 2, leftBound),
103
+ Math.max(leftBound, rightBound),
104
+ );
105
+ y =
106
+ placement === "top"
107
+ ? padding.top + margin
108
+ : ch - padding.bottom - margin - s.height;
109
+ }
110
+
111
+ return {
112
+ opacity: active ? crosshairOpacity.get() : 0,
113
+ transform: [{ translateX: x }, { translateY: y }],
114
+ };
115
+ });
116
+
117
+ if (element == null) return null;
118
+
119
+ return (
120
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
121
+ <Animated.View
122
+ pointerEvents="box-none"
123
+ onLayout={onLayout}
124
+ style={[styles.anchor, animatedStyle]}
125
+ >
126
+ {element}
127
+ </Animated.View>
128
+ </View>
129
+ );
130
+ }
131
+
132
+ const styles = StyleSheet.create({
133
+ // top/left 0 + transform so the measured element can be placed precisely.
134
+ anchor: { position: "absolute", top: 0, left: 0 },
135
+ });
@@ -86,6 +86,8 @@ import {
86
86
  ThresholdLineOverlay,
87
87
  } from "./ThresholdLineOverlay";
88
88
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
89
+ import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
90
+ import { CustomTooltipOverlay } from "./CustomTooltipOverlay";
89
91
  import { BadgeOverlay } from "./BadgeOverlay";
90
92
  import { CrosshairOverlay } from "./CrosshairOverlay";
91
93
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
@@ -199,6 +201,8 @@ function useLiveChartController({
199
201
  markers,
200
202
  onMarkerHover,
201
203
  markerHitRadius = 16,
204
+ renderMarker,
205
+ renderTooltip,
202
206
  leftEdgeFade = true,
203
207
 
204
208
  // ── Callbacks ───────────────────────────────────────────────────────────
@@ -530,6 +534,10 @@ function useLiveChartController({
530
534
  onScrubAction,
531
535
  metricsCfg.badge,
532
536
  markersActive || refPressActive ? deferTapHit : undefined,
537
+ scrubCfg?.tooltipPlacement ?? "side",
538
+ scrubCfg?.tooltipShowValue ?? true,
539
+ scrubCfg?.tooltipShowTime ?? true,
540
+ scrubCfg?.tooltipMargin ?? 8,
533
541
  );
534
542
 
535
543
  // Scrub-action composes a Tap (place/move the reticle, press the badge, dismiss)
@@ -682,6 +690,8 @@ function useLiveChartController({
682
690
  rootGesture,
683
691
  markersActive,
684
692
  markersSV,
693
+ renderMarker,
694
+ renderTooltip,
685
695
  // selection dot: resolved config + fallback color (the chart line/accent color)
686
696
  selectionDot: selectionDotCfg,
687
697
  selectionColor: lineProp?.color ?? palette.line,
@@ -819,6 +829,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
819
829
  degenPackRevision,
820
830
  markersActive,
821
831
  markersSV,
832
+ renderMarker,
822
833
  emptyText,
823
834
  metricsCfg,
824
835
  layoutWidth,
@@ -996,6 +1007,7 @@ function ChartStack({ model }: { model: LiveChartModel }) {
996
1007
  palette={palette}
997
1008
  font={skiaFont}
998
1009
  lineData={engine.data}
1010
+ renderMarker={renderMarker}
999
1011
  />
1000
1012
  </Group>
1001
1013
  )}
@@ -1055,8 +1067,14 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1055
1067
  dotOuterRadius,
1056
1068
  selectionDot,
1057
1069
  selectionColor,
1070
+ renderTooltip,
1058
1071
  } = model;
1059
1072
 
1073
+ // A custom line-mode tooltip is an RN overlay (sibling of <Canvas>), so the
1074
+ // default Skia pill is suppressed here while it's active. Candle keeps its
1075
+ // built-in OHLC stack (renderTooltip is ignored in candle mode).
1076
+ const customTooltipActive = !isCandle && renderTooltip != null;
1077
+
1060
1078
  if (!tradeStreamResolved && !scrubCfg) return null;
1061
1079
 
1062
1080
  // Extend the scrub dim past the plot's right edge to fully cover the live dot
@@ -1089,7 +1107,7 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1089
1107
  padding={effectivePadding}
1090
1108
  palette={palette}
1091
1109
  font={skiaFont}
1092
- showTooltip={scrubCfg.tooltip}
1110
+ showTooltip={scrubCfg.tooltip && !customTooltipActive}
1093
1111
  selectionDot={selectionDot}
1094
1112
  selectionY={crosshair.scrubDotY}
1095
1113
  scrubActive={crosshair.scrubActive}
@@ -1101,6 +1119,9 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
1101
1119
  tooltipBackground={scrubCfg.tooltipBackground}
1102
1120
  tooltipColor={scrubCfg.tooltipColor}
1103
1121
  tooltipBorderColor={scrubCfg.tooltipBorderColor}
1122
+ tooltipBorderRadius={scrubCfg.tooltipBorderRadius}
1123
+ tooltipShowValue={scrubCfg.tooltipShowValue}
1124
+ tooltipShowTime={scrubCfg.tooltipShowTime}
1104
1125
  >
1105
1126
  {/* Candle charts render a multi-line OHLC tooltip; the line
1106
1127
  chart falls back to CrosshairOverlay's default value/time
@@ -1251,6 +1272,13 @@ export function LiveChart(props: LiveChartProps) {
1251
1272
  formatValue,
1252
1273
  topLabelCfg,
1253
1274
  bottomLabelCfg,
1275
+ markersActive,
1276
+ markersSV,
1277
+ renderMarker,
1278
+ renderTooltip,
1279
+ scrubCfg,
1280
+ crosshair,
1281
+ isCandle,
1254
1282
  } = model;
1255
1283
 
1256
1284
  return (
@@ -1306,6 +1334,36 @@ export function LiveChart(props: LiveChartProps) {
1306
1334
  defaultColor={palette.gridLabel}
1307
1335
  padding={effectivePadding}
1308
1336
  />
1337
+
1338
+ {/* Custom-rendered markers — RN views floated over the canvas (non-Skia),
1339
+ pinned to each marker's live position. Sibling of <Canvas>. */}
1340
+ {markersActive && renderMarker && (
1341
+ <CustomMarkerOverlay
1342
+ markers={markersSV}
1343
+ renderMarker={renderMarker}
1344
+ engine={engine}
1345
+ padding={effectivePadding}
1346
+ lineData={engine.data}
1347
+ />
1348
+ )}
1349
+
1350
+ {/* Custom scrub tooltip — an RN view floated over the canvas (non-Skia),
1351
+ positioned on the UI thread. Sibling of <Canvas>. Line mode only. */}
1352
+ {scrubCfg && renderTooltip && !isCandle && (
1353
+ <CustomTooltipOverlay
1354
+ renderTooltip={renderTooltip}
1355
+ scrubX={crosshair.scrubX}
1356
+ scrubValue={crosshair.scrubValue}
1357
+ scrubTime={crosshair.scrubTime}
1358
+ scrubActive={crosshair.scrubActive}
1359
+ crosshairOpacity={crosshair.crosshairOpacity}
1360
+ tooltipLayout={crosshair.tooltipLayout}
1361
+ engine={engine}
1362
+ padding={effectivePadding}
1363
+ placement={scrubCfg.tooltipPlacement}
1364
+ margin={scrubCfg.tooltipMargin}
1365
+ />
1366
+ )}
1309
1367
  </View>
1310
1368
  </GestureDetector>
1311
1369
  );
@@ -63,6 +63,7 @@ import {
63
63
  } from "../theme";
64
64
  import type { LiveChartSeriesProps, Marker, SeriesConfig } from "../types";
65
65
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
66
+ import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
66
67
  import { CrosshairLine } from "./CrosshairLine";
67
68
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
68
69
  import { LeftEdgeFade } from "./LeftEdgeFade";
@@ -143,6 +144,7 @@ function useLiveChartSeriesController({
143
144
  markers,
144
145
  onMarkerHover,
145
146
  markerHitRadius = 16,
147
+ renderMarker,
146
148
  leftEdgeFade = true,
147
149
  }: LiveChartSeriesProps) {
148
150
  const emptyMarkers = useSharedValue<Marker[]>([]);
@@ -386,6 +388,7 @@ function useLiveChartSeriesController({
386
388
  rootGesture,
387
389
  markersActive,
388
390
  markersSV,
391
+ renderMarker,
389
392
  // selection dot: resolved config + fallback color (the leading series' color)
390
393
  selectionDot: selectionDotCfg,
391
394
  selectionColor: lineColors[0],
@@ -426,6 +429,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
426
429
  degenPackRevision,
427
430
  markersActive,
428
431
  markersSV,
432
+ renderMarker,
429
433
  series,
430
434
  emptyText,
431
435
  metricsCfg,
@@ -541,6 +545,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
541
545
  palette={palette}
542
546
  font={skiaFont}
543
547
  series={series}
548
+ renderMarker={renderMarker}
544
549
  />
545
550
  </Group>
546
551
  )}
@@ -649,6 +654,9 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
649
654
  formatValue,
650
655
  topLabelCfg,
651
656
  bottomLabelCfg,
657
+ markersActive,
658
+ markersSV,
659
+ renderMarker,
652
660
  } = model;
653
661
 
654
662
  // Extend the scrub dim past the plot's right edge to fully cover the series
@@ -735,6 +743,18 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
735
743
  defaultColor={palette.gridLabel}
736
744
  padding={effectivePadding}
737
745
  />
746
+
747
+ {/* Custom-rendered markers — RN views floated over the canvas
748
+ (non-Skia), pinned to each marker's live position. */}
749
+ {markersActive && renderMarker && (
750
+ <CustomMarkerOverlay
751
+ markers={markersSV}
752
+ renderMarker={renderMarker}
753
+ engine={engine}
754
+ padding={effectivePadding}
755
+ series={series}
756
+ />
757
+ )}
738
758
  </View>
739
759
  </GestureDetector>
740
760
  {legendCfg.position === "bottom" ? legend : null}
@@ -148,6 +148,7 @@ export function MarkerOverlay({
148
148
  font,
149
149
  series,
150
150
  lineData,
151
+ renderMarker,
151
152
  }: {
152
153
  markers: SharedValue<Marker[]>;
153
154
  engine: ChartEngineLayout;
@@ -158,6 +159,12 @@ export function MarkerOverlay({
158
159
  series?: SharedValue<SeriesConfig[]>;
159
160
  /** Single-series line data; anchors markers that omit `value`. */
160
161
  lineData?: SharedValue<LiveChartPoint[]>;
162
+ /**
163
+ * When provided, markers it returns an element for are rendered as RN views by
164
+ * `CustomMarkerOverlay` instead — so they're excluded from the atlas here to
165
+ * avoid a (blurry) glyph drawn behind the custom element.
166
+ */
167
+ renderMarker?: (marker: Marker) => unknown;
161
168
  }) {
162
169
  // Seed from the current markers at mount; the reaction below keeps it in sync.
163
170
  const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
@@ -177,6 +184,24 @@ export function MarkerOverlay({
177
184
  [markers, pull],
178
185
  );
179
186
 
187
+ // Ids of markers the consumer renders as custom RN views (CustomMarkerOverlay).
188
+ // Stabilized via a string key so an inline `renderMarker` that returns the same
189
+ // set doesn't churn `customIds`' identity (which the per-frame worklet captures).
190
+ const customKey = useMemo(() => {
191
+ if (!renderMarker) return "";
192
+ let k = "";
193
+ for (let i = 0; i < snapshot.length; i++) {
194
+ const m = snapshot[i];
195
+ if (renderMarker(m) != null) k += `${m.id}\x1f`;
196
+ }
197
+ return k;
198
+ }, [snapshot, renderMarker]);
199
+ const customIds = useMemo<Record<string, true>>(() => {
200
+ const o: Record<string, true> = {};
201
+ for (const id of customKey.split("\x1f")) if (id) o[id] = true;
202
+ return o;
203
+ }, [customKey]);
204
+
180
205
  // Rebuild the atlas image only when the set of distinct appearances or the
181
206
  // theme/font changes — never per frame. `markersSignature` already drives the
182
207
  // snapshot, so this useMemo re-runs at most at the snapshot cadence.
@@ -184,17 +209,18 @@ export function MarkerOverlay({
184
209
  const sigs = new Set<string>();
185
210
  for (let i = 0; i < snapshot.length; i++) {
186
211
  const m = snapshot[i];
187
- if (!isConnectorMarker(m)) sigs.add(markerAppearanceSig(m));
212
+ if (!isConnectorMarker(m) && !customIds[m.id])
213
+ sigs.add(markerAppearanceSig(m));
188
214
  }
189
215
  return Array.from(sigs).sort().join("\x1e");
190
- }, [snapshot]);
216
+ }, [snapshot, customIds]);
191
217
  // Cells bake in resolved colors; include the palette fields they depend on.
192
218
  const paletteKey = `${palette.bgRgb.join(",")}|${palette.line}|${palette.refLine}|${palette.dotUp}|${palette.refLabel}`;
193
219
  // Rasterize at the screen's device-pixel ratio so sprites stay crisp on
194
220
  // retina canvases instead of being upscaled from a logical-sized texture.
195
221
  const dpr = PixelRatio.get();
196
222
  const atlas = useMemo(
197
- () => buildMarkerAtlas(snapshot, palette, font, dpr),
223
+ () => buildMarkerAtlas(snapshot.filter((m) => !customIds[m.id]), palette, font, dpr),
198
224
  // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
199
225
  [appearanceKey, paletteKey, font, dpr],
200
226
  );
@@ -245,6 +271,8 @@ export function MarkerOverlay({
245
271
  if (!pt.visible) continue;
246
272
  const m = ms[i];
247
273
  if (isConnectorMarker(m)) continue;
274
+ // Custom-rendered markers are floated as RN views, not drawn here.
275
+ if (customIds[m.id]) continue;
248
276
  const cell = cells[markerAppearanceSig(m)];
249
277
  if (!cell) continue;
250
278
  // Center the cell on the projected point. The cell's source rect is in
@@ -256,7 +284,7 @@ export function MarkerOverlay({
256
284
  }
257
285
  return { transforms, sprites };
258
286
  },
259
- [cells, invScale, markers, engine, padding, series, lineData],
287
+ [cells, customIds, invScale, markers, engine, padding, series, lineData],
260
288
  );
261
289
  const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
262
290
  const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
@@ -89,6 +89,16 @@ export interface ResolvedScrubConfig {
89
89
  tooltipColor: string | undefined;
90
90
  /** undefined → palette.tooltipBorder */
91
91
  tooltipBorderColor: string | undefined;
92
+ /** Tooltip pill corner radius in px. */
93
+ tooltipBorderRadius: number;
94
+ /** Where the tooltip pill sits relative to the scrub line. */
95
+ tooltipPlacement: "side" | "top" | "bottom";
96
+ /** Gap (px) between the tooltip and the plot edge it's pinned to. */
97
+ tooltipMargin: number;
98
+ /** Show the value row in the default tooltip body. */
99
+ tooltipShowValue: boolean;
100
+ /** Show the time row in the default tooltip body. */
101
+ tooltipShowTime: boolean;
92
102
  /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
93
103
  panGestureDelay: number;
94
104
  }
@@ -357,6 +367,11 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
357
367
  tooltipBackground: undefined,
358
368
  tooltipColor: undefined,
359
369
  tooltipBorderColor: undefined,
370
+ tooltipBorderRadius: 5,
371
+ tooltipPlacement: "side",
372
+ tooltipMargin: 8,
373
+ tooltipShowValue: true,
374
+ tooltipShowTime: true,
360
375
  panGestureDelay: 0,
361
376
  };
362
377