react-native-livechart 4.9.1 → 4.10.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.
@@ -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,10 +31,18 @@ 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,
38
38
  } from "./crosshairShared";
39
+ import {
40
+ delayedPanTouchCancelled,
41
+ delayedPanTouchDown,
42
+ delayedPanTouchUp,
43
+ resetDelayedPanGuard,
44
+ shouldStartDelayedPan,
45
+ } from "./delayedPanGuard";
39
46
 
40
47
  const ACTION_HIT_SLOP = 6;
41
48
  const RETICLE_HIT = 14;
@@ -150,6 +157,10 @@ export function useCrosshair(
150
157
  // Tracks whether the active scrub phase actually began, so a tap that never
151
158
  // activates doesn't emit a spurious onGestureEnd.
152
159
  const gestureStarted = useSharedValue(false);
160
+ // Guard state for RNGH's delayed-pan post-lift activation on iOS. The shared
161
+ // lifecycle helpers are also used by LiveChartSeries and unit-tested directly.
162
+ const fingerDown = useSharedValue(false);
163
+ const panActivated = useSharedValue(false);
153
164
  // Where the crosshair line should start (canvas Y) so it stops at a top-pinned
154
165
  // custom tooltip instead of running through it. -1 = no top tooltip → the line
155
166
  // starts at padding.top. Written by CustomTooltipOverlay, read by CrosshairOverlay.
@@ -172,203 +183,216 @@ export function useCrosshair(
172
183
  const actionShowText = scrubAction?.text ?? true;
173
184
  const hasTimeBadge = scrubAction?.timeBadge ?? false;
174
185
 
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
186
  const isCandleMode = candleOpts?.mode === "candle";
187
187
  const candlesSV = candleOpts?.candles;
188
188
  const liveCandleSV = candleOpts?.liveCandle;
189
189
  const candleWidthSecs = candleOpts?.candleWidthSecs ?? 60;
190
190
 
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
191
  // Monospace advance width, measured once per render (not per scrub frame) so
240
192
  // the tooltip layout worklet can size text by character count instead of a
241
193
  // per-frame Skia measureText.
242
194
  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(),
195
+ const scrubTime = useSharedValue(-1);
196
+ const scrubCandle = useSharedValue<CandlePoint | null>(null);
197
+ const scrubValue = useSharedValue<number | null>(null);
198
+ const crosshairOpacity = useSharedValue(0);
199
+ const scrubDotY = useSharedValue(-1);
200
+ const tooltipLayout = useSharedValue(HIDDEN_TOOLTIP);
201
+
202
+ // These six outputs traverse the same scrub + engine inputs. One reaction
203
+ // computes and publishes the snapshot atomically instead of compiling six
204
+ // independent derived-value mappers with chained dependencies.
205
+ useAnimatedReaction(
206
+ () => {
207
+ "worklet";
208
+ const active = scrubActive.get();
209
+ const x = scrubX.get();
210
+ const canvasWidth = engine.canvasWidth.get();
211
+ const canvasHeight = engine.canvasHeight.get();
212
+ const time = computeScrubTime(
213
+ active,
214
+ x,
251
215
  padding,
252
- engine.canvasWidth.get(),
253
- formatValue,
254
- formatTime,
255
- font,
256
- monoCharWidth,
216
+ canvasWidth,
217
+ engine.timestamp.get(),
218
+ engine.displayWindow.get(),
257
219
  );
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
- });
220
+ const candle =
221
+ isCandleMode && candlesSV && active && time >= 0
222
+ ? pickCandleAtTime(
223
+ candlesSV.get(),
224
+ liveCandleSV?.get() ?? null,
225
+ time,
226
+ candleWidthSecs,
227
+ )
228
+ : null;
229
+ const value =
230
+ !active || time < 0
231
+ ? null
232
+ : isCandleMode
233
+ ? (candle?.close ?? null)
234
+ : interpolateAtTime(engine.data.get(), time);
235
+ const dotY = computeScrubDotY(
236
+ value,
237
+ engine.displayMin.get(),
238
+ engine.displayMax.get(),
239
+ canvasHeight,
240
+ padding.top,
241
+ padding.bottom,
242
+ );
243
+ const layout = isCandleMode
244
+ ? computeCandleTooltipLayout(
245
+ active,
246
+ x,
247
+ candle,
248
+ time,
249
+ padding,
250
+ canvasWidth,
251
+ formatValue,
252
+ formatTime,
253
+ font,
254
+ monoCharWidth,
255
+ )
256
+ : deriveCrosshairTooltipSingle(
257
+ active,
258
+ x,
259
+ time,
260
+ value,
261
+ padding,
262
+ canvasWidth,
263
+ formatValue,
264
+ formatTime,
265
+ font,
266
+ monoCharWidth,
267
+ tooltipPlacement,
268
+ tooltipShowValue,
269
+ tooltipShowTime,
270
+ canvasHeight,
271
+ tooltipMargin,
272
+ dotY,
273
+ );
274
+ return {
275
+ time,
276
+ candle,
277
+ value,
278
+ opacity: computeCrosshairOpacity(active, x, canvasWidth, padding.right),
279
+ dotY,
280
+ layout,
281
+ };
282
+ },
283
+ (snapshot) => {
284
+ "worklet";
285
+ scrubTime.set(snapshot.time);
286
+ scrubCandle.set(snapshot.candle);
287
+ scrubValue.set(snapshot.value);
288
+ crosshairOpacity.set(snapshot.opacity);
289
+ scrubDotY.set(snapshot.dotY);
290
+ tooltipLayout.set(snapshot.layout);
291
+ },
292
+ );
278
293
 
