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.
@@ -25,6 +25,7 @@ import {
25
25
  GROUP_BADGE_SIG,
26
26
  groupBgSig,
27
27
  groupCountText,
28
+ groupCountTextWidth,
28
29
  groupGlyphSig,
29
30
  isConnectorMarker,
30
31
  markerAppearanceSig,
@@ -58,7 +59,7 @@ const GROUP_CORNER_INSET = 0.82;
58
59
 
59
60
  /**
60
61
  * Append a collapsed-cluster count badge (round bg + centered, proportionally
61
- * kerned digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
62
+ * laid-out digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
62
63
  * size as the default group badge, shrunk in a glyph's corner for `showGroupCount`.
63
64
  *
64
65
  * A **module-level** worklet (not a closure inside the per-frame derived value):
@@ -77,6 +78,7 @@ function pushGroupCountBadge(
77
78
  repColor: string,
78
79
  count: number,
79
80
  mul: number,
81
+ letterSpacing: number,
80
82
  ): void {
81
83
  "worklet";
82
84
  const text = groupCountText(count);
@@ -87,20 +89,11 @@ function pushGroupCountBadge(
87
89
  );
88
90
  sprites.push(bg.rect);
89
91
  }
90
- // Lay digits out proportionally (each by its own ink width), scaled to fit the
91
- // small round badge, centered. A negative kern (fraction of a digit) closes the
92
- // font's side-bearing gap so "12" reads tight, not "1 2".
92
+ // Lay digits out proportionally (each by its own ink width), centered. The
93
+ // configurable spacing is applied between glyph ink bounds, not inferred from
94
+ // the widest digit proportional fonts otherwise overlap narrow digits.
93
95
  const S = BADGE_TEXT_SCALE;
94
- let maxDW = 1;
95
- for (let d = 0; d < 10; d++) {
96
- const dw = digitWidths["" + d] ?? 0;
97
- if (dw > maxDW) maxDW = dw;
98
- }
99
- const kern = -maxDW * S * 0.42;
100
- let totalW = 0;
101
- for (let c = 0; c < text.length; c++) {
102
- totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
103
- }
96
+ const totalW = groupCountTextWidth(text, digitWidths, letterSpacing);
104
97
  let dx = cx - (totalW * mul) / 2;
105
98
  for (let c = 0; c < text.length; c++) {
106
99
  const w = (digitWidths[text[c]] ?? 0) * S * mul;
@@ -117,7 +110,7 @@ function pushGroupCountBadge(
117
110
  );
118
111
  sprites.push(gc.rect);
119
112
  }
120
- dx += w + kern * mul;
113
+ dx += w + letterSpacing * mul;
121
114
  }
122
115
  }
123
116
 
@@ -326,7 +319,7 @@ export function MarkerOverlay({
326
319
  typeof cluster.groupBadge === "object" ? cluster.groupBadge : undefined;
327
320
  const groupBadgeImage = groupBadgeCfg?.image;
328
321
  const groupBadgeKey = groupBadgeCfg
329
- ? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}`
322
+ ? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}|${groupBadgeCfg.letterSpacing ?? ""}`
330
323
  : "";
331
324
  const atlas = useMemo(
332
325
  () =>
@@ -431,6 +424,7 @@ export function MarkerOverlay({
431
424
  repColor,
432
425
  pt.groupCount,
433
426
  GROUP_CORNER_SCALE,
427
+ groupBadgeCfg?.letterSpacing ?? 0,
434
428
  );
435
429
  }
436
430
  continue;
@@ -448,6 +442,7 @@ export function MarkerOverlay({
448
442
  repColor,
449
443
  pt.groupCount,
450
444
  1,
445
+ groupBadgeCfg?.letterSpacing ?? 0,
451
446
  );
452
447
  continue;
453
448
  }
@@ -24,6 +24,22 @@ import { AnimatedLabel } from "./AnimatedLabel";
24
24
  /** Right margin (px) for floating labels — keeps them just off the canvas edge. */
25
25
  const FLOAT_LABEL_RIGHT_MARGIN = 6;
26
26
 
27
+ /** Returns true when a Y-axis label's line box intersects the live badge pill. */
28
+ export function yAxisLabelIntersectsBadge(
29
+ labelCenterY: number,
30
+ labelHeight: number,
31
+ badgeCenterY: number,
32
+ badgeHeight: number,
33
+ ) {
34
+ "worklet";
35
+ const labelHalfHeight = labelHeight / 2;
36
+ const badgeHalfHeight = badgeHeight / 2;
37
+ return (
38
+ labelCenterY + labelHalfHeight > badgeCenterY - badgeHalfHeight &&
39
+ labelCenterY - labelHalfHeight < badgeCenterY + badgeHalfHeight
40
+ );
41
+ }
42
+
27
43
  export function YAxisOverlay({
28
44
  entries,
29
45
  engine,
@@ -33,6 +49,9 @@ export function YAxisOverlay({
33
49
  badge = false,
34
50
  badgeTail = true,
35
51
  badgeMetrics = BADGE_METRICS_DEFAULTS,
52
+ badgeCenterY,
53
+ badgeFontSize,
54
+ badgeOffsetY = 0,
36
55
  seriesLabelInset = 0,
37
56
  gridStyle,
38
57
  variant = "all",
@@ -49,6 +68,12 @@ export function YAxisOverlay({
49
68
  badgeTail?: boolean;
50
69
  /** Badge pill geometry tokens (kept in sync with useBadge). */
51
70
  badgeMetrics?: BadgeMetrics;
71
+ /** Live badge center before its configured Y offset. Enables label collision suppression. */
72
+ badgeCenterY?: SharedValue<number>;
73
+ /** Live badge font size, used to reconstruct the pill's vertical bounds. */
74
+ badgeFontSize?: number;
75
+ /** Configured live badge Y offset. */
76
+ badgeOffsetY?: number;
52
77
  /** When > 0, series labels occupy the left portion of the gutter; Y-axis labels right-align. */
53
78
  seriesLabelInset?: number;
54
79
  /** Grid-line styling overrides. Omit for the legacy solid 1px line. */
@@ -92,6 +117,14 @@ export function YAxisOverlay({
92
117
  const w = engine.canvasWidth.get();
93
118
  const fm = font.getMetrics();
94
119
  const baselineOffset = (fm.ascent + fm.descent) / 2;
120
+ const labelHeight = fm.descent - fm.ascent;
121
+ const resolvedBadgeCenterY = badgeCenterY
122
+ ? badgeCenterY.get() + badgeOffsetY
123
+ : null;
124
+ const badgeHeight =
125
+ badgeFontSize === undefined
126
+ ? 0
127
+ : badgeFontSize + badgeMetrics.padY * 2;
95
128
  const result: { x: number; y: number; label: string; alpha: number }[] = [];
96
129
  for (let i = 0; i < items.length; i++) {
97
130
  const e = items[i];
@@ -107,7 +140,16 @@ export function YAxisOverlay({
107
140
  x,
108
141
  y: e.y - baselineOffset,
109
142
  label: e.label,
110
- alpha: e.alpha,
143
+ alpha:
144
+ resolvedBadgeCenterY !== null &&
145
+ yAxisLabelIntersectsBadge(
146
+ e.y,
147
+ labelHeight,
148
+ resolvedBadgeCenterY,
149
+ badgeHeight,
150
+ )
151
+ ? 0
152
+ : e.alpha,
111
153
  });
112
154
  }
113
155
  return result;
@@ -59,6 +59,21 @@ export function groupCountText(count: number): string {
59
59
  return count > 99 ? "99" : `${count}`;
60
60
  }
61
61
 
62
+ /** Width of proportionally laid-out count-badge text at its on-canvas scale. */
63
+ export function groupCountTextWidth(
64
+ text: string,
65
+ digitWidths: Record<string, number>,
66
+ letterSpacing = 0,
67
+ ): number {
68
+ "worklet";
69
+ let width = 0;
70
+ for (let i = 0; i < text.length; i++) {
71
+ width += (digitWidths[text[i]] ?? 0) * BADGE_TEXT_SCALE;
72
+ if (i > 0) width += letterSpacing;
73
+ }
74
+ return width;
75
+ }
76
+
62
77
  /** Default glyph color per kind when `marker.color` is unset. Worklet-safe so the
63
78
  * per-frame overlay worklet can resolve a collapsed cluster's badge color. */
64
79
  export function defaultMarkerColor(
@@ -333,10 +348,12 @@ function groupBgCellSpec(
333
348
  bgColor: string,
334
349
  uw: number,
335
350
  gh: number,
351
+ letterSpacing: number,
336
352
  ): CellSpec {
337
353
  const m2 = CELL_MARGIN * 2;
338
354
  // Inner circle fits a centered two-digit string at BADGE_TEXT_SCALE plus padding.
339
- const innerR = Math.ceil(Math.max(2 * uw, gh) * BADGE_TEXT_SCALE) / 2 + BADGE_PAD;
355
+ const textWidth = 2 * uw * BADGE_TEXT_SCALE + Math.max(0, letterSpacing);
356
+ const innerR = Math.ceil(Math.max(textWidth, gh * BADGE_TEXT_SCALE)) / 2 + BADGE_PAD;
340
357
  const outerR = innerR + BADGE_BORDER;
341
358
  const diameter = 2 * outerR;
342
359
  return {
@@ -429,7 +446,10 @@ export function buildMarkerAtlas(
429
446
  colors.add(m.color ?? defaultMarkerColor(m.kind, palette));
430
447
  }
431
448
  for (const color of colors) {
432
- specs.push({ sig: groupBgSig(color), spec: groupBgCellSpec(color, bgColor, uw, gh) });
449
+ specs.push({
450
+ sig: groupBgSig(color),
451
+ spec: groupBgCellSpec(color, bgColor, uw, gh, groupBadge?.letterSpacing ?? 0),
452
+ });
433
453
  }
434
454
  }
435
455
 
@@ -14,6 +14,17 @@ const TOOLTIP_EDGE_GAP = 4;
14
14
  const TOOLTIP_TOP_MARGIN = 8;
15
15
  const FADE_ZONE = 4;
16
16
 
17
+ /** Measure rendered text so the pill and its content share the same centre. */
18
+ function measureTooltipTextWidth(
19
+ font: SkFont,
20
+ text: string,
21
+ monoCharWidth: number,
22
+ ): number {
23
+ "worklet";
24
+ const measured = measureFontTextWidth(font, text);
25
+ return measured > 0 ? measured : text.length * monoCharWidth;
26
+ }
27
+
17
28
  export interface TooltipLayout {
18
29
  x: number;
19
30
  y: number;
@@ -197,10 +208,7 @@ export function computeTooltipLayout(
197
208
  formatValue: (v: number) => string,
198
209
  formatTime: (t: number) => string,
199
210
  font: SkFont,
200
- /** Monospace advance width. When > 0, text width is `len * monoCharWidth`
201
- * instead of a per-frame Skia `measureText` — scrubbing re-runs this worklet
202
- * every frame, and `measureText` shapes text each call (a real cost,
203
- * especially in the simulator). Falls back to `measureText` when 0. */
211
+ /** Monospace advance width fallback when font measurement returns zero. */
204
212
  monoCharWidth = 0,
205
213
  /** Where the pill sits relative to the scrub line. `"side"` offsets it right
206
214
  * (flipping left near the edge); `"top"`/`"bottom"` center it over the line,
@@ -243,14 +251,11 @@ export function computeTooltipLayout(
243
251
  const totalH =
244
252
  TOOLTIP_PAD_Y * 2 + lineH * rowCount + TOOLTIP_LINE_GAP * (rowCount - 1);
245
253
 
246
- const valueW =
247
- monoCharWidth > 0
248
- ? valueStr.length * monoCharWidth
249
- : measureFontTextWidth(font, valueStr);
250
- const timeW =
251
- monoCharWidth > 0
252
- ? timeStr.length * monoCharWidth
253
- : measureFontTextWidth(font, timeStr);
254
+ // Use actual glyph bounds for every visible-row variant. The old
255
+ // character-count approximation made punctuation-heavy dates/times appear
256
+ // off-centre even when the pill itself was positioned correctly.
257
+ const valueW = measureTooltipTextWidth(font, valueStr, monoCharWidth);
258
+ const timeW = measureTooltipTextWidth(font, timeStr, monoCharWidth);
254
259
  // Only the visible rows contribute to the pill width.
255
260
  const contentW = Math.max(sv ? valueW : 0, st ? timeW : 0);
256
261
  const pillW = contentW + TOOLTIP_PAD_X * 2;
@@ -333,8 +338,7 @@ export function computeTooltipLayoutMulti(
333
338
  padding: ChartPadding,
334
339
  canvasWidth: number,
335
340
  font: SkFont,
336
- /** Monospace advance width; when > 0, sizes text by length instead of a
337
- * per-frame Skia `measureText`. See {@link computeTooltipLayout}. */
341
+ /** Monospace advance width fallback when font measurement returns zero. */
338
342
  monoCharWidth = 0,
339
343
  ): TooltipLayout {
340
344
  "worklet";
@@ -349,10 +353,7 @@ export function computeTooltipLayoutMulti(
349
353
  let contentW = 0;
350
354
  const lineWidths: number[] = [];
351
355
  for (let i = 0; i < n; i++) {
352
- const w =
353
- monoCharWidth > 0
354
- ? lines[i].text.length * monoCharWidth
355
- : measureFontTextWidth(font, lines[i].text);
356
+ const w = measureTooltipTextWidth(font, lines[i].text, monoCharWidth);
356
357
  lineWidths.push(w);
357
358
  if (w > contentW) contentW = w;
358
359
  }
@@ -0,0 +1,80 @@
1
+ import type { SharedValue } from "react-native-reanimated";
2
+
3
+ type BooleanSharedValue = Pick<SharedValue<boolean>, "get" | "set">;
4
+
5
+ type TouchCountEvent = {
6
+ numberOfTouches: number;
7
+ };
8
+
9
+ type GestureStateManager = {
10
+ fail: () => void;
11
+ };
12
+
13
+ /** Mark the first pointer of a delayed pan as down. Zero-delay pans are inert. */
14
+ export function delayedPanTouchDown(
15
+ delayMs: number,
16
+ fingerDown: BooleanSharedValue,
17
+ ): void {
18
+ "worklet";
19
+ if (delayMs > 0) fingerDown.set(true);
20
+ }
21
+
22
+ /**
23
+ * Fail a delayed pan that is still pending when its final pointer lifts.
24
+ *
25
+ * RNGH 2.x on iOS can leave `activateAfterLongPress`'s timer armed after a
26
+ * stationary touch ends. Failing the pending recognizer forces the reset that
27
+ * cancels that timer. An already-active pan is left to finish normally.
28
+ */
29
+ export function delayedPanTouchUp(
30
+ delayMs: number,
31
+ event: TouchCountEvent,
32
+ manager: GestureStateManager,
33
+ fingerDown: BooleanSharedValue,
34
+ panActivated: BooleanSharedValue,
35
+ ): void {
36
+ "worklet";
37
+ if (delayMs <= 0 || event.numberOfTouches > 0) return;
38
+ fingerDown.set(false);
39
+ if (!panActivated.get()) manager.fail();
40
+ }
41
+
42
+ /** Clear pointer state when the native touch stream is cancelled. */
43
+ export function delayedPanTouchCancelled(
44
+ delayMs: number,
45
+ fingerDown: BooleanSharedValue,
46
+ ): void {
47
+ "worklet";
48
+ if (delayMs > 0) fingerDown.set(false);
49
+ }
50
+
51
+ /**
52
+ * Record a real delayed-pan activation, or reject an activation whose timer
53
+ * raced the final pointer-up. The rejected path deliberately keeps
54
+ * `panActivated` false so a missing native FINALIZE cannot poison the next
55
+ * interaction.
56
+ */
57
+ export function shouldStartDelayedPan(
58
+ delayMs: number,
59
+ fingerDown: BooleanSharedValue,
60
+ panActivated: BooleanSharedValue,
61
+ ): boolean {
62
+ "worklet";
63
+ if (delayMs <= 0) return true;
64
+ if (!fingerDown.get()) {
65
+ panActivated.set(false);
66
+ return false;
67
+ }
68
+ panActivated.set(true);
69
+ return true;
70
+ }
71
+
72
+ /** Reset the delayed-pan lifecycle after native finalization. */
73
+ export function resetDelayedPanGuard(
74
+ fingerDown: BooleanSharedValue,
75
+ panActivated: BooleanSharedValue,
76
+ ): void {
77
+ "worklet";
78
+ fingerDown.set(false);
79
+ panActivated.set(false);
80
+ }