react-native-livechart 1.0.0 → 2.0.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.
Files changed (84) hide show
  1. package/README.md +15 -3
  2. package/dist/components/CrosshairLine.d.ts +6 -1
  3. package/dist/components/CrosshairLine.d.ts.map +1 -1
  4. package/dist/components/CrosshairOverlay.d.ts +7 -1
  5. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  6. package/dist/components/DotOverlay.d.ts +12 -5
  7. package/dist/components/DotOverlay.d.ts.map +1 -1
  8. package/dist/components/LiveChart.d.ts.map +1 -1
  9. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  10. package/dist/components/LoadingOverlay.d.ts +6 -2
  11. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  12. package/dist/components/MarkerOverlay.d.ts +8 -4
  13. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  14. package/dist/components/MultiSeriesDots.d.ts +8 -2
  15. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  16. package/dist/components/YAxisOverlay.d.ts +4 -2
  17. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  18. package/dist/constants.d.ts +16 -13
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/core/liveChartEngineTick.d.ts +2 -0
  21. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  22. package/dist/core/liveChartSeriesEngineTick.d.ts +2 -0
  23. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  24. package/dist/core/resolveConfig.d.ts +34 -4
  25. package/dist/core/resolveConfig.d.ts.map +1 -1
  26. package/dist/core/useLiveChartEngine.d.ts +3 -0
  27. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  28. package/dist/core/useLiveChartSeriesEngine.d.ts +3 -0
  29. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  30. package/dist/draw/candle.d.ts +2 -2
  31. package/dist/draw/candle.d.ts.map +1 -1
  32. package/dist/draw/grid.d.ts +2 -1
  33. package/dist/draw/grid.d.ts.map +1 -1
  34. package/dist/draw/line.d.ts +8 -8
  35. package/dist/draw/line.d.ts.map +1 -1
  36. package/dist/draw/markerAtlas.d.ts +44 -0
  37. package/dist/draw/markerAtlas.d.ts.map +1 -0
  38. package/dist/hooks/crosshairShared.d.ts +12 -4
  39. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  40. package/dist/hooks/resolveChartLayout.d.ts +3 -1
  41. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  42. package/dist/hooks/useBadge.d.ts +2 -2
  43. package/dist/hooks/useBadge.d.ts.map +1 -1
  44. package/dist/hooks/useCandlePaths.d.ts +2 -2
  45. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  46. package/dist/hooks/useCrosshair.d.ts +3 -1
  47. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  48. package/dist/hooks/useCrosshairSeries.d.ts +3 -1
  49. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  50. package/dist/hooks/useYAxis.d.ts +2 -1
  51. package/dist/hooks/useYAxis.d.ts.map +1 -1
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/types.d.ts +136 -5
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/components/CrosshairLine.tsx +30 -10
  58. package/src/components/CrosshairOverlay.tsx +31 -10
  59. package/src/components/DotOverlay.tsx +29 -14
  60. package/src/components/LiveChart.tsx +67 -15
  61. package/src/components/LiveChartSeries.tsx +76 -17
  62. package/src/components/LoadingOverlay.tsx +20 -11
  63. package/src/components/MarkerOverlay.tsx +119 -164
  64. package/src/components/MultiSeriesDots.tsx +30 -2
  65. package/src/components/YAxisOverlay.tsx +8 -4
  66. package/src/constants.ts +54 -14
  67. package/src/core/liveChartEngineTick.ts +7 -2
  68. package/src/core/liveChartSeriesEngineTick.ts +8 -2
  69. package/src/core/resolveConfig.ts +137 -38
  70. package/src/core/useLiveChartEngine.ts +6 -0
  71. package/src/core/useLiveChartSeriesEngine.ts +6 -0
  72. package/src/draw/candle.ts +11 -6
  73. package/src/draw/grid.ts +5 -5
  74. package/src/draw/line.ts +106 -25
  75. package/src/draw/markerAtlas.ts +304 -0
  76. package/src/hooks/crosshairShared.ts +24 -3
  77. package/src/hooks/resolveChartLayout.ts +11 -2
  78. package/src/hooks/useBadge.ts +38 -24
  79. package/src/hooks/useCandlePaths.ts +4 -2
  80. package/src/hooks/useCrosshair.ts +15 -2
  81. package/src/hooks/useCrosshairSeries.ts +8 -2
  82. package/src/hooks/useYAxis.ts +4 -1
  83. package/src/index.ts +7 -0
  84. package/src/types.ts +147 -5