279
294
  // ── 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.
295
+ const lockPrice = useSharedValue<number | null>(null);
296
+ const lockY = useSharedValue(-1);
297
+ const lockTime = useSharedValue(-1);
298
+ const lockCandle = useSharedValue<CandlePoint | null>(null);
299
+ const actionBadge = useSharedValue(HIDDEN_ACTION_BADGE);
300
+ const timeBadge = useSharedValue(HIDDEN_TIME_BADGE);
301
+
302
+ // The lock price, projected coordinates, candle and both badges are one
303
+ // coherent reticle snapshot. Publishing them from one reaction replaces six
304
+ // chained derived-value mappers and prevents intermediate mixed-frame state.
295
305
  /* 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
- ),
306
+ useAnimatedReaction(
307
+ () => {
308
+ "worklet";
309
+ const active = lockActive.get();
310
+ const x = lockX.get();
311
+ const rawPrice = lockPriceValue.get();
312
+ const canvasWidth = engine.canvasWidth.get();
313
+ const canvasHeight = engine.canvasHeight.get();
314
+ const price =
315
+ active && rawPrice !== null
316
+ ? snapPrice(rawPrice, snapIncrement)
317
+ : null;
318
+ let y = -1;
319
+ if (
320
+ rawPrice !== null &&
321
+ canvasHeight - padding.top - padding.bottom > 0
322
+ ) {
323
+ y = clampPlotY(
324
+ computeScrubDotY(
325
+ rawPrice,
326
+ engine.displayMin.get(),
327
+ engine.displayMax.get(),
328
+ canvasHeight,
329
+ padding.top,
330
+ padding.bottom,
331
+ ),
332
+ padding.top,
333
+ canvasHeight,
334
+ padding.bottom,
335
+ );
336
+ }
337
+ const time = computeScrubTime(
338
+ active,
339
+ x,
340
+ padding,
341
+ canvasWidth,
342
+ engine.timestamp.get(),
343
+ engine.displayWindow.get(),
344
+ );
345
+ const candle =
346
+ isCandleMode && candlesSV && active && time >= 0
347
+ ? pickCandleAtTime(
348
+ candlesSV.get(),
349
+ liveCandleSV?.get() ?? null,
350
+ time,
351
+ candleWidthSecs,
352
+ )
353
+ : null;
354
+ const badge =
355
+ price === null
356
+ ? HIDDEN_ACTION_BADGE
357
+ : computeActionBadgeLayout(
358
+ active,
359
+ y,
360
+ actionShowText ? formatValue(price) : "",
361
+ actionIcon,
362
+ canvasWidth,
363
+ canvasWidth - padding.right,
364
+ font,
365
+ badgeMetrics.marginEdge,
366
+ badgeMetrics.padX,
367
+ badgeMetrics.padY,
368
+ );
369
+ const timeLayout =
370
+ !hasTimeBadge || !active || time < 0
371
+ ? HIDDEN_TIME_BADGE
372
+ : computeTimeBadgeLayout(
373
+ active,
374
+ x,
375
+ formatTime(time),
376
+ canvasWidth,
377
+ canvasHeight - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
378
+ font,
379
+ badgeMetrics.padX,
380
+ badgeMetrics.padY,
381
+ badgeMetrics.marginEdge,
382
+ );
383
+ return { price, y, time, candle, badge, timeLayout };
384
+ },
385
+ (snapshot) => {
386
+ "worklet";
387
+ lockPrice.set(snapshot.price);
388
+ lockY.set(snapshot.y);
389
+ lockTime.set(snapshot.time);
390
+ lockCandle.set(snapshot.candle);
391
+ actionBadge.set(snapshot.badge);
392
+ timeBadge.set(snapshot.timeLayout);
393
+ },
320
394
  );
321
395
 
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
396
  /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
373
397
  function handleScrubAction(
374
398
  price: number,
@@ -485,6 +509,30 @@ export function useCrosshair(
485
509
  .activateAfterLongPress(longPressMs)
486
510
  .maxPointers(1)
487
511
  .shouldCancelWhenOutside(false)
512
+ .onTouchesDown(
513
+ /* istanbul ignore next */ () => {
514
+ "worklet";
515
+ delayedPanTouchDown(longPressMs, fingerDown);
516
+ },
517
+ )
518
+ .onTouchesUp(
519
+ /* istanbul ignore next */ (e, manager) => {
520
+ "worklet";
521
+ delayedPanTouchUp(
522
+ longPressMs,
523
+ e,
524
+ manager,
525
+ fingerDown,
526
+ panActivated,
527
+ );
528
+ },
529
+ )
530
+ .onTouchesCancelled(
531
+ /* istanbul ignore next */ () => {
532
+ "worklet";
533
+ delayedPanTouchCancelled(longPressMs, fingerDown);
534
+ },
535
+ )
488
536
  // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
