react-native-livechart 4.9.0 → 4.9.2

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.
@@ -208,7 +208,11 @@ function useLiveChartSeriesController({
208
208
  ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
209
209
  : (scrubCfg?.panGestureDelay ?? 0);
210
210
 
211
- const selectionDotCfg = resolveSelectionDot(selectionDot);
211
+ // Multi-series defaults the scrub selection dot OFF: it can only track one
212
+ // line (the leading series), which reads as a bug next to the other series.
213
+ // The crosshair line + per-series tooltip stack already mark the scrub point.
214
+ // Passing `selectionDot` explicitly (true / config) still opts it in.
215
+ const selectionDotCfg = resolveSelectionDot(selectionDot ?? false);
212
216
  const gridStyleCfg = resolveGridStyle(gridStyle);
213
217
  const dotCfg = resolveMultiSeriesDot(dotProp);
214
218
  // Outer footprint of a dot (the color-filled radius plus the halo ring).
@@ -235,7 +235,9 @@ export function tickLiveChartEngineFrame(
235
235
  }
236
236
  }
237
237
  const lc = input.liveCandle;
238
- if (lc) {
238
+ // Fold the in-progress candle in only while its bucket is visible — when
239
+ // scrolled back past it, the live candle must not stretch the Y range.
240
+ if (lc && (!scrolledBack || lc.time <= state.timestamp)) {
239
241
  /* istanbul ignore next -- trivial min/max */
240
242
  if (lc.low < tMin) {
241
243
  tMin = lc.low;
@@ -257,6 +259,11 @@ export function tickLiveChartEngineFrame(
257
259
  else hi = mid;
258
260
  }
259
261
  for (let i = lo; i < points.length; i++) {
262
+ // While scrolled back, stop at the frozen right edge (mirrors the candle
263
+ // scan) so newer points don't inflate the visible Y range. Following
264
+ // live, keep the tail inclusive — feed timestamps can run slightly ahead
265
+ // of the local clock and must not flicker out of the range.
266
+ if (scrolledBack && points[i].time > state.timestamp) break;
260
267
  const v = points[i].value;
261
268
  if (v < tMin) {
262
269
  tMin = v;
@@ -279,9 +286,15 @@ export function tickLiveChartEngineFrame(
279
286
  state.extremaMinTime = hasMin ? minTime : NaN;
280
287
  state.extremaMaxTime = hasMax ? maxTime : NaN;
281
288
 
282
- const cv = state.displayValue;
283
- if (cv < tMin) tMin = cv;
284
- if (cv > tMax) tMax = cv;
289
+ // Keep the animated live tip inside the range — but only while it's actually
290
+ // in the window. Scrolled back, the live value sits beyond the frozen right
291
+ // edge; folding it in would pin the Y range to the live price and stop the
292
+ // axis from adapting to the visible history.
293
+ if (!scrolledBack) {
294
+ const cv = state.displayValue;
295
+ if (cv < tMin) tMin = cv;
296
+ if (cv > tMax) tMax = cv;
297
+ }
285
298
 
286
299
  const ref = input.referenceValue;
287
300
  if (ref !== undefined) {
@@ -215,6 +215,12 @@ export function tickLiveChartSeriesEngineFrame(
215
215
  else hi = mid;
216
216
  }
217
217
  for (let j = lo; j < points.length; j++) {
218
+ // While scrolled back, stop at the frozen right edge so newer points
219
+ // don't inflate the visible Y range (the per-series tips folded below
220
+ // already track the edge value, so they stay in-range). Following live,
221
+ // keep the tail inclusive — feed timestamps can run slightly ahead of
222
+ // the local clock and must not flicker out of the range.
223
+ if (scrolledBack && points[j].time > state.timestamp) break;
218
224
  const v = points[j].value;
219
225
  /* istanbul ignore next -- trivial min/max */
220
226
  if (v < tMin) tMin = v;
@@ -3,14 +3,41 @@ import {
3
3
  Skia,
4
4
  useFont,
5
5
  type SkFont,
6
+ type SkFontMgr,
6
7
  } from "@shopify/react-native-skia";
7
8
 
8
9
  import { resolveFontConfig } from "../core/resolveConfig";
9
10
  import type { FontConfig } from "../types";
10
11
 
12
+ /**
13
+ * System-font match cache. `matchFont` walks the platform font manager to
14
+ * resolve a typeface — a few ms per call — and every chart resolves several
15
+ * fonts per render, so a screen full of sparklines would otherwise re-match
16
+ * the same `{family, size, weight}` dozens of times. `SkFont` instances are
17
+ * immutable and safe to share across canvases. Custom `fontManager`s bypass
18
+ * the cache (their identity isn't part of the key).
19
+ */
20
+ const systemFontCache = new Map<string, SkFont>();
21
+ let systemFontMgr: SkFontMgr | null = null;
22
+
23
+ function matchSystemFont(
24
+ fontFamily: string,
25
+ fontSize: number,
26
+ fontWeight: NonNullable<FontConfig["fontWeight"]>,
27
+ ): SkFont {
28
+ const key = `${fontFamily}|${fontSize}|${fontWeight}`;
29
+ const cached = systemFontCache.get(key);
30
+ if (cached) return cached;
31
+ systemFontMgr ??= Skia.FontMgr.System();
32
+ const font = matchFont({ fontFamily, fontSize, fontWeight }, systemFontMgr);
33
+ systemFontCache.set(key, font);
34
+ return font;
35
+ }
36
+
11
37
  /**
12
38
  * Resolves Skia text for charts: optional bundled `typeface` via `useFont`, otherwise
13
- * `matchFont` with optional custom `fontManager`.
39
+ * `matchFont` with optional custom `fontManager`. System-font matches are cached
40
+ * module-wide, so many charts sharing a family/size/weight resolve it once.
14
41
  */
15
42
  export function useChartSkiaFont(
16
43
  fontProp: FontConfig | undefined,
@@ -22,10 +49,9 @@ export function useChartSkiaFont(
22
49
  const typefaceSource = fontProp?.typeface ?? null;
23
50
  const customFont = useFont(typefaceSource, fontSize);
24
51
 
25
- const fallbackFont = matchFont(
26
- { fontFamily, fontSize, fontWeight },
27
- fontProp?.fontManager ?? Skia.FontMgr.System(),
28
- );
52
+ const fallbackFont = fontProp?.fontManager
53
+ ? matchFont({ fontFamily, fontSize, fontWeight }, fontProp.fontManager)
54
+ : matchSystemFont(fontFamily, fontSize, fontWeight);
29
55
 
30
56
  if (typefaceSource != null) {
31
57
  return customFont ?? fallbackFont;
@@ -3,7 +3,6 @@ import { Platform } from "react-native";
3
3
  import { Gesture } from "react-native-gesture-handler";
4
4
  import {
5
5
  useAnimatedReaction,
6
- useDerivedValue,
7
6
  useSharedValue,
8
7
  type SharedValue,
9
8
  } from "react-native-reanimated";
@@ -32,6 +31,7 @@ import {
32
31
  deriveCrosshairTooltipSingle,
33
32
  HIDDEN_ACTION_BADGE,
34
33
  HIDDEN_TIME_BADGE,
34
+ HIDDEN_TOOLTIP,
35
35
  pointInRect,
36
36
  snapPrice,
37
37
  type CrosshairState,
@@ -172,203 +172,216 @@ export function useCrosshair(
172
172
  const actionShowText = scrubAction?.text ?? true;
173
173
  const hasTimeBadge = scrubAction?.timeBadge ?? false;
174
174
 
175
- const scrubTime = useDerivedValue(() =>
176
- computeScrubTime(
177
- scrubActive.get(),
178
- scrubX.get(),
179
- padding,
180
- engine.canvasWidth.get(),
181
- engine.timestamp.get(),
182
- engine.displayWindow.get(),
183
- ),
184
- );
185
-
186
175
  const isCandleMode = candleOpts?.mode === "candle";
187
176
  const candlesSV = candleOpts?.candles;
188
177
  const liveCandleSV = candleOpts?.liveCandle;
189
178
  const candleWidthSecs = candleOpts?.candleWidthSecs ?? 60;
190
179
 
191
- /* istanbul ignore next -- worklet */
192
- const scrubCandle = useDerivedValue(() => {
193
- if (
194
- !isCandleMode ||
195
- !candlesSV ||
196
- !scrubActive.get() ||
197
- scrubTime.get() < 0
198
- )
199
- return null;
200
- return pickCandleAtTime(
201
- candlesSV.get(),
202
- liveCandleSV?.get() ?? null,
203
- scrubTime.get(),
204
- candleWidthSecs,
205
- );
206
- });
207
-
208
- /* istanbul ignore next -- worklet */
209
- const scrubValue = useDerivedValue(() => {
210
- if (!scrubActive.get() || scrubTime.get() < 0) return null;
211
- if (isCandleMode) {
212
- return scrubCandle.get()?.close ?? null;
213
- }
214
- return interpolateAtTime(engine.data.get(), scrubTime.get());
215
- });
216
-
217
- const crosshairOpacity = useDerivedValue(() =>
218
- computeCrosshairOpacity(
219
- scrubActive.get(),
220
- scrubX.get(),
221
- engine.canvasWidth.get(),
222
- padding.right,
223
- ),
224
- );
225
-
226
- // Y pixel of the scrub intersection — used by the selection dot. -1 when
227
- // there's no value to mark.
228
- const scrubDotY = useDerivedValue(() =>
229
- computeScrubDotY(
230
- scrubValue.get(),
231
- engine.displayMin.get(),
232
- engine.displayMax.get(),
233
- engine.canvasHeight.get(),
234
- padding.top,
235
- padding.bottom,
236
- ),
237
- );
238
-
239
180
  // Monospace advance width, measured once per render (not per scrub frame) so
240
181
  // the tooltip layout worklet can size text by character count instead of a
241
182
  // per-frame Skia measureText.
242
183
  const monoCharWidth = font.measureText("0").width;
243
-
244
- const tooltipLayout = useDerivedValue(() => {
245
- if (isCandleMode) {
246
- return computeCandleTooltipLayout(
247
- scrubActive.get(),
248
- scrubX.get(),
249
- scrubCandle.get(),
250
- scrubTime.get(),
184
+ const scrubTime = useSharedValue(-1);
185
+ const scrubCandle = useSharedValue<CandlePoint | null>(null);
186
+ const scrubValue = useSharedValue<number | null>(null);
187
+ const crosshairOpacity = useSharedValue(0);
188
+ const scrubDotY = useSharedValue(-1);
189
+ const tooltipLayout = useSharedValue(HIDDEN_TOOLTIP);
190
+
191
+ // These six outputs traverse the same scrub + engine inputs. One reaction
192
+ // computes and publishes the snapshot atomically instead of compiling six
193
+ // independent derived-value mappers with chained dependencies.
194
+ useAnimatedReaction(
195
+ () => {
196
+ "worklet";
197
+ const active = scrubActive.get();
198
+ const x = scrubX.get();
199
+ const canvasWidth = engine.canvasWidth.get();
200
+ const canvasHeight = engine.canvasHeight.get();
201
+ const time = computeScrubTime(
202
+ active,
203
+ x,
251
204
  padding,
252
- engine.canvasWidth.get(),
253
- formatValue,
254
- formatTime,
255
- font,
256
- monoCharWidth,
205
+ canvasWidth,
206
+ engine.timestamp.get(),
207
+ engine.displayWindow.get(),
257
208
  );
258
- }
259
- return deriveCrosshairTooltipSingle(
260
- scrubActive.get(),
261
- scrubX.get(),
262
- scrubTime.get(),
263
- scrubValue.get(),
264
- padding,
265
- engine.canvasWidth.get(),
266
- formatValue,
267
- formatTime,
268
- font,
269
- monoCharWidth,
270
- tooltipPlacement,
271
- tooltipShowValue,
272
- tooltipShowTime,
273
- engine.canvasHeight.get(),
274
- tooltipMargin,
275
- scrubDotY.get(),
276
- );
277
- });
209
+ const candle =
210
+ isCandleMode && candlesSV && active && time >= 0
211
+ ? pickCandleAtTime(
212
+ candlesSV.get(),
213
+ liveCandleSV?.get() ?? null,
214
+ time,
215
+ candleWidthSecs,
216
+ )
217
+ : null;
218
+ const value =
219
+ !active || time < 0
220
+ ? null
221
+ : isCandleMode
222
+ ? (candle?.close ?? null)
223
+ : interpolateAtTime(engine.data.get(), time);
224
+ const dotY = computeScrubDotY(
225
+ value,
226
+ engine.displayMin.get(),
227
+ engine.displayMax.get(),
228
+ canvasHeight,
229
+ padding.top,
230
+ padding.bottom,
231
+ );
232
+ const layout = isCandleMode
233
+ ? computeCandleTooltipLayout(
234
+ active,
235
+ x,
236
+ candle,
237
+ time,
238
+ padding,
239
+ canvasWidth,
240
+ formatValue,
241
+ formatTime,
242
+ font,
243
+ monoCharWidth,
244
+ )
245
+ : deriveCrosshairTooltipSingle(
246
+ active,
247
+ x,
248
+ time,
249
+ value,
250
+ padding,
251
+ canvasWidth,
252
+ formatValue,
253
+ formatTime,
254
+ font,
255
+ monoCharWidth,
256
+ tooltipPlacement,
257
+ tooltipShowValue,
258
+ tooltipShowTime,
259
+ canvasHeight,
260
+ tooltipMargin,
261
+ dotY,
262
+ );
263
+ return {
264
+ time,
265
+ candle,
266
+ value,
267
+ opacity: computeCrosshairOpacity(active, x, canvasWidth, padding.right),
268
+ dotY,
269
+ layout,
270
+ };
271
+ },
272
+ (snapshot) => {
273
+ "worklet";
274
+ scrubTime.set(snapshot.time);
275
+ scrubCandle.set(snapshot.candle);
276
+ scrubValue.set(snapshot.value);
277
+ crosshairOpacity.set(snapshot.opacity);
278
+ scrubDotY.set(snapshot.dotY);
279
+ tooltipLayout.set(snapshot.layout);
280
+ },
281
+ );
278
282
 
279
283
  // ── Scrub-action lock derivations ──────────────────────────────────────────
280
- // Reported price = the frozen chosen price, optionally snapped. Independent of
281
- // the display range (the point of freezing it), so the badge readout and the
282
- // onScrubAction payload stay stable while the chart auto-rescales. Null until a
283
- // reticle is placed.
284
- /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
285
- const lockPrice = useDerivedValue(() => {
286
- if (!lockActive.get()) return null;
287
- const p = lockPriceValue.get();
288
- return p === null ? null : snapPrice(p, snapIncrement);
289
- });
290
-
291
- // Screen Y of the locked level, DERIVED from the frozen price each frame so the
292
- // line + badge track the chosen price as displayMin/Max move (rather than the
293
- // price drifting under a pixel-fixed reticle). Pins to the plot edge when the
294
- // price scrolls out of the visible range; -1 until placed / laid out.
284
+ const lockPrice = useSharedValue<number | null>(null);
285
+ const lockY = useSharedValue(-1);
286
+ const lockTime = useSharedValue(-1);
287
+ const lockCandle = useSharedValue<CandlePoint | null>(null);
288
+ const actionBadge = useSharedValue(HIDDEN_ACTION_BADGE);
289
+ const timeBadge = useSharedValue(HIDDEN_TIME_BADGE);
290
+
291
+ // The lock price, projected coordinates, candle and both badges are one
292
+ // coherent reticle snapshot. Publishing them from one reaction replaces six
293
+ // chained derived-value mappers and prevents intermediate mixed-frame state.
295
294
  /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
296
- const lockY = useDerivedValue(() => {
297
- const p = lockPriceValue.get();
298
- const ch = engine.canvasHeight.get();
299
- if (p === null || ch - padding.top - padding.bottom <= 0) return -1;
300
- const y = computeScrubDotY(
301
- p,
302
- engine.displayMin.get(),
303
- engine.displayMax.get(),
304
- ch,
305
- padding.top,
306
- padding.bottom,
307
- );
308
- return clampPlotY(y, padding.top, ch, padding.bottom);
309
- });
310
-
311
- const lockTime = useDerivedValue(() =>
312
- computeScrubTime(
313
- lockActive.get(),
314
- lockX.get(),
315
- padding,
316
- engine.canvasWidth.get(),
317
- engine.timestamp.get(),
318
- engine.displayWindow.get(),
319
- ),
295
+ useAnimatedReaction(
296
+ () => {
297
+ "worklet";
298
+ const active = lockActive.get();
299
+ const x = lockX.get();
300
+ const rawPrice = lockPriceValue.get();
301
+ const canvasWidth = engine.canvasWidth.get();
302
+ const canvasHeight = engine.canvasHeight.get();
303
+ const price =
304
+ active && rawPrice !== null
305
+ ? snapPrice(rawPrice, snapIncrement)
306
+ : null;
307
+ let y = -1;
308
+ if (
309
+ rawPrice !== null &&
310
+ canvasHeight - padding.top - padding.bottom > 0
311
+ ) {
312
+ y = clampPlotY(
313
+ computeScrubDotY(
314
+ rawPrice,
315
+ engine.displayMin.get(),
316
+ engine.displayMax.get(),
317
+ canvasHeight,
318
+ padding.top,
319
+ padding.bottom,
320
+ ),
321
+ padding.top,
322
+ canvasHeight,
323
+ padding.bottom,
324
+ );
325
+ }
326
+ const time = computeScrubTime(
327
+ active,
328
+ x,
329
+ padding,
330
+ canvasWidth,
331
+ engine.timestamp.get(),
332
+ engine.displayWindow.get(),
333
+ );
334
+ const candle =
335
+ isCandleMode && candlesSV && active && time >= 0
336
+ ? pickCandleAtTime(
337
+ candlesSV.get(),
338
+ liveCandleSV?.get() ?? null,
339
+ time,
340
+ candleWidthSecs,
341
+ )
342
+ : null;
343
+ const badge =
344
+ price === null
345
+ ? HIDDEN_ACTION_BADGE
346
+ : computeActionBadgeLayout(
347
+ active,
348
+ y,
349
+ actionShowText ? formatValue(price) : "",
350
+ actionIcon,
351
+ canvasWidth,
352
+ canvasWidth - padding.right,
353
+ font,
354
+ badgeMetrics.marginEdge,
355
+ badgeMetrics.padX,
356
+ badgeMetrics.padY,
357
+ );
358
+ const timeLayout =
359
+ !hasTimeBadge || !active || time < 0
360
+ ? HIDDEN_TIME_BADGE
361
+ : computeTimeBadgeLayout(
362
+ active,
363
+ x,
364
+ formatTime(time),
365
+ canvasWidth,
366
+ canvasHeight - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
367
+ font,
368
+ badgeMetrics.padX,
369
+ badgeMetrics.padY,
370
+ badgeMetrics.marginEdge,
371
+ );
372
+ return { price, y, time, candle, badge, timeLayout };
373
+ },
374
+ (snapshot) => {
375
+ "worklet";
376
+ lockPrice.set(snapshot.price);
377
+ lockY.set(snapshot.y);
378
+ lockTime.set(snapshot.time);
379
+ lockCandle.set(snapshot.candle);
380
+ actionBadge.set(snapshot.badge);
381
+ timeBadge.set(snapshot.timeLayout);
382
+ },
320
383
  );
321
384
 
322
- /* istanbul ignore next -- worklet */
323
- const lockCandle = useDerivedValue(() => {
324
- if (!isCandleMode || !candlesSV || !lockActive.get() || lockTime.get() < 0)
325
- return null;
326
- return pickCandleAtTime(
327
- candlesSV.get(),
328
- liveCandleSV?.get() ?? null,
329
- lockTime.get(),
330
- candleWidthSecs,
331
- );
332
- });
333
-
334
- // Right-gutter action-badge layout — also the tap hit rect. Hidden when not locked.
335
- /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
336
- const actionBadge = useDerivedValue(() => {
337
- const price = lockPrice.get();
338
- if (price === null) return HIDDEN_ACTION_BADGE;
339
- return computeActionBadgeLayout(
340
- lockActive.get(),
341
- lockY.get(),
342
- actionShowText ? formatValue(price) : "",
343
- actionIcon,
344
- engine.canvasWidth.get(),
345
- engine.canvasWidth.get() - padding.right,
346
- font,
347
- badgeMetrics.marginEdge,
348
- badgeMetrics.padX,
349
- badgeMetrics.padY,
350
- );
351
- });
352
-
353
- // X-axis time badge at the reticle (opt-in). Hidden unless enabled + locked.
354
- /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
355
- const timeBadge = useDerivedValue(() => {
356
- if (!hasTimeBadge || !lockActive.get()) return HIDDEN_TIME_BADGE;
357
- const t = lockTime.get();
358
- if (t < 0) return HIDDEN_TIME_BADGE;
359
- return computeTimeBadgeLayout(
360
- lockActive.get(),
361
- lockX.get(),
362
- formatTime(t),
363
- engine.canvasWidth.get(),
364
- engine.canvasHeight.get() - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
365
- font,
366
- badgeMetrics.padX,
367
- badgeMetrics.padY,
368
- badgeMetrics.marginEdge,
369
- );
370
- });
371
-
372
385
  /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
373
386
  function handleScrubAction(
374
387
  price: number,
@@ -129,15 +129,28 @@ export function useXAxis(
129
129
  targetKeys.push(Math.round(t * 100));
130
130
  }
131
131
 
132
- const alphas = labelAlphas.get();
132
+ // SharedValue payloads may be frozen after crossing the JS/UI boundary.
133
+ // Work on a fresh cache (including fresh entries) so adding/removing keys
134
+ // and updating alpha never mutates the serialized value returned by get().
135
+ const previousAlphas = labelAlphas.get();
136
+ const alphas: Record<number, { alpha: number; text: string }> = {};
137
+ const previousKeys = Object.keys(previousAlphas);
138
+ for (let i = 0; i < previousKeys.length; i++) {
139
+ const key = Number(previousKeys[i]);
140
+ const previous = previousAlphas[key];
141
+ alphas[key] = { alpha: previous.alpha, text: previous.text };
142
+ }
143
+ let cacheChanged = false;
133
144
 
134
145
  // Create labels for new keys only. A key encodes its time, so the formatted
135
- // text never changes — re-formatting (and re-allocating the entry) every
136
- // frame just churns strings/objects for the GC.
146
+ // text never changes — re-formatting every frame would churn strings for
147
+ // the GC. The cache itself is copied below to keep SharedValue payloads
148
+ // immutable after they cross the JS/UI boundary.
137
149
  for (let i = 0; i < targetKeys.length; i++) {
138
150
  const key = targetKeys[i];
139
151
  if (!alphas[key]) {
140
152
  alphas[key] = { alpha: 0, text: formatTime(key / 100) };
153
+ cacheChanged = true;
141
154
  }
142
155
  }
143
156
 
@@ -160,13 +173,16 @@ export function useXAxis(
160
173
 
161
174
  if (next < 0.01 && target === 0) {
162
175
  delete alphas[key];
176
+ cacheChanged = true;
163
177
  } else {
164
- // Mutate in place text is stable, so no need to reallocate the entry.
178
+ // The entry is already a private copy, so updating alpha cannot mutate
179
+ // the serialized cache held by the SharedValue.
180
+ if (label.alpha !== next) cacheChanged = true;
165
181
  label.alpha = next;
166
182
  }
167
183
  }
168
184
 
169
- labelAlphas.set(alphas);
185
+ if (cacheChanged) labelAlphas.set(alphas);
170
186
 
171
187
  // Collect visible labels
172
188
  const raw: XAxisEntry[] = [];
package/src/types.ts CHANGED
@@ -1979,9 +1979,14 @@ export interface LiveChartCoreProps {
1979
1979
  /** Crosshair scrubbing on hover/drag. `true` = defaults, `false` = disabled, or pass `ScrubConfig`. Default `true`. */
1980
1980
  scrub?: boolean | ScrubConfig;
1981
1981
  /**
1982
- * Selection dot drawn at the scrub intersection while scrubbing. `true`/omitted
1983
- * = built-in dot, `false` = hidden, or pass `SelectionDotConfig` (`size`,
1984
- * `color`, `ring`, or a custom `component`). Default `true`.
1982
+ * Selection dot drawn at the scrub intersection while scrubbing. `true` =
1983
+ * built-in dot, `false` = hidden, or pass `SelectionDotConfig` (`size`,
1984
+ * `color`, `ring`, or a custom `component`).
1985
+ *
1986
+ * Defaults differ per chart: `LiveChart` shows it (`true`); `LiveChartSeries`
1987
+ * hides it (`false`) — with multiple lines the dot can only track the leading
1988
+ * series, so the crosshair + per-series tooltips mark the scrub point instead.
1989
+ * Pass `true` / a config to opt a multi-series chart in.
1985
1990
  */
1986
1991
  selectionDot?: boolean | SelectionDotConfig;
1987
1992
  /** Called once when the user starts scrubbing/panning the chart. */