react-native-livechart 1.1.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 (67) hide show
  1. package/README.md +15 -4
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  4. package/dist/components/LiveChart.d.ts.map +1 -1
  5. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  6. package/dist/components/LoadingOverlay.d.ts +6 -2
  7. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  8. package/dist/components/MarkerOverlay.d.ts +8 -4
  9. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  10. package/dist/components/YAxisOverlay.d.ts +4 -2
  11. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  12. package/dist/constants.d.ts +16 -13
  13. package/dist/constants.d.ts.map +1 -1
  14. package/dist/core/liveChartEngineTick.d.ts +2 -0
  15. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  16. package/dist/core/liveChartSeriesEngineTick.d.ts +2 -0
  17. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  18. package/dist/core/resolveConfig.d.ts +18 -4
  19. package/dist/core/resolveConfig.d.ts.map +1 -1
  20. package/dist/core/useLiveChartEngine.d.ts +3 -0
  21. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  22. package/dist/core/useLiveChartSeriesEngine.d.ts +3 -0
  23. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  24. package/dist/draw/candle.d.ts +2 -2
  25. package/dist/draw/candle.d.ts.map +1 -1
  26. package/dist/draw/grid.d.ts +2 -1
  27. package/dist/draw/grid.d.ts.map +1 -1
  28. package/dist/draw/line.d.ts +8 -8
  29. package/dist/draw/line.d.ts.map +1 -1
  30. package/dist/draw/markerAtlas.d.ts +44 -0
  31. package/dist/draw/markerAtlas.d.ts.map +1 -0
  32. package/dist/hooks/resolveChartLayout.d.ts +3 -1
  33. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  34. package/dist/hooks/useBadge.d.ts +2 -2
  35. package/dist/hooks/useBadge.d.ts.map +1 -1
  36. package/dist/hooks/useCandlePaths.d.ts +2 -2
  37. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  38. package/dist/hooks/useYAxis.d.ts +2 -1
  39. package/dist/hooks/useYAxis.d.ts.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/types.d.ts +100 -5
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/CrosshairLine.tsx +23 -9
  46. package/src/components/CrosshairOverlay.tsx +23 -9
  47. package/src/components/LiveChart.tsx +14 -0
  48. package/src/components/LiveChartSeries.tsx +10 -1
  49. package/src/components/LoadingOverlay.tsx +20 -11
  50. package/src/components/MarkerOverlay.tsx +119 -164
  51. package/src/components/YAxisOverlay.tsx +8 -4
  52. package/src/constants.ts +54 -14
  53. package/src/core/liveChartEngineTick.ts +7 -2
  54. package/src/core/liveChartSeriesEngineTick.ts +8 -2
  55. package/src/core/resolveConfig.ts +88 -32
  56. package/src/core/useLiveChartEngine.ts +6 -0
  57. package/src/core/useLiveChartSeriesEngine.ts +6 -0
  58. package/src/draw/candle.ts +11 -6
  59. package/src/draw/grid.ts +5 -5
  60. package/src/draw/line.ts +106 -25
  61. package/src/draw/markerAtlas.ts +304 -0
  62. package/src/hooks/resolveChartLayout.ts +11 -2
  63. package/src/hooks/useBadge.ts +38 -24
  64. package/src/hooks/useCandlePaths.ts +4 -2
  65. package/src/hooks/useYAxis.ts +4 -1
  66. package/src/index.ts +7 -0
  67. package/src/types.ts +109 -5