package/src/draw/line.ts CHANGED
@@ -1,11 +1,5 @@
1
- import {
2
- BADGE_DOT_GAP,
3
- BADGE_MARGIN_RIGHT,
4
- BADGE_PILL_PAD_X,
5
- BADGE_PILL_PAD_Y,
6
- BADGE_TAIL_LEN,
7
- } from "../constants";
8
- import type { ChartInsets, LiveChartPoint } from "../types";
1
+ import { BADGE_METRICS_DEFAULTS } from "../constants";
2
+ import type { BadgeMetrics, ChartInsets, LiveChartPoint } from "../types";
9
3
  export {
10
4
  BADGE_DOT_GAP,
11
5
  BADGE_MARGIN_RIGHT,
@@ -36,10 +30,14 @@ export const DEFAULT_PADDING: ChartPadding = {
36
30
  * When `showTail` is false the tail spike is omitted and only the round cap
37
31
  * radius is returned, letting callers shrink the right gutter.
38
32
  */
39
- export function badgeTailAndCap(fontSize: number, showTail = true): number {
33
+ export function badgeTailAndCap(
34
+ fontSize: number,
35
+ showTail = true,
36
+ badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
37
+ ): number {
40
38
  "worklet";
41
- const pillH = fontSize + BADGE_PILL_PAD_Y * 2;
42
- return (showTail ? BADGE_TAIL_LEN : 0) + pillH / 2;
39
+ const pillH = fontSize + badge.padY * 2;
40
+ return (showTail ? badge.tailLength : 0) + pillH / 2;
43
41
  }
44
42
 
45
43
  /**
@@ -56,10 +54,11 @@ export function pillTextLeftX(
56
54
  paddingRight: number,
57
55
  tl: number,
58
56
  textWidth: number,
57
+ badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
59
58
  ): number {
60
59
  "worklet";
61
60
  const bodyLeft = canvasWidth - paddingRight + tl;
62
- const bodyRight = canvasWidth - BADGE_MARGIN_RIGHT;
61
+ const bodyRight = canvasWidth - badge.marginEdge;
63
62
  return (bodyLeft + bodyRight - textWidth) / 2;
64
63
  }
65
64
 
@@ -98,10 +97,11 @@ export function minPaddingRightForBadgeYAxisAlign(
98
97
  fontSize: number,
99
98
  textWidth: number,
100
99
  showTail = true,
100
+ badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
101
101
  ): number {
102
- const tl = badgeTailAndCap(fontSize, showTail);
102
+ const tl = badgeTailAndCap(fontSize, showTail, badge);
103
103
  return Math.ceil(
104
- BADGE_DOT_GAP + tl + 2 * BADGE_PILL_PAD_X + textWidth + BADGE_MARGIN_RIGHT,
104
+ badge.dotGap + tl + 2 * badge.padX + textWidth + badge.marginEdge,
105
105
  );
106
106
  }
107
107
 
@@ -110,8 +110,9 @@ export function resolveAutoRight(
110
110
  yAxis: boolean,
111
111
  badge: boolean,
112
112
  showTail = true,
113
+ badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
113
114
  ): number {
114
- if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail);
115
+ if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
115
116
  if (yAxis) return 44;
116
117
  return DEFAULT_PADDING.right;
117
118
  }
@@ -120,15 +121,21 @@ export function resolveAutoRight(
120
121
  * Minimum `padding.left` for a badge pill drawn in the left chart margin (label width + horizontal padding + dot gap).
121
122
  * `resolveChartLayout` does not call this; it remains available for custom layouts or `resolvePadding(..., badgeOnLeft: true)`.
122
123
  */
123
- export function minPaddingLeftForBadge(textWidth: number): number {
124
+ export function minPaddingLeftForBadge(
125
+ textWidth: number,
126
+ badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
127
+ ): number {
124
128
  return Math.ceil(
125
- BADGE_MARGIN_RIGHT + 2 * BADGE_PILL_PAD_X + textWidth + BADGE_DOT_GAP,
129
+ badge.marginEdge + 2 * badge.padX + textWidth + badge.dotGap,
126
130
  );
127
131
  }
128
132
 
129
133
  /** Default left inset, or a wider inset when `badgeOnLeft` is true (see `minPaddingLeftForBadge`). */
130
- export function resolveAutoLeft(badgeOnLeft: boolean): number {
131
- if (badgeOnLeft) return minPaddingLeftForBadge(49);
134
+ export function resolveAutoLeft(
135
+ badgeOnLeft: boolean,
136
+ badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
137
+ ): number {
138
+ if (badgeOnLeft) return minPaddingLeftForBadge(49, badgeMetrics);
132
139
  return DEFAULT_PADDING.left;
133
140
  }
134
141
 
@@ -171,9 +178,15 @@ export function resolvePadding(
171
178
  badgeOnLeft = false,
172
179
  xAxis = true,
173
180
  showTail = true,
181
+ badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
174
182
  ): ChartPadding {
175
- const autoRight = resolveAutoRight(yAxis, badge && !badgeOnLeft, showTail);
176
- const autoLeft = resolveAutoLeft(badgeOnLeft);
183
+ const autoRight = resolveAutoRight(
184
+ yAxis,
185
+ badge && !badgeOnLeft,
186
+ showTail,
187
+ badgeMetrics,
188
+ );
189
+ const autoLeft = resolveAutoLeft(badgeOnLeft, badgeMetrics);
177
190
  const autoBottom = xAxis ? DEFAULT_PADDING.bottom : 8;
178
191
  if (!override) {
179
192
  return {
@@ -239,12 +252,80 @@ export function buildLinePoints(
239
252
  }
240
253
  const startIdx = Math.max(0, lo - 1);
241
254
 
242
- for (let i = startIdx; i < data.length; i++) {
243
- if (data[i].time > now) break;
255
+ // End of the visible range: first index strictly after `now` (upper bound).
256
+ let elo = startIdx;
257
+ let ehi = data.length;
258
+ while (elo < ehi) {
259
+ const emid = (elo + ehi) >> 1;
260
+ if (data[emid].time <= now) elo = emid + 1;
261
+ else ehi = emid;
262
+ }
263
+ const endIdx = elo;
264
+
265
+ const xScale = chartW / windowSecs;
266
+ const yScale = chartH / valRange;
267
+
268
+ // Decimate to ~2 points per horizontal pixel once the window is denser than
269
+ // that. Drawing more points than the canvas is wide is wasted per-frame work
270
+ // (array build + Skia stroke) that scales with sample count rather than
271
+ // pixels — the dominant cost on dense / wide-window charts, and the reason
272
+ // scrubbing a saturated window drops frames. Below the threshold we keep the
273
+ // exact per-sample path (and existing behaviour) untouched.
274
+ const maxPlainPoints = Math.ceil(chartW * 2);
275
+
276
+ if (endIdx - startIdx <= maxPlainPoints) {
277
+ for (let i = startIdx; i < endIdx; i++) {
278
+ pts.push(
279
+ padding.left + (data[i].time - winStart) * xScale,
280
+ padding.top + (displayMax - data[i].value) * yScale,
281
+ );
282
+ }
283
+ } else {
284
+ // Min/max-per-pixel-column decimation: within each pixel column keep the
285
+ // lowest- and highest-value samples (emitted in their original time order)
286
+ // so the line's envelope and any volatility spikes survive at full vertical
287
+ // fidelity while the point count stays bounded by the canvas width.
288
+ let curCol = -2147483648;
289
+ let minIdx = startIdx;
290
+ let maxIdx = startIdx;
291
+ for (let i = startIdx; i < endIdx; i++) {
292
+ const col = ((data[i].time - winStart) * xScale) | 0;
293
+ if (col !== curCol) {
294
+ if (curCol !== -2147483648) {
295
+ const a = minIdx <= maxIdx ? minIdx : maxIdx;
296
+ const b = minIdx <= maxIdx ? maxIdx : minIdx;
297
+ pts.push(
298
+ padding.left + (data[a].time - winStart) * xScale,
299
+ padding.top + (displayMax - data[a].value) * yScale,
300
+ );
301
+ if (b !== a) {
302
+ pts.push(
303
+ padding.left + (data[b].time - winStart) * xScale,
304
+ padding.top + (displayMax - data[b].value) * yScale,
305
+ );
306
+ }
307
+ }
308
+ curCol = col;
309
+ minIdx = i;
310
+ maxIdx = i;
311
+ } else {
312
+ if (data[i].value < data[minIdx].value) minIdx = i;
313
+ if (data[i].value > data[maxIdx].value) maxIdx = i;
314
+ }
315
+ }
316
+ // Flush the final column.
317
+ const a = minIdx <= maxIdx ? minIdx : maxIdx;
318
+ const b = minIdx <= maxIdx ? maxIdx : minIdx;
244
319
  pts.push(
245
- padding.left + ((data[i].time - winStart) / windowSecs) * chartW,
246
- padding.top + ((displayMax - data[i].value) / valRange) * chartH,
320
+ padding.left + (data[a].time - winStart) * xScale,
321
+ padding.top + (displayMax - data[a].value) * yScale,
247
322
  );
323
+ if (b !== a) {
324
+ pts.push(
325
+ padding.left + (data[b].time - winStart) * xScale,
326
+ padding.top + (displayMax - data[b].value) * yScale,
327
+ );
328
+ }
248
329
  }
249
330
 
250
331
  // Live tip at current time with smoothed value
@@ -0,0 +1,304 @@
1
+ import {
2
+ Skia,
3
+ PaintStyle,
4
+ StrokeCap,
5
+ StrokeJoin,
6
+ drawAsImageFromPicture,
7
+ type SkCanvas,
8
+ type SkFont,
9
+ type SkImage,
10
+ type SkPaint,
11
+ type SkRect,
12
+ } from "@shopify/react-native-skia";
13
+ import type { LiveChartPalette, Marker, MarkerKind } from "../types";
14
+
15
+ /** Default icon box (px) when `marker.size` is unset. */
16
+ export const DEFAULT_ICON_SIZE = 16;
17
+ /** Circular badge padding around the icon glyph + background ring width. */
18
+ const PILL_PAD = 2;
19
+ const PILL_BORDER = 2;
20
+ const PILL_TEXT_COLOR = "#ffffff";
21
+ /** Anti-alias breathing room baked around every cell so sprites don't clip. */
22
+ const CELL_MARGIN = 2;
23
+
24
+ /** Default glyph color per kind when `marker.color` is unset. */
25
+ export function defaultMarkerColor(
26
+ kind: MarkerKind,
27
+ palette: LiveChartPalette,
28
+ ): string {
29
+ switch (kind) {
30
+ case "trade":
31
+ return palette.line;
32
+ case "boost":
33
+ return palette.refLine;
34
+ case "graduation":
35
+ return palette.dotUp;
36
+ case "winner":
37
+ return palette.dotUp;
38
+ case "clawback":
39
+ return palette.refLabel;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Axis-anchored kinds whose geometry depends on the chart's baseline (a
45
+ * variable-length stem, or a box pinned to the axis) and therefore cannot be a
46
+ * fixed-size sprite. These fall back to a self-projecting glyph component.
47
+ *
48
+ * Only applies when the marker has no `icon`/`image` override — those take
49
+ * precedence and render as ordinary centered stamps via the atlas.
50
+ */
51
+ export function isConnectorMarker(m: Marker): boolean {
52
+ "worklet";
53
+ return (
54
+ !m.image &&
55
+ !m.icon &&
56
+ (m.kind === "graduation" || m.kind === "clawback")
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Stable key for a marker's *visual appearance* (excludes position/anchor).
62
+ * Two markers with the same signature share one atlas cell. Called both on the
63
+ * JS thread (to build the atlas) and inside the per-frame worklet (to look a
64
+ * marker's cell up), so it must stay worklet-safe and primitive-only.
65
+ */
66
+ export function markerAppearanceSig(m: Marker): string {
67
+ "worklet";
68
+ if (m.image) return `img\x1f${m.id}\x1f${m.size ?? DEFAULT_ICON_SIZE}`;
69
+ const size = m.size ?? "";
70
+ if (m.icon)
71
+ return `ic\x1f${m.icon}\x1f${m.pill ? 1 : 0}\x1f${m.color ?? ""}\x1f${size}`;
72
+ return `k\x1f${m.kind}\x1f${m.color ?? ""}\x1f${size}`;
73
+ }
74
+
75
+ /** One packed glyph in the atlas image: its source rect + box size. */
76
+ export interface AtlasCell {
77
+ rect: SkRect;
78
+ w: number;
79
+ h: number;
80
+ }
81
+
82
+ export interface MarkerAtlas {
83
+ /** Packed glyph texture, or null when there are no atlas-rendered markers. */
84
+ image: SkImage | null;
85
+ /** appearance-signature → cell. */
86
+ cells: Record<string, AtlasCell>;
87
+ }
88
+
89
+ const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {} };
90
+
91
+ function fillPaint(color: string): SkPaint {
92
+ const p = Skia.Paint();
93
+ p.setAntiAlias(true);
94
+ p.setColor(Skia.Color(color));
95
+ p.setStyle(PaintStyle.Fill);
96
+ return p;
97
+ }
98
+
99
+ function strokePaint(color: string, width: number, round = false): SkPaint {
100
+ const p = Skia.Paint();
101
+ p.setAntiAlias(true);
102
+ p.setColor(Skia.Color(color));
103
+ p.setStyle(PaintStyle.Stroke);
104
+ p.setStrokeWidth(width);
105
+ if (round) {
106
+ p.setStrokeCap(StrokeCap.Round);
107
+ p.setStrokeJoin(StrokeJoin.Round);
108
+ }
109
+ return p;
110
+ }
111
+
112
+ interface CellSpec {
113
+ w: number;
114
+ h: number;
115
+ /** Draw the glyph centered at (cx, cy) in the recording canvas. */
116
+ draw: (canvas: SkCanvas, cx: number, cy: number) => void;
117
+ }
118
+
119
+ /**
120
+ * Geometry + draw routine for one marker appearance. Mirrors the per-glyph
121
+ * rendering the old `MarkerGlyph` did inline, so the atlas is pixel-equivalent.
122
+ */
123
+ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec {
124
+ const color = m.color ?? defaultMarkerColor(m.kind, palette);
125
+ const m2 = CELL_MARGIN * 2;
126
+
127
+ if (m.image) {
128
+ const img = m.image;
129
+ const size = m.size ?? DEFAULT_ICON_SIZE;
130
+ const imgPaint = Skia.Paint();
131
+ imgPaint.setAntiAlias(true);
132
+ return {
133
+ w: size + m2,
134
+ h: size + m2,
135
+ draw: (canvas, cx, cy) => {
136
+ const iw = img.width();
137
+ const ih = img.height();
138
+ // `contain` fit: scale to fit the box without distorting aspect.
139
+ const scale = iw > 0 && ih > 0 ? Math.min(size / iw, size / ih) : 1;
140
+ const dw = iw * scale;
141
+ const dh = ih * scale;
142
+ canvas.drawImageRect(
143
+ img,
144
+ Skia.XYWHRect(0, 0, iw, ih),
145
+ Skia.XYWHRect(cx - dw / 2, cy - dh / 2, dw, dh),
146
+ imgPaint,
147
+ );
148
+ },
149
+ };
150
+ }
151
+
152
+ if (m.icon) {
153
+ const icon = m.icon;
154
+ const b = font.measureText(icon);
155
+ // Center the glyph on its measured bounds (tighter than ascent/descent).
156
+ const iconDX = b.x + b.width / 2;
157
+ const iconDY = b.y + b.height / 2;
158
+
159
+ if (m.pill) {
160
+ const bgColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
161
+ const pillR = Math.max(b.width, b.height) / 2 + PILL_PAD;
162
+ const ringR = pillR + PILL_BORDER;
163
+ const box = Math.ceil(2 * ringR) + m2;
164
+ return {
165
+ w: box,
166
+ h: box,
167
+ draw: (canvas, cx, cy) => {
168
+ canvas.drawCircle(cx, cy, ringR, fillPaint(bgColor));
169
+ canvas.drawCircle(cx, cy, pillR, fillPaint(color));
170
+ canvas.drawText(
171
+ icon,
172
+ cx - iconDX,
173
+ cy - iconDY,
174
+ fillPaint(PILL_TEXT_COLOR),
175
+ font,
176
+ );
177
+ },
178
+ };
179
+ }
180
+
181
+ return {
182
+ w: Math.ceil(Math.max(b.width, 1)) + m2,
183
+ h: Math.ceil(Math.max(b.height, 1)) + m2,
184
+ draw: (canvas, cx, cy) => {
185
+ canvas.drawText(icon, cx - iconDX, cy - iconDY, fillPaint(color), font);
186
+ },
187
+ };
188
+ }
189
+
190
+ if (m.kind === "trade") {
191
+ const box = 2 * (5 + 1) + m2; // ring r=5 stroked w=2
192
+ return {
193
+ w: box,
194
+ h: box,
195
+ draw: (canvas, cx, cy) => {
196
+ canvas.drawCircle(cx, cy, 5, strokePaint(color, 2));
197
+ canvas.drawCircle(cx, cy, 2, fillPaint(color));
198
+ },
199
+ };
200
+ }
201
+
202
+ if (m.kind === "winner") {
203
+ const outer = 7;
204
+ const inner = 3;
205
+ const box = 2 * outer + m2;
206
+ return {
207
+ w: box,
208
+ h: box,
209
+ draw: (canvas, cx, cy) => {
210
+ const p = Skia.Path.Make();
211
+ for (let k = 0; k < 10; k++) {
212
+ const ang = -Math.PI / 2 + (k * Math.PI) / 5;
213
+ const rad = k % 2 === 0 ? outer : inner;
214
+ const px = cx + rad * Math.cos(ang);
215
+ const py = cy + rad * Math.sin(ang);
216
+ if (k === 0) p.moveTo(px, py);
217
+ else p.lineTo(px, py);
218
+ }
219
+ p.close();
220
+ canvas.drawPath(p, fillPaint(color));
221
+ },
222
+ };
223
+ }
224
+
225
+ if (m.kind === "boost") {
226
+ const L = 6;
227
+ const box = 2 * L + m2;
228
+ return {
229
+ w: box,
230
+ h: box,
231
+ draw: (canvas, cx, cy) => {
232
+ const p = Skia.Path.Make();
233
+ for (let k = 0; k < 4; k++) {
234
+ const ang = (k * Math.PI) / 4;
235
+ const dx = L * Math.cos(ang);
236
+ const dy = L * Math.sin(ang);
237
+ p.moveTo(cx - dx, cy - dy);
238
+ p.lineTo(cx + dx, cy + dy);
239
+ }
240
+ canvas.drawPath(p, strokePaint(color, 1.5, true));
241
+ },
242
+ };
243
+ }
244
+
245
+ // Connector kinds are handled outside the atlas; this is a defensive dot.
246
+ const box = 6 + m2;
247
+ return {
248
+ w: box,
249
+ h: box,
250
+ draw: (canvas, cx, cy) => canvas.drawCircle(cx, cy, 3, fillPaint(color)),
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Rasterize every distinct marker appearance into a single packed atlas image,
256
+ * once per appearance-set change (NOT per frame). The per-frame worklet then
257
+ * blits these cells via one `drawAtlas` call.
258
+ *
259
+ * Connector kinds (see `isConnectorMarker`) are skipped — they render via a
260
+ * self-projecting fallback component.
261
+ */
262
+ export function buildMarkerAtlas(
263
+ markers: Marker[],
264
+ palette: LiveChartPalette,
265
+ font: SkFont,
266
+ ): MarkerAtlas {
267
+ const seen = new Set<string>();
268
+ const specs: { sig: string; spec: CellSpec }[] = [];
269
+ for (let i = 0; i < markers.length; i++) {
270
+ const m = markers[i];
271
+ if (isConnectorMarker(m)) continue;
272
+ const sig = markerAppearanceSig(m);
273
+ if (seen.has(sig)) continue;
274
+ seen.add(sig);
275
+ specs.push({ sig, spec: cellSpec(m, palette, font) });
276
+ }
277
+ if (specs.length === 0) return EMPTY_ATLAS;
278
+
279
+ let totalW = 0;
280
+ let maxH = 0;
281
+ for (let i = 0; i < specs.length; i++) {
282
+ totalW += specs[i].spec.w;
283
+ if (specs[i].spec.h > maxH) maxH = specs[i].spec.h;
284
+ }
285
+ const W = Math.max(1, Math.ceil(totalW));
286
+ const H = Math.max(1, Math.ceil(maxH));
287
+
288
+ const recorder = Skia.PictureRecorder();
289
+ const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, W, H));
290
+ const cells: Record<string, AtlasCell> = {};
291
+ let x = 0;
292
+ for (let i = 0; i < specs.length; i++) {
293
+ const { sig, spec } = specs[i];
294
+ canvas.save();
295
+ canvas.translate(x, 0);
296
+ spec.draw(canvas, spec.w / 2, H / 2);
297
+ canvas.restore();
298
+ cells[sig] = { rect: Skia.XYWHRect(x, 0, spec.w, H), w: spec.w, h: H };
299
+ x += spec.w;
300
+ }
301
+ const picture = recorder.finishRecordingAsPicture();
302
+ const image = drawAsImageFromPicture(picture, { width: W, height: H });
303
+ return { image, cells };
304
+ }
@@ -109,6 +109,11 @@ export function computeTooltipLayout(
109
109
  formatValue: (v: number) => string,
110
110
  formatTime: (t: number) => string,
111
111
  font: SkFont,
112
+ /** Monospace advance width. When > 0, text width is `len * monoCharWidth`
113
+ * instead of a per-frame Skia `measureText` — scrubbing re-runs this worklet
114
+ * every frame, and `measureText` shapes text each call (a real cost,
115
+ * especially in the simulator). Falls back to `measureText` when 0. */
116
+ monoCharWidth = 0,
112
117
  ): TooltipLayout {
113
118
  "worklet";
114
119
  if (!scrubActive || scrubValue === null) return HIDDEN_TOOLTIP;
@@ -122,8 +127,14 @@ export function computeTooltipLayout(
122
127
  const lineH = -fm.ascent + fm.descent;
123
128
  const totalH = TOOLTIP_PAD_Y * 2 + lineH * 2 + TOOLTIP_LINE_GAP;
124
129
 
125
- const valueW = measureFontTextWidth(font, valueStr);
126
- const timeW = measureFontTextWidth(font, timeStr);
130
+ const valueW =
131
+ monoCharWidth > 0
132
+ ? valueStr.length * monoCharWidth
133
+ : measureFontTextWidth(font, valueStr);
134
+ const timeW =
135
+ monoCharWidth > 0
136
+ ? timeStr.length * monoCharWidth
137
+ : measureFontTextWidth(font, timeStr);
127
138
  const contentW = Math.max(valueW, timeW);
128
139
  const pillW = contentW + TOOLTIP_PAD_X * 2;
129
140
 
@@ -166,6 +177,9 @@ export function computeTooltipLayoutMulti(
166
177
  padding: ChartPadding,
167
178
  canvasWidth: number,
168
179
  font: SkFont,
180
+ /** Monospace advance width; when > 0, sizes text by length instead of a
181
+ * per-frame Skia `measureText`. See {@link computeTooltipLayout}. */
182
+ monoCharWidth = 0,
169
183
  ): TooltipLayout {
170
184
  "worklet";
171
185
  if (!scrubActive || lines.length === 0) return HIDDEN_TOOLTIP;
@@ -179,7 +193,10 @@ export function computeTooltipLayoutMulti(
179
193
  let contentW = 0;
180
194
  const lineWidths: number[] = [];
181
195
  for (let i = 0; i < n; i++) {
182
- const w = measureFontTextWidth(font, lines[i].text);
196
+ const w =
197
+ monoCharWidth > 0
198
+ ? lines[i].text.length * monoCharWidth
199
+ : measureFontTextWidth(font, lines[i].text);
183
200
  lineWidths.push(w);
184
201
  if (w > contentW) contentW = w;
185
202
  }
@@ -237,6 +254,7 @@ export function computeCandleTooltipLayout(
237
254
  formatValue: (v: number) => string,
238
255
  formatTime: (t: number) => string,
239
256
  font: SkFont,
257
+ monoCharWidth = 0,
240
258
  ): TooltipLayout {
241
259
  "worklet";
242
260
  if (!scrubActive || !candle) return HIDDEN_TOOLTIP;
@@ -254,6 +272,7 @@ export function computeCandleTooltipLayout(
254
272
  padding,
255
273
  canvasWidth,
256
274
  font,
275
+ monoCharWidth,
257
276
  );
258
277
  }
259
278
 
@@ -279,6 +298,7 @@ export function deriveCrosshairTooltipSingle(
279
298
  formatValue: (v: number) => string,
280
299
  formatTime: (t: number) => string,
281
300
  font: SkFont,
301
+ monoCharWidth = 0,
282
302
  ): TooltipLayout {
283
303
  "worklet";
284
304
  if (!scrubActive || scrubTime < 0) return HIDDEN_TOOLTIP;
@@ -292,5 +312,6 @@ export function deriveCrosshairTooltipSingle(
292
312
  formatValue,
293
313
  formatTime,
294
314
  font,
315
+ monoCharWidth,
295
316
  );
296
317
  }
@@ -9,7 +9,7 @@ import {
9
9
  resolvePadding,
10
10
  type ChartPadding,
11
11
  } from "../draw/line";
12
- import type { ChartInsets, LiveChartPalette } from "../types";
12
+ import type { BadgeMetrics, ChartInsets, LiveChartPalette } from "../types";
13
13
 
14
14
  export interface ChartLayoutConfig {
15
15
  palette: LiveChartPalette;
@@ -17,6 +17,8 @@ export interface ChartLayoutConfig {
17
17
  insetsOverride?: ChartInsets;
18
18
  yAxis: boolean;
19
19
  badge: boolean;
20
+ /** Badge pill geometry tokens. Omit for built-in defaults. */
21
+ badgeMetrics?: BadgeMetrics;
20
22
  /**
21
23
  * When true (default if omitted and `badge` is true), reserve the wide right gutter for the badge.
22
24
  * Set false when the badge is anchored left of the live dot (`position: "left"`).
@@ -79,6 +81,7 @@ export function resolveChartLayout(
79
81
  config.font.getSize(),
80
82
  measuredYAxisLabelWidth,
81
83
  showTail,
84
+ config.badgeMetrics,
82
85
  )
83
86
  : config.multiSeriesValueLabel && config.yAxis
84
87
  ? Math.max(
@@ -91,7 +94,12 @@ export function resolveChartLayout(
91
94
  ? Math.max(measuredYAxisLabelWidth + 16 + dotR * 2, 44)
92
95
  : resolveAutoRight(false, false);
93
96
  } else {
94
- rightPad = resolveAutoRight(config.yAxis, badgeUsesRightGutter, showTail);
97
+ rightPad = resolveAutoRight(
98
+ config.yAxis,
99
+ badgeUsesRightGutter,
100
+ showTail,
101
+ config.badgeMetrics,
102
+ );
95
103
  }
96
104
 
97
105
  if (config.pulse && config.yAxis && config.insetsOverride?.right == null) {
@@ -121,6 +129,7 @@ export function resolveChartLayout(
121
129
  false,
122
130
  xAxis,
123
131
  showTail,
132
+ config.badgeMetrics,
124
133
  );
125
134
 
126
135
  let padding: ChartPadding = { ...base, right: rightPad, left: leftPad };