489
537
  // `activateAfterLongPress` only delays activation, so onBegin still fires
490
538
  // immediately — using it would scrub instantly and ignore panGestureDelay,
@@ -492,6 +540,7 @@ export function useCrosshair(
492
540
  .onStart(
493
541
  /* istanbul ignore next */ (e) => {
494
542
  "worklet";
543
+ if (!shouldStartDelayedPan(longPressMs, fingerDown, panActivated)) return;
495
544
  if (!enabled) return;
496
545
  // Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
497
546
  // the price). The ephemeral live-scrub never engages, so scrubActive
@@ -556,6 +605,7 @@ export function useCrosshair(
556
605
  .onFinalize(
557
606
  /* istanbul ignore next */ () => {
558
607
  "worklet";
608
+ resetDelayedPanGuard(fingerDown, panActivated);
559
609
  // A lock-adjust drag leaves the reticle in place (scrubActive was never
560
610
  // set); a live-scrub clears its crosshair. Always clear scrubActive so a
561
611
  // stray scrub can never linger behind a placed reticle.
@@ -20,6 +20,13 @@ import {
20
20
  HIDDEN_TOOLTIP,
21
21
  type CrosshairState,
22
22
  } from "./crosshairShared";
23
+ import {
24
+ delayedPanTouchCancelled,
25
+ delayedPanTouchDown,
26
+ delayedPanTouchUp,
27
+ resetDelayedPanGuard,
28
+ shouldStartDelayedPan,
29
+ } from "./delayedPanGuard";
23
30
 
24
31
  /**
25
32
  * LiveChartSeries crosshair + scrub. No tooltip — data is delivered via
@@ -40,6 +47,10 @@ export function useCrosshairSeries(
40
47
  // Tracks whether the active scrub phase actually began, so a tap that never
41
48
  // activates doesn't emit a spurious onGestureEnd.
42
49
  const gestureStarted = useSharedValue(false);
50
+ // Mirrors LiveChart's guard against RNGH's delayed-pan timer activating after
51
+ // the final pointer has already lifted on iOS.
52
+ const fingerDown = useSharedValue(false);
53
+ const panActivated = useSharedValue(false);
43
54
 
44
55
  const scrubTime = useDerivedValue(() =>
45
56
  computeScrubTime(
@@ -158,6 +169,30 @@ export function useCrosshairSeries(
158
169
  .activateAfterLongPress(panGestureDelay)
159
170
  .maxPointers(1)
160
171
  .shouldCancelWhenOutside(false)
172
+ .onTouchesDown(
173
+ /* istanbul ignore next */ () => {
174
+ "worklet";
175
+ delayedPanTouchDown(panGestureDelay, fingerDown);
176
+ },
177
+ )
178
+ .onTouchesUp(
179
+ /* istanbul ignore next */ (e, manager) => {
180
+ "worklet";
181
+ delayedPanTouchUp(
182
+ panGestureDelay,
183
+ e,
184
+ manager,
185
+ fingerDown,
186
+ panActivated,
187
+ );
188
+ },
189
+ )
190
+ .onTouchesCancelled(
191
+ /* istanbul ignore next */ () => {
192
+ "worklet";
193
+ delayedPanTouchCancelled(panGestureDelay, fingerDown);
194
+ },
195
+ )
161
196
  // Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
162
197
  // `activateAfterLongPress` only delays activation, so onBegin still fires
163
198
  // immediately — using it would scrub instantly and ignore panGestureDelay,
@@ -165,6 +200,8 @@ export function useCrosshairSeries(
165
200
  .onStart(
166
201
  /* istanbul ignore next */ (e) => {
167
202
  "worklet";
203
+ if (!shouldStartDelayedPan(panGestureDelay, fingerDown, panActivated))
204
+ return;
168
205
  if (!enabled) return;
169
206
  scrubX.set(e.x);
170
207
  scrubActive.set(true);
@@ -182,6 +219,7 @@ export function useCrosshairSeries(
182
219
  .onFinalize(
183
220
  /* istanbul ignore next */ () => {
184
221
  "worklet";
222
+ resetDelayedPanGuard(fingerDown, panActivated);
185
223
  scrubActive.set(false);
186
224
  if (gestureStarted.get()) {
187
225
  gestureStarted.set(false);
@@ -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
@@ -1236,10 +1236,15 @@ export interface MarkerClusterConfig {
1236
1236
  * A dedicated badge drawn for a collapsed marker cluster — your own Skia design,
1237
1237
  * independent of the member markers. Pass it as
1238
1238
  * {@link MarkerClusterConfig.groupBadge}. Glyph precedence mirrors a single marker:
1239
- * `image` → `icon` (`pill`) → a filled dot. Requires `image` or `icon` (otherwise
1240
- * the group falls back to the count badge).
1239
+ * `image` → `icon` (`pill`) → the built-in count badge. An object containing only
1240
+ * {@link letterSpacing} customizes that built-in count badge.
1241
1241
  */
1242
1242
  export interface MarkerGroupBadge {
1243
+ /**
1244
+ * Extra spacing in px between digits in the built-in count badge. Also applies
1245
+ * to the optional corner count on a custom group badge. Default `0`.
1246
+ */
1247
+ letterSpacing?: number;
1243
1248
  /** Custom Skia image for the collapsed group (e.g. from `useImage`). Takes
1244
1249
  * precedence over {@link icon}. */
1245
1250
  image?: SkImage;