@@ -13,10 +13,8 @@ import {
13
13
  import { useRef } from "react";
14
14
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
15
15
  import {
16
- BADGE_DOT_GAP,
17
- EMPTY_GAP_FADE_WIDTH,
18
- EMPTY_STATE_LABEL_ALPHA,
19
- EMPTY_TEXT_GAP_PAD,
16
+ BADGE_METRICS_DEFAULTS,
17
+ EMPTY_STATE_METRICS_DEFAULTS,
20
18
  } from "../constants";
21
19
  import {
22
20
  badgeTailAndCap,
@@ -26,7 +24,11 @@ import {
26
24
  } from "../draw/line";
27
25
  import { drawSpline } from "../math/spline";
28
26
  import { buildSquigglyPts } from "../math/squiggly";
29
- import type { LiveChartPalette } from "../types";
27
+ import type {
28
+ BadgeMetrics,
29
+ EmptyStateMetrics,
30
+ LiveChartPalette,
31
+ } from "../types";
30
32
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
31
33
 
32
34
  const PLACEHOLDER_LABEL_COUNT = 4;
@@ -57,6 +59,8 @@ export function LoadingOverlay({
57
59
  strokeWidth,
58
60
  badge = false,
59
61
  badgeTail = true,
62
+ badgeMetrics = BADGE_METRICS_DEFAULTS,
63
+ emptyMetrics = EMPTY_STATE_METRICS_DEFAULTS,
60
64
  }: {
61
65
  engine: ChartEngineLayout;
62
66
  padding: ChartPadding;
@@ -72,9 +76,14 @@ export function LoadingOverlay({
72
76
  badge?: boolean;
73
77
  /** Whether the badge tail spike is shown; affects the left inset used for skeleton alignment. */
74
78
  badgeTail?: boolean;
79
+ /** Badge pill geometry tokens (kept in sync with GridOverlay/useBadge). */
80
+ badgeMetrics?: BadgeMetrics;
81
+ /** Empty-state layout tokens. */
82
+ emptyMetrics?: EmptyStateMetrics;
75
83
  }) {
76
84
  // Same left-inset formula as GridOverlay (only used when badge=true)
77
- const leftInset = BADGE_DOT_GAP + badgeTailAndCap(font.getSize(), badgeTail);
85
+ const leftInset =
86
+ badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
78
87
 
79
88
  // Ping-pong persistent paths — avoid allocating a JSI-backed SkPath per frame
80
89
  // while the squiggly loading/empty-state animation is running.
@@ -127,7 +136,7 @@ export function LoadingOverlay({
127
136
  const chartH = h - padding.top - padding.bottom;
128
137
  // Mirror GridOverlay: pillTextLeftX when badge is on, gutterCenteredTextLeftX otherwise
129
138
  const x = badge
130
- ? pillTextLeftX(w, padding.right, leftInset, RECT_W)
139
+ ? pillTextLeftX(w, padding.right, leftInset, RECT_W, badgeMetrics)
131
140
  : gutterCenteredTextLeftX(w, padding.right, RECT_W);
132
141
  // Centre the group of rects around the chart's vertical midpoint
133
142
  const groupH = (PLACEHOLDER_LABEL_COUNT - 1) * RECT_SPACING;
@@ -157,7 +166,7 @@ export function LoadingOverlay({
157
166
  gapRight: 0,
158
167
  centerY: 0,
159
168
  eraseH: 0,
160
- fadeW: EMPTY_GAP_FADE_WIDTH,
169
+ fadeW: emptyMetrics.gapFadeWidth,
161
170
  textX: 0,
162
171
  textY: 0,
163
172
  textW: 0,
@@ -168,8 +177,8 @@ export function LoadingOverlay({
168
177
  const centerY = padding.top + chartH / 2;
169
178
  const chartCentreX = (padding.left + w - padding.right) / 2;
170
179
  const textW = font.measureText(emptyText).width;
171
- const gapHalf = textW / 2 + EMPTY_TEXT_GAP_PAD;
172
- const fadeW = EMPTY_GAP_FADE_WIDTH;
180
+ const gapHalf = textW / 2 + emptyMetrics.gapPad;
181
+ const fadeW = emptyMetrics.gapFadeWidth;
173
182
  const gapLeft = chartCentreX - gapHalf - fadeW;
174
183
  const gapRight = chartCentreX + gapHalf + fadeW;
175
184
  const eraseH = Math.max(56, chartH * 0.28) + strokeWidth;
@@ -221,7 +230,7 @@ export function LoadingOverlay({
221
230
  isEmpty.value ? emptyText : "",
222
231
  );
223
232
  const emptyLabelOpacity = useDerivedValue(() =>
224
- isEmpty.value ? EMPTY_STATE_LABEL_ALPHA : 0,
233
+ isEmpty.value ? emptyMetrics.labelOpacity : 0,
225
234
  );
226
235
 
227
236
  return (
@@ -1,87 +1,70 @@
1
1
  import {
2
- Circle,
2
+ Atlas,
3
3
  Group,
4
- Image as SkiaImage,
5
4
  Path,
6
5
  Skia,
7
- Text as SkiaText,
8
6
  type SkFont,
9
7
  type SkPath,
10
8
  } from "@shopify/react-native-skia";
11
- import { useRef, useState } from "react";
9
+ import { useMemo, useRef, useState } from "react";
12
10
  import {
13
- useAnimatedReaction,
14
11
  useDerivedValue,
12
+ useAnimatedReaction,
15
13
  type SharedValue,
16
14
  } from "react-native-reanimated";
17
15
  import { scheduleOnRN } from "react-native-worklets";
18
16
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
19
17
  import type { ChartPadding } from "../draw/line";
20
- import { markersSignature, projectPoint } from "../math/markers";
18
+ import {
19
+ buildMarkerAtlas,
20
+ defaultMarkerColor,
21
+ isConnectorMarker,
22
+ markerAppearanceSig,
23
+ type AtlasCell,
24
+ } from "../draw/markerAtlas";
25
+ import {
26
+ markersSignature,
27
+ projectMarkers,
28
+ projectPoint,
29
+ type ProjectedMarker,
30
+ } from "../math/markers";
21
31
  import type {
22
32
  LiveChartPalette,
23
33
  LiveChartPoint,
24
34
  Marker,
25
- MarkerKind,
26
35
  SeriesConfig,
27
36
  } from "../types";
28
37
 
29
- /** Default glyph color per kind when `marker.color` is unset. */
30
- function defaultMarkerColor(kind: MarkerKind, palette: LiveChartPalette): string {
31
- switch (kind) {
32
- case "trade":
33
- return palette.line;
34
- case "boost":
35
- return palette.refLine;
36
- case "graduation":
37
- return palette.dotUp;
38
- case "winner":
39
- return palette.dotUp;
40
- case "clawback":
41
- return palette.refLabel;
42
- }
43
- }
44
-
45
38
  const OFF = -9999;
46
- const DEFAULT_ICON_SIZE = 16;
47
- /** Circular badge: padding around the icon glyph, background-colored ring width,
48
- * and the icon color drawn on the fill. */
49
- const PILL_PAD = 2;
50
- const PILL_BORDER = 2;
51
- const PILL_TEXT_COLOR = "#ffffff";
52
39
 
53
- function MarkerGlyph({
40
+ /**
41
+ * Self-projecting fallback for the axis-anchored kinds (graduation stem + flag,
42
+ * clawback box) that can't be fixed-size atlas sprites. Rare, so the per-glyph
43
+ * worklet cost here is negligible; each glyph still projects its OWN marker so
44
+ * reordering the array can't make it flash another marker's position.
45
+ */
46
+ function ConnectorGlyph({
54
47
  marker,
55
48
  color,
56
- bgColor,
57
49
  engine,
58
50
  padding,
59
51
  seriesSV,
60
52
  lineDataSV,
61
- font,
62
53
  axisY,
63
54
  }: {
64
55
  marker: Marker;
65
56
  color: string;
66
- /** Chart background color, drawn as a ring behind a `pill` badge. */
67
- bgColor?: string;
68
57
  engine: ChartEngineLayout;
69
58
  padding: ChartPadding;
70
59
  seriesSV?: SharedValue<SeriesConfig[]>;
71
60
  lineDataSV?: SharedValue<LiveChartPoint[]>;
72
- font: SkFont;
73
61
  axisY: SharedValue<number>;
74
62
  }) {
75
- // Capture only primitive anchor fields — never the full marker (which may
76
- // carry non-serializable `data` / `image`) — in the worklet closure.
77
- const { kind, icon, image, pill } = marker;
63
+ const { kind } = marker;
78
64
  const time = marker.time;
79
65
  const value = marker.value;
80
66
  const seriesId = marker.seriesId;
81
- const size = marker.size ?? DEFAULT_ICON_SIZE;
82
67
 
83
- // Each glyph projects its OWN marker — no shared index buffer, so reordering
84
- // the marker array can't make a glyph flash another marker's position.
85
68
  const layout = useDerivedValue(() =>
86
69
  projectPoint(time, value, seriesId, {
87
70
  canvasWidth: engine.canvasWidth.get(),
@@ -99,12 +82,6 @@ function MarkerGlyph({
99
82
  }),
100
83
  );
101
84
 
102
- const cx = useDerivedValue(() =>
103
- layout.get().visible ? layout.get().x : OFF,
104
- );
105
- const cy = useDerivedValue(() =>
106
- layout.get().visible ? layout.get().y : OFF,
107
- );
108
85
  const opacity = useDerivedValue(() => (layout.get().visible ? 1 : 0));
109
86
 
110
87
  const cacheRef = useRef<{ a: SkPath; b: SkPath; tick: boolean } | null>(null);
@@ -119,30 +96,9 @@ function MarkerGlyph({
119
96
  p.reset();
120
97
  const l = layout.get();
121
98
  if (!l.visible) return p;
122
- const x = l.x;
99
+ const x = l.visible ? l.x : OFF;
123
100
  const y = l.y;
124
- if (kind === "winner") {
125
- const outer = 7;
126
- const inner = 3;
127
- for (let k = 0; k < 10; k++) {
128
- const ang = -Math.PI / 2 + (k * Math.PI) / 5;
129
- const rad = k % 2 === 0 ? outer : inner;
130
- const px = x + rad * Math.cos(ang);
131
- const py = y + rad * Math.sin(ang);
132
- if (k === 0) p.moveTo(px, py);
133
- else p.lineTo(px, py);
134
- }
135
- p.close();
136
- } else if (kind === "boost") {
137
- const L = 6;
138
- for (let k = 0; k < 4; k++) {
139
- const ang = (k * Math.PI) / 4;
140
- const dx = L * Math.cos(ang);
141
- const dy = L * Math.sin(ang);
142
- p.moveTo(x - dx, y - dy);
143
- p.lineTo(x + dx, y + dy);
144
- }
145
- } else if (kind === "graduation") {
101
+ if (kind === "graduation") {
146
102
  p.moveTo(x, axisY.get());
147
103
  p.lineTo(x, y);
148
104
  p.moveTo(x, y - 7);
@@ -161,88 +117,7 @@ function MarkerGlyph({
161
117
  return p;
162
118
  });
163
119
 
164
- // Image icon centered on the marker.
165
- const imgX = useDerivedValue(() =>
166
- layout.get().visible ? layout.get().x - size / 2 : OFF,
167
- );
168
- const imgY = useDerivedValue(() =>
169
- layout.get().visible ? layout.get().y - size / 2 : OFF,
170
- );
171
-
172
- // Text / emoji icon centering — width + baseline shift depend only on the
173
- // (stable) font and icon, so measure once instead of every frame. Skia
174
- // `measureText`/`getMetrics` both allocate, so hoisting them out of the
175
- // per-frame worklet removes one measure + one metrics call per glyph/frame.
176
- // Center the icon glyph on the marker using its measured bounds — tighter and
177
- // more accurate than font ascent/descent, so `+` / `−` etc. sit centered both
178
- // horizontally and vertically in the badge. Stable per render, not per frame.
179
- const iconBounds = font.measureText(icon ?? "");
180
- const iconDX = iconBounds.x + iconBounds.width / 2;
181
- const iconDY = iconBounds.y + iconBounds.height / 2;
182
-
183
- const iconX = useDerivedValue(() =>
184
- layout.get().visible ? layout.get().x - iconDX : OFF,
185
- );
186
- const iconY = useDerivedValue(() =>
187
- layout.get().visible ? layout.get().y - iconDY : OFF,
188
- );
189
-
190
- // Circular badge radius — fits the glyph's larger dimension + padding.
191
- const pillR = Math.max(iconBounds.width, iconBounds.height) / 2 + PILL_PAD;
192
-
193
- if (image) {
194
- return (
195
- <Group opacity={opacity}>
196
- <SkiaImage
197
- image={image}
198
- x={imgX}
199
- y={imgY}
200
- width={size}
201
- height={size}
202
- fit="contain"
203
- />
204
- </Group>
205
- );
206
- }
207
-
208
- if (icon) {
209
- if (pill) {
210
- // Filled circular badge in the marker color with the icon in white — e.g.
211
- // a green `+` buy / red `−` sell tag. A background-colored ring underneath
212
- // separates it from the line passing behind.
213
- return (
214
- <Group opacity={opacity}>
215
- {bgColor !== undefined && (
216
- <Circle cx={cx} cy={cy} r={pillR + PILL_BORDER} color={bgColor} />
217
- )}
218
- <Circle cx={cx} cy={cy} r={pillR} color={color} />
219
- <SkiaText
220
- x={iconX}
221
- y={iconY}
222
- text={icon}
223
- font={font}
224
- color={PILL_TEXT_COLOR}
225
- />
226
- </Group>
227
- );
228
- }
229
- return (
230
- <Group opacity={opacity}>
231
- <SkiaText x={iconX} y={iconY} text={icon} font={font} color={color} />
232
- </Group>
233
- );
234
- }
235
-
236
- if (kind === "trade") {
237
- return (
238
- <Group opacity={opacity}>
239
- <Circle cx={cx} cy={cy} r={5} color={color} style="stroke" strokeWidth={2} />
240
- <Circle cx={cx} cy={cy} r={2} color={color} />
241
- </Group>
242
- );
243
- }
244
-
245
- const fill = kind === "winner" || kind === "clawback";
120
+ const fill = kind === "clawback";
246
121
  return (
247
122
  <Group opacity={opacity}>
248
123
  <Path
@@ -258,10 +133,14 @@ function MarkerGlyph({
258
133
  }
259
134
 
260
135
  /**
261
- * Renders the `markers` SharedValue as canvas glyphs. Marker metadata is
262
- * mirrored into React state when it changes; each glyph projects its own
263
- * position every frame (keyed by id), so adding/removing markers never makes a
264
- * surviving glyph flash another marker's position.
136
+ * Renders the `markers` SharedValue as canvas glyphs.
137
+ *
138
+ * Every "stamp" glyph (icon, pill, image, and the trade/winner/boost shapes) is
139
+ * pre-rasterized once per distinct appearance into a single packed atlas image,
140
+ * then drawn each frame with ONE `drawAtlas` call driven by a single worklet —
141
+ * so per-frame UI-thread cost is O(1) mappers + one draw, not O(markers × ~9
142
+ * derived values + N Skia subtrees). Axis-anchored kinds (graduation/clawback)
143
+ * fall back to `ConnectorGlyph`.
265
144
  *
266
145
  * Glyph precedence per marker: `image` → `icon` (text) → built-in `kind` shape.
267
146
  */
@@ -288,9 +167,7 @@ export function MarkerOverlay({
288
167
  const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
289
168
 
290
169
  // Read the `markers` prop from closure rather than a SharedValue passed
291
- // through `scheduleOnRN`: the handle serialized across the worklet→JS
292
- // boundary exposes the native `.value` accessor but NOT the `.get()` method,
293
- // so calling `.get()` on it throws ("sv.get is not a function").
170
+ // through `scheduleOnRN` (which loses `.get()`); mirrors the data model.
294
171
  const pull = () => {
295
172
  setSnapshot(markers.get().slice());
296
173
  };
@@ -304,25 +181,103 @@ export function MarkerOverlay({
304
181
  [markers, pull],
305
182
  );
306
183
 
184
+ // Rebuild the atlas image only when the set of distinct appearances or the
185
+ // theme/font changes — never per frame. `markersSignature` already drives the
186
+ // snapshot, so this useMemo re-runs at most at the snapshot cadence.
187
+ const appearanceKey = useMemo(() => {
188
+ const sigs = new Set<string>();
189
+ for (let i = 0; i < snapshot.length; i++) {
190
+ const m = snapshot[i];
191
+ if (!isConnectorMarker(m)) sigs.add(markerAppearanceSig(m));
192
+ }
193
+ return Array.from(sigs).sort().join("\x1e");
194
+ }, [snapshot]);
195
+ // Cells bake in resolved colors; include the palette fields they depend on.
196
+ const paletteKey = `${palette.bgRgb.join(",")}|${palette.line}|${palette.refLine}|${palette.dotUp}|${palette.refLabel}`;
197
+ const atlas = useMemo(
198
+ () => buildMarkerAtlas(snapshot, palette, font),
199
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
200
+ [appearanceKey, paletteKey, font],
201
+ );
202
+ const cells: Record<string, AtlasCell> = atlas.cells;
203
+ const atlasImage = atlas.image;
204
+
205
+ // One pooled, ping-ponged projection buffer reused every frame (no per-frame
206
+ // array allocation for the projection itself).
207
+ const projRef = useRef<{
208
+ a: ProjectedMarker[];
209
+ b: ProjectedMarker[];
210
+ tick: boolean;
211
+ } | null>(null);
212
+ if (projRef.current === null) {
213
+ projRef.current = { a: [], b: [], tick: false };
214
+ }
215
+
216
+ // Single per-frame worklet: project all markers, then emit a transform +
217
+ // source rect for each visible atlas marker. Three mappers total regardless
218
+ // of marker count (this + the two thin readers below).
219
+ const atlasData = useDerivedValue(
220
+ () => {
221
+ const ms = markers.get();
222
+ const proj = projRef.current!;
223
+ proj.tick = !proj.tick;
224
+ const buf = proj.tick ? proj.a : proj.b;
225
+ projectMarkers(ms, buf, {
226
+ canvasWidth: engine.canvasWidth.get(),
227
+ canvasHeight: engine.canvasHeight.get(),
228
+ padTop: padding.top,
229
+ padBottom: padding.bottom,
230
+ padLeft: padding.left,
231
+ padRight: padding.right,
232
+ timestamp: engine.timestamp.get(),
233
+ displayWindow: engine.displayWindow.get(),
234
+ displayMin: engine.displayMin.get(),
235
+ displayMax: engine.displayMax.get(),
236
+ series: series?.get(),
237
+ lineData: lineData?.get(),
238
+ });
239
+ const transforms = [];
240
+ const sprites = [];
241
+ for (let i = 0; i < ms.length; i++) {
242
+ const pt = buf[i];
243
+ if (!pt.visible) continue;
244
+ const m = ms[i];
245
+ if (isConnectorMarker(m)) continue;
246
+ const cell = cells[markerAppearanceSig(m)];
247
+ if (!cell) continue;
248
+ // Center the cell on the projected point (RSXform: scale 1, no rotation).
249
+ transforms.push(
250
+ Skia.RSXform(1, 0, pt.x - cell.w / 2, pt.y - cell.h / 2),
251
+ );
252
+ sprites.push(cell.rect);
253
+ }
254
+ return { transforms, sprites };
255
+ },
256
+ [cells, markers, engine, padding, series, lineData],
257
+ );
258
+ const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
259
+ const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
260
+
307
261
  const axisY = useDerivedValue(
308
262
  () => engine.canvasHeight.get() - padding.bottom,
309
263
  );
310
264
 
311
- const bgColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
265
+ const connectors = snapshot.filter(isConnectorMarker);
312
266
 
313
267
  return (
314
268
  <Group>
315
- {snapshot.map((m) => (
316
- <MarkerGlyph
269
+ {atlasImage && (
270
+ <Atlas image={atlasImage} sprites={sprites} transforms={transforms} />
271
+ )}
272
+ {connectors.map((m) => (
273
+ <ConnectorGlyph
317
274
  key={m.id}
318
275
  marker={m}
319
276
  color={m.color ?? defaultMarkerColor(m.kind, palette)}
320
- bgColor={bgColor}
321
277
  engine={engine}
322
278
  padding={padding}
323
279
  seriesSV={series}
324
280
  lineDataSV={lineData}
325
- font={font}
326
281
  axisY={axisY}
327
282
  />
328
283
  ))}
@@ -8,7 +8,7 @@ import {
8
8
  } from "@shopify/react-native-skia";
9
9
  import { useRef } from "react";
10
10
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
11
- import { BADGE_DOT_GAP } from "../constants";
11
+ import { BADGE_METRICS_DEFAULTS } from "../constants";
12
12
  import type { ResolvedGridStyleConfig } from "../core/resolveConfig";
13
13
  import type { YAxisEntry } from "../draw/grid";
14
14
  import {
@@ -19,7 +19,7 @@ import {
19
19
  type ChartPadding,
20
20
  } from "../draw/line";
21
21
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
22
- import type { LiveChartPalette } from "../types";
22
+ import type { BadgeMetrics, LiveChartPalette } from "../types";
23
23
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
24
24
  import { AnimatedLabel } from "./AnimatedLabel";
25
25
 
@@ -33,6 +33,7 @@ export function YAxisOverlay({
33
33
  font,
34
34
  badge = false,
35
35
  badgeTail = true,
36
+ badgeMetrics = BADGE_METRICS_DEFAULTS,
36
37
  seriesLabelInset = 0,
37
38
  gridStyle,
38
39
  }: {
@@ -45,6 +46,8 @@ export function YAxisOverlay({
45
46
  badge?: boolean;
46
47
  /** Whether the badge tail spike is shown; affects the left inset used for label alignment. */
47
48
  badgeTail?: boolean;
49
+ /** Badge pill geometry tokens (kept in sync with useBadge). */
50
+ badgeMetrics?: BadgeMetrics;
48
51
  /** When > 0, series labels occupy the left portion of the gutter; Y-axis labels right-align. */
49
52
  seriesLabelInset?: number;
50
53
  /** Grid-line styling overrides. Omit for the legacy solid 1px line. */
@@ -81,7 +84,8 @@ export function YAxisOverlay({
81
84
  return path;
82
85
  });
83
86
 
84
- const leftInset = BADGE_DOT_GAP + badgeTailAndCap(font.getSize(), badgeTail);
87
+ const leftInset =
88
+ badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
85
89
 
86
90
  const labelEntries = useDerivedValue(() => {
87
91
  const items = entries.get();
@@ -93,7 +97,7 @@ export function YAxisOverlay({
93
97
  const e = items[i];
94
98
  const textW = measureFontTextWidth(font, e.label);
95
99
  const x = badge
96
- ? pillTextLeftX(w, padding.right, leftInset, textW)
100
+ ? pillTextLeftX(w, padding.right, leftInset, textW, badgeMetrics)
97
101
  : seriesLabelInset > 0
98
102
  ? gutterRightAlignedTextLeftX(w, textW)
99
103
  : gutterCenteredTextLeftX(w, padding.right, textW);
package/src/constants.ts CHANGED
@@ -1,19 +1,66 @@
1
+ import type {
2
+ BadgeMetrics,
3
+ CandleMetrics,
4
+ EmptyStateMetrics,
5
+ GridMetrics,
6
+ MotionMetrics,
7
+ } from "./types";
8
+
1
9
  /** Milliseconds per frame at 60 fps — baseline for frame-rate-independent lerp. */
2
10
  export const MS_PER_FRAME_60FPS = 16.67;
3
11
 
4
- /** Extra speed applied to catch up when the live dot lags behind the target. */
5
- export const ADAPTIVE_SPEED_BOOST = 0.12;
12
+ // ─── Metric default tokens (single source of truth for LiveChartMetrics) ─────
13
+ // The resolved `metrics` config (see resolveMetrics) is assembled from these
14
+ // objects. Draw/worklet helpers default their metric params to the matching
15
+ // object so call sites that don't thread metrics keep the built-in behavior.
16
+
17
+ /** Default value-badge pill geometry. */
18
+ export const BADGE_METRICS_DEFAULTS: BadgeMetrics = {
19
+ padX: 10,
20
+ padY: 3,
21
+ tailLength: 5,
22
+ marginEdge: 4,
23
+ dotGap: 12,
24
+ tailSpread: 2.5,
25
+ };
26
+
27
+ /** Default candlestick body/wick geometry. */
28
+ export const CANDLE_METRICS_DEFAULTS: CandleMetrics = {
29
+ minBodyPx: 1,
30
+ maxBodyPx: 40,
31
+ bodyWidthRatio: 0.8,
32
+ };
33
+
34
+ /** Default grid + axis-label fade speeds. */
35
+ export const GRID_METRICS_DEFAULTS: GridMetrics = {
36
+ fadeInSpeed: 0.18,
37
+ fadeOutSpeed: 0.12,
38
+ };
6
39
 
40
+ /** Default per-frame lerp speeds for value/color transitions. */
41
+ export const MOTION_METRICS_DEFAULTS: MotionMetrics = {
42
+ badgeColorSpeed: 0.08,
43
+ adaptiveSpeedBoost: 0.12,
44
+ };
45
+
46
+ /** Default empty-state (no-data) layout. */
47
+ export const EMPTY_STATE_METRICS_DEFAULTS: EmptyStateMetrics = {
48
+ labelOpacity: 0.35,
49
+ gapPad: 20,
50
+ gapFadeWidth: 30,
51
+ };
52
+
53
+ // Back-compat scalar aliases — canonical source is BADGE_METRICS_DEFAULTS above.
7
54
  /** Horizontal padding inside the badge pill, on each side of the label. */
8
- export const BADGE_PILL_PAD_X = 10;
55
+ export const BADGE_PILL_PAD_X = BADGE_METRICS_DEFAULTS.padX;
9
56
  /** Vertical padding above and below the label inside the badge pill. */
10
- export const BADGE_PILL_PAD_Y = 3;
57
+ export const BADGE_PILL_PAD_Y = BADGE_METRICS_DEFAULTS.padY;
11
58
  /** Length of the badge tail (the pointed spike toward the dot). */
12
- export const BADGE_TAIL_LEN = 5;
59
+ export const BADGE_TAIL_LEN = BADGE_METRICS_DEFAULTS.tailLength;
13
60
  /** Gap between the pill's right edge and the canvas right edge. */
14
- export const BADGE_MARGIN_RIGHT = 4;
61
+ export const BADGE_MARGIN_RIGHT = BADGE_METRICS_DEFAULTS.marginEdge;
15
62
  /** Gap between the live dot and the badge tail tip. */
16
- export const BADGE_DOT_GAP = 12;
63
+ export const BADGE_DOT_GAP = BADGE_METRICS_DEFAULTS.dotGap;
17
64
 
18
65
  /** Maximum simultaneous series rendered (paths/dots slots). */
19
66
  export const MAX_MULTI_SERIES = 12;
@@ -21,13 +68,6 @@ export const MAX_MULTI_SERIES = 12;
21
68
  /** Default width (px) of the left-edge fade band */
22
69
  export const FADE_EDGE_WIDTH = 40;
23
70
 
24
- /** Empty-state label opacity. */
25
- export const EMPTY_STATE_LABEL_ALPHA = 0.35;
26
- /** Half-padding (px) around empty text for the squiggle “gap” erase band. */
27
- export const EMPTY_TEXT_GAP_PAD = 20;
28
- /** Horizontal fade width (px) on each side of the empty-text gap. */
29
- export const EMPTY_GAP_FADE_WIDTH = 30;
30
-
31
71
  /**
32
72
  * Floats per degen particle slot: `x, y, vx, vy, t0, active, size, colorIndex`.
33
73
  * `colorIndex` selects which entry of the renderer's color list a particle uses
@@ -1,6 +1,6 @@
1
1
  import type { CandlePoint, LiveChartPoint } from "../types";
2
2
 
3
- import { ADAPTIVE_SPEED_BOOST } from "../constants";
3
+ import { MOTION_METRICS_DEFAULTS } from "../constants";
4
4
  import { lerp } from "../math/lerp";
5
5
 
6
6
  export interface EngineTickMutable {
@@ -18,6 +18,8 @@ export interface EngineTickInput {
18
18
  timeWindow: number;
19
19
  smoothing: number;
20
20
  exaggerate: boolean;
21
+ /** Extra catch-up speed added to `smoothing` when the live value lags. Default `0.12`. */
22
+ adaptiveSpeedBoost?: number;
21
23
  referenceValue: number | undefined;
22
24
  /** Additional reference values (lines + bands) folded into the Y range. */
23
25
  referenceValues?: number[];
@@ -68,7 +70,10 @@ export function tickLiveChartEngineFrame(
68
70
  const range = state.displayMax - state.displayMin;
69
71
  const gapRatio =
70
72
  range > 0 ? Math.min(Math.abs(target - state.displayValue) / range, 1) : 0;
71
- const adaptiveSpeed = speed + (1 - gapRatio) * ADAPTIVE_SPEED_BOOST;
73
+ const adaptiveSpeed =
74
+ speed +
75
+ (1 - gapRatio) *
76
+ (input.adaptiveSpeedBoost ?? MOTION_METRICS_DEFAULTS.adaptiveSpeedBoost);
72
77
 
73
78
  state.displayValue = lerp(
74
79
  state.displayValue,
@@ -1,4 +1,4 @@
1
- import { ADAPTIVE_SPEED_BOOST } from "../constants";
1
+ import { MOTION_METRICS_DEFAULTS } from "../constants";
2
2
  import { lerp } from "../math/lerp";
3
3
  import type { SeriesConfig } from "../types";
4
4
 
@@ -18,6 +18,8 @@ export interface MultiEngineTickInput {
18
18
  timeWindow: number;
19
19
  smoothing: number;
20
20
  exaggerate: boolean;
21
+ /** Extra catch-up speed added to `smoothing` when a series tip lags. Default `0.12`. */
22
+ adaptiveSpeedBoost?: number;
21
23
  referenceValue: number | undefined;
22
24
  /** Additional reference values (lines + bands) folded into the Y range. */
23
25
  referenceValues?: number[];
@@ -80,7 +82,11 @@ export function tickLiveChartSeriesEngineFrame(
80
82
  const cur = state.displayValues[i];
81
83
  const gapRatio =
82
84
  range > 0 ? Math.min(Math.abs(target - cur) / range, 1) : 0;
83
- const adaptiveSpeed = speed + (1 - gapRatio) * ADAPTIVE_SPEED_BOOST;
85
+ const adaptiveSpeed =
86
+ speed +
87
+ (1 - gapRatio) *
88
+ (input.adaptiveSpeedBoost ??
89
+ MOTION_METRICS_DEFAULTS.adaptiveSpeedBoost);
84
90
  state.displayValues[i] = lerp(cur, target, adaptiveSpeed, input.dt);
85
91
 
86
92
  const targetOp = series[i].visible !== false ? 1 : 0;