react-native-livechart 3.12.0 → 4.1.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 (140) hide show
  1. package/dist/components/BadgeOverlay.d.ts +9 -1
  2. package/dist/components/BadgeOverlay.d.ts.map +1 -1
  3. package/dist/components/ChartOverlayLayer.d.ts +19 -0
  4. package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
  5. package/dist/components/CustomMarkerOverlay.d.ts +16 -6
  6. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
  8. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
  9. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  10. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/DotOverlay.d.ts +7 -1
  12. package/dist/components/DotOverlay.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts +6 -3
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/MultiSeriesDots.d.ts +4 -1
  18. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
  20. package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
  21. package/dist/components/ReferenceLineOverlay.d.ts +25 -2
  22. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  23. package/dist/components/XAxisOverlay.d.ts +7 -1
  24. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  25. package/dist/components/YAxisOverlay.d.ts +12 -1
  26. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  27. package/dist/constants.d.ts +13 -0
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/core/liveChartEngineTick.d.ts +29 -0
  30. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  31. package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
  32. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  33. package/dist/core/resolveConfig.d.ts +62 -2
  34. package/dist/core/resolveConfig.d.ts.map +1 -1
  35. package/dist/core/useLiveChartEngine.d.ts +58 -7
  36. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  37. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
  38. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  39. package/dist/draw/grid.d.ts +13 -1
  40. package/dist/draw/grid.d.ts.map +1 -1
  41. package/dist/draw/markerAtlas.d.ts +20 -2
  42. package/dist/draw/markerAtlas.d.ts.map +1 -1
  43. package/dist/draw/volume.d.ts +25 -0
  44. package/dist/draw/volume.d.ts.map +1 -0
  45. package/dist/hooks/crosshairShared.d.ts +12 -6
  46. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  47. package/dist/hooks/overlayScale.d.ts +22 -0
  48. package/dist/hooks/overlayScale.d.ts.map +1 -0
  49. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  50. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  51. package/dist/hooks/useBadge.d.ts +22 -1
  52. package/dist/hooks/useBadge.d.ts.map +1 -1
  53. package/dist/hooks/useCandlePaths.d.ts +6 -0
  54. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  55. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  56. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  57. package/dist/hooks/useChartPaths.d.ts +8 -1
  58. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  59. package/dist/hooks/useCrosshair.d.ts +9 -2
  60. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  61. package/dist/hooks/useLiveDot.d.ts +6 -1
  62. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  63. package/dist/hooks/useMarkers.d.ts +9 -4
  64. package/dist/hooks/useMarkers.d.ts.map +1 -1
  65. package/dist/hooks/usePanScroll.d.ts +82 -0
  66. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  67. package/dist/hooks/usePinchZoom.d.ts +74 -0
  68. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  69. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  70. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  71. package/dist/hooks/useReferenceLine.d.ts +23 -4
  72. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  74. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  75. package/dist/hooks/useVisibleRange.d.ts +39 -0
  76. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  77. package/dist/hooks/useYAxis.d.ts +1 -1
  78. package/dist/hooks/useYAxis.d.ts.map +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/math/markerCluster.d.ts +47 -0
  82. package/dist/math/markerCluster.d.ts.map +1 -0
  83. package/dist/math/markers.d.ts +10 -0
  84. package/dist/math/markers.d.ts.map +1 -1
  85. package/dist/math/referenceDrag.d.ts +24 -0
  86. package/dist/math/referenceDrag.d.ts.map +1 -0
  87. package/dist/math/referenceGroup.d.ts +31 -0
  88. package/dist/math/referenceGroup.d.ts.map +1 -0
  89. package/dist/math/referenceLines.d.ts +33 -7
  90. package/dist/math/referenceLines.d.ts.map +1 -1
  91. package/dist/types.d.ts +524 -21
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/components/BadgeOverlay.tsx +26 -1
  95. package/src/components/ChartOverlayLayer.tsx +32 -0
  96. package/src/components/CustomMarkerOverlay.tsx +150 -57
  97. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  98. package/src/components/CustomTooltipOverlay.tsx +27 -7
  99. package/src/components/DotOverlay.tsx +9 -0
  100. package/src/components/LiveChart.tsx +550 -32
  101. package/src/components/LiveChartSeries.tsx +111 -5
  102. package/src/components/MarkerOverlay.tsx +92 -6
  103. package/src/components/MultiSeriesDots.tsx +12 -3
  104. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  105. package/src/components/ReferenceLineOverlay.tsx +105 -32
  106. package/src/components/XAxisOverlay.tsx +9 -2
  107. package/src/components/YAxisOverlay.tsx +49 -28
  108. package/src/constants.ts +17 -0
  109. package/src/core/liveChartEngineTick.ts +81 -8
  110. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  111. package/src/core/resolveConfig.ts +133 -2
  112. package/src/core/useLiveChartEngine.ts +111 -11
  113. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  114. package/src/draw/grid.ts +71 -2
  115. package/src/draw/markerAtlas.ts +117 -3
  116. package/src/draw/volume.ts +190 -0
  117. package/src/hooks/crosshairShared.ts +37 -9
  118. package/src/hooks/overlayScale.ts +59 -0
  119. package/src/hooks/resolveChartLayout.ts +33 -0
  120. package/src/hooks/useBadge.ts +89 -21
  121. package/src/hooks/useCandlePaths.ts +104 -7
  122. package/src/hooks/useChartOverlayContext.ts +93 -0
  123. package/src/hooks/useChartPaths.ts +14 -1
  124. package/src/hooks/useCrosshair.ts +33 -2
  125. package/src/hooks/useLiveDot.ts +16 -5
  126. package/src/hooks/useMarkers.ts +39 -11
  127. package/src/hooks/usePanScroll.ts +253 -0
  128. package/src/hooks/usePinchZoom.ts +189 -0
  129. package/src/hooks/useReferenceDrag.ts +254 -0
  130. package/src/hooks/useReferenceLine.ts +56 -9
  131. package/src/hooks/useReferenceLinePress.ts +16 -2
  132. package/src/hooks/useVisibleRange.ts +119 -0
  133. package/src/hooks/useYAxis.ts +2 -0
  134. package/src/index.ts +17 -1
  135. package/src/math/markerCluster.ts +214 -0
  136. package/src/math/markers.ts +35 -3
  137. package/src/math/referenceDrag.ts +66 -0
  138. package/src/math/referenceGroup.ts +65 -0
  139. package/src/math/referenceLines.ts +76 -19
  140. package/src/types.ts +544 -21
@@ -6,14 +6,16 @@ import {
6
6
  Text as SkiaText,
7
7
  type SkFont,
8
8
  } from "@shopify/react-native-skia";
9
- import { useDerivedValue } from "react-native-reanimated";
9
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
10
10
 
11
11
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
12
12
  import type { ChartPadding } from "../draw/line";
13
+ import { useChartSkiaFont } from "../hooks/useChartSkiaFont";
13
14
  import { usePathBuilder } from "../hooks/usePathBuilder";
14
15
  import { useReferenceLine } from "../hooks/useReferenceLine";
16
+ import { MONO_FONT_FAMILY } from "../lib/monoFontFamily";
15
17
  import { referenceLineForm, resolveReferenceBadge } from "../math/referenceLines";
16
- import type { LiveChartPalette, ReferenceLine } from "../types";
18
+ import type { FontConfig, LiveChartPalette, ReferenceLine } from "../types";
17
19
 
18
20
  /** Translucent fill alpha for value / time bands. */
19
21
  const BAND_FILL_OPACITY = 0.16;
@@ -35,7 +37,12 @@ export function ReferenceLineOverlay({
35
37
  palette,
36
38
  formatValue,
37
39
  font,
40
+ fontProp,
38
41
  badgeLayer = false,
42
+ suppressTag = false,
43
+ groupHidden,
44
+ dragValues,
45
+ index = 0,
39
46
  }: {
40
47
  engine: ChartEngineLayout;
41
48
  padding: ChartPadding;
@@ -43,6 +50,12 @@ export function ReferenceLineOverlay({
43
50
  palette: LiveChartPalette;
44
51
  formatValue: (v: number) => string;
45
52
  font: SkFont;
53
+ /**
54
+ * The chart's raw font config — used to build a per-badge font when the line's
55
+ * {@link ReferenceLineBadgeConfig} sets `fontSize` / `fontFamily` / `fontWeight`.
56
+ * When no badge font knob is set, the resolved `font` is used as-is.
57
+ */
58
+ fontProp?: FontConfig;
46
59
  /**
47
60
  * Render only the badge + label (`true`) or only the lines / bands (`false`,
48
61
  * default). The caller draws the base pass behind the chart content and the
@@ -50,13 +63,65 @@ export function ReferenceLineOverlay({
50
63
  * of being erased by the fade's `dstOut` blend.
51
64
  */
52
65
  badgeLayer?: boolean;
66
+ /**
67
+ * Suppress the built-in tag (badge pill + connector + chevron + icon + gutter
68
+ * label) for this line — used when a custom `renderReferenceLine` element owns
69
+ * the tag. The line / band stroke still draws. No effect on the base pass.
70
+ */
71
+ suppressTag?: boolean;
72
+ /**
73
+ * Per-frame grouping flags (index-aligned): when this line's slot is `true` it's
74
+ * collapsed into a group count handle, so its tag is suppressed (the line / band
75
+ * stroke still draws).
76
+ */
77
+ groupHidden?: SharedValue<boolean[]>;
78
+ /** Per-line live value overrides (dragged values), index-aligned with `referenceLines`. */
79
+ dragValues?: SharedValue<number[]>;
80
+ /** This line's index into {@link dragValues}. */
81
+ index?: number;
53
82
  }) {
54
83
  const form = referenceLineForm(line);
55
84
  const isBand = form === "value-band" || form === "time-band";
56
- const layout = useReferenceLine(engine, padding, line, formatValue, font);
57
85
 
58
86
  const color = line.color ?? palette.refLine;
59
- const labelColor = line.labelColor ?? line.color ?? palette.refLabel;
87
+
88
+ // Resolved badge appearance (badge config → fallback flat fields → theme).
89
+ const badge = resolveReferenceBadge(line);
90
+
91
+ // Per-badge font (size/family/weight) override; reuses the chart `font` when no
92
+ // badge font knob is set, so plain lines and gutter labels are unchanged. The
93
+ // built font drives pill measurement (via useReferenceLine) and the badge text.
94
+ const badgeHasFontOverride =
95
+ badge != null &&
96
+ (badge.fontSize != null ||
97
+ badge.fontFamily != null ||
98
+ badge.fontWeight != null);
99
+ const badgeFontOverride = useChartSkiaFont(
100
+ badgeHasFontOverride
101
+ ? {
102
+ ...fontProp,
103
+ fontFamily: badge?.fontFamily ?? fontProp?.fontFamily,
104
+ fontSize: badge?.fontSize ?? fontProp?.fontSize,
105
+ fontWeight: badge?.fontWeight ?? fontProp?.fontWeight,
106
+ }
107
+ : fontProp,
108
+ MONO_FONT_FAMILY,
109
+ palette.labelFontSize,
110
+ );
111
+ const badgeFont = badgeHasFontOverride ? badgeFontOverride : font;
112
+
113
+ const layout = useReferenceLine(
114
+ engine,
115
+ padding,
116
+ line,
117
+ formatValue,
118
+ badgeFont,
119
+ dragValues,
120
+ index,
121
+ );
122
+
123
+ const labelColor =
124
+ badge?.textColor ?? line.labelColor ?? line.color ?? palette.refLabel;
60
125
  const strokeWidth = line.strokeWidth ?? 1;
61
126
  const intervals = line.intervals ?? [4, 4];
62
127
 
@@ -64,11 +129,17 @@ export function ReferenceLineOverlay({
64
129
  const bandFillOpacity = line.fillOpacity ?? BAND_FILL_OPACITY;
65
130
  const hasBandBorder = isBand && line.strokeWidth !== undefined;
66
131
 
67
- // Resolved badge appearance (badge config → fallback flat fields → theme).
68
- const badge = resolveReferenceBadge(line);
69
132
  const badgeBackground = badge?.background ?? palette.tooltipBg;
70
133
  const badgeBorderColor = badge?.borderColor ?? color;
134
+ const badgeBorderWidth = badge?.borderWidth ?? 1;
71
135
  const badgeRadius = badge?.radius ?? BADGE_PILL_RADIUS;
136
+ // Nudge the whole badge (connector + pill + chevron + icon + label) off its anchor.
137
+ const badgeOffsetX = badge?.offsetX ?? 0;
138
+ const badgeOffsetY = badge?.offsetY ?? 0;
139
+ const badgeTransform =
140
+ badgeOffsetX !== 0 || badgeOffsetY !== 0
141
+ ? [{ translateX: badgeOffsetX }, { translateY: badgeOffsetY }]
142
+ : undefined;
72
143
 
73
144
  const lineBuilder = usePathBuilder();
74
145
  const bandBuilder = usePathBuilder();
@@ -79,11 +150,11 @@ export function ReferenceLineOverlay({
79
150
  const linePath = useDerivedValue(() => {
80
151
  const b = lineBuilder.value;
81
152
  const l = layout.get();
82
- // A plain full-width line. A badge instead draws a pill + a connector to the
83
- // opposite edge, below.
84
- if (l.visible && !l.badge && !isBand) {
85
- b.moveTo(l.x1, l.y);
86
- b.lineTo(l.x2, l.y);
153
+ // A plain horizontal line, drawn at the line extent (full-width when opted in).
154
+ // A non-full-width badge instead draws a pill + connector to the edge, below.
155
+ if (l.visible && l.drawLine && !isBand) {
156
+ b.moveTo(l.lineX1, l.y);
157
+ b.lineTo(l.lineX2, l.y);
87
158
  }
88
159
  return b.detach();
89
160
  });
@@ -92,10 +163,10 @@ export function ReferenceLineOverlay({
92
163
  const b = bandBuilder.value;
93
164
  const l = layout.get();
94
165
  if (l.visible && isBand) {
95
- b.moveTo(l.x1, l.y);
96
- b.lineTo(l.x2, l.y);
97
- b.lineTo(l.x2, l.yBottom);
98
- b.lineTo(l.x1, l.yBottom);
166
+ b.moveTo(l.lineX1, l.y);
167
+ b.lineTo(l.lineX2, l.y);
168
+ b.lineTo(l.lineX2, l.yBottom);
169
+ b.lineTo(l.lineX1, l.yBottom);
99
170
  b.close();
100
171
  }
101
172
  return b.detach();
@@ -107,16 +178,16 @@ export function ReferenceLineOverlay({
107
178
  if (l.visible && hasBandBorder) {
108
179
  if (form === "time-band") {
109
180
  // Vertical edges at the band's left / right.
110
- b.moveTo(l.x1, l.y);
111
- b.lineTo(l.x1, l.yBottom);
112
- b.moveTo(l.x2, l.y);
113
- b.lineTo(l.x2, l.yBottom);
181
+ b.moveTo(l.lineX1, l.y);
182
+ b.lineTo(l.lineX1, l.yBottom);
183
+ b.moveTo(l.lineX2, l.y);
184
+ b.lineTo(l.lineX2, l.yBottom);
114
185
  } else {
115
186
  // Horizontal edges at the band's top / bottom.
116
- b.moveTo(l.x1, l.y);
117
- b.lineTo(l.x2, l.y);
118
- b.moveTo(l.x1, l.yBottom);
119
- b.lineTo(l.x2, l.yBottom);
187
+ b.moveTo(l.lineX1, l.y);
188
+ b.lineTo(l.lineX2, l.y);
189
+ b.moveTo(l.lineX1, l.yBottom);
190
+ b.lineTo(l.lineX2, l.yBottom);
120
191
  }
121
192
  }
122
193
  return b.detach();
@@ -155,7 +226,7 @@ export function ReferenceLineOverlay({
155
226
 
156
227
  const lineOpacity = useDerivedValue(() => {
157
228
  const l = layout.get();
158
- return l.visible && !l.badge && !isBand ? 1 : 0;
229
+ return l.visible && l.drawLine && !isBand ? 1 : 0;
159
230
  });
160
231
  const bandOpacity = useDerivedValue(() =>
161
232
  layout.get().visible && isBand ? bandFillOpacity : 0,
@@ -165,12 +236,14 @@ export function ReferenceLineOverlay({
165
236
  );
166
237
  const badgeOpacity = useDerivedValue(() => {
167
238
  const l = layout.get();
168
- return l.visible && l.badge ? 1 : 0;
239
+ const grouped = groupHidden ? groupHidden.get()[index] === true : false;
240
+ return !suppressTag && !grouped && l.visible && l.badge ? 1 : 0;
169
241
  });
170
242
  // Text + icon ride in the badge pass too, so they stay crisp above the fade.
171
243
  const labelOpacity = useDerivedValue(() => {
172
244
  const l = layout.get();
173
- return l.visible && l.label.length > 0 ? 1 : 0;
245
+ const grouped = groupHidden ? groupHidden.get()[index] === true : false;
246
+ return !suppressTag && !grouped && l.visible && l.label.length > 0 ? 1 : 0;
174
247
  });
175
248
  const iconOpacity = useDerivedValue(() => {
176
249
  const l = layout.get();
@@ -186,7 +259,7 @@ export function ReferenceLineOverlay({
186
259
  // Font metrics depend only on the (stable) font, so read them once instead of
187
260
  // on every frame inside the pill worklet (`getMetrics` allocates + crosses JSI).
188
261
  const { ascent: fontAscent, height: pillH } = (() => {
189
- const fm = font.getMetrics();
262
+ const fm = badgeFont.getMetrics();
190
263
  return {
191
264
  ascent: fm.ascent,
192
265
  height: fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2,
@@ -238,7 +311,7 @@ export function ReferenceLineOverlay({
238
311
 
239
312
  {/* Badge pass — connector + pill + chevron + icon, above the left-edge fade. */}
240
313
  {badgeLayer && (
241
- <Group opacity={badgeOpacity}>
314
+ <Group opacity={badgeOpacity} transform={badgeTransform}>
242
315
  <Path
243
316
  path={connPath}
244
317
  style="stroke"
@@ -263,7 +336,7 @@ export function ReferenceLineOverlay({
263
336
  r={badgeRadius}
264
337
  color={badgeBorderColor}
265
338
  style="stroke"
266
- strokeWidth={1}
339
+ strokeWidth={badgeBorderWidth}
267
340
  />
268
341
  <Path
269
342
  path={chevronPath}
@@ -278,7 +351,7 @@ export function ReferenceLineOverlay({
278
351
  x={iconX}
279
352
  y={labelY}
280
353
  text={iconText}
281
- font={font}
354
+ font={badgeFont}
282
355
  color={labelColor}
283
356
  />
284
357
  </Group>
@@ -287,12 +360,12 @@ export function ReferenceLineOverlay({
287
360
 
288
361
  {/* Labels ride in the badge pass too, so they stay crisp above the fade. */}
289
362
  {badgeLayer && (
290
- <Group opacity={labelOpacity}>
363
+ <Group opacity={labelOpacity} transform={badgeTransform}>
291
364
  <SkiaText
292
365
  x={labelX}
293
366
  y={labelY}
294
367
  text={labelText}
295
- font={font}
368
+ font={badgeFont}
296
369
  color={labelColor}
297
370
  />
298
371
  </Group>
@@ -19,12 +19,19 @@ export function XAxisOverlay({
19
19
  padding,
20
20
  palette,
21
21
  font,
22
+ volumeBandHeight = 0,
22
23
  }: {
23
24
  entries: SharedValue<XAxisEntry[]>;
24
25
  engine: ChartEngineLayout;
25
26
  padding: ChartPadding;
26
27
  palette: LiveChartPalette;
27
28
  font: SkFont;
29
+ /**
30
+ * Reserved volume-band height (px) folded into `padding.bottom`. The axis line
31
+ * + labels shift back down by this so they stay at the very bottom, below the
32
+ * band, while the price plot above shrinks. `0` = no band (default).
33
+ */
34
+ volumeBandHeight?: number;
28
35
  }) {
29
36
  const axisBuilder = usePathBuilder();
30
37
 
@@ -33,7 +40,7 @@ export function XAxisOverlay({
33
40
  const b = axisBuilder.value;
34
41
  const w = engine.canvasWidth.get();
35
42
  const h = engine.canvasHeight.get();
36
- const lineY = h - padding.bottom;
43
+ const lineY = h - padding.bottom + volumeBandHeight;
37
44
 
38
45
  // Bottom axis line
39
46
  b.moveTo(padding.left, lineY);
@@ -53,7 +60,7 @@ export function XAxisOverlay({
53
60
  "worklet";
54
61
  const items = entries.get();
55
62
  const h = engine.canvasHeight.get();
56
- const y = h - padding.bottom + LABEL_OFFSET_Y;
63
+ const y = h - padding.bottom + volumeBandHeight + LABEL_OFFSET_Y;
57
64
  const n = items.length;
58
65
  const out: { x: number; y: number; label: string; alpha: number }[] = [];
59
66
  for (let i = 0; i < n; i++) {
@@ -5,7 +5,7 @@ import {
5
5
  type SkFont,
6
6
  } from "@shopify/react-native-skia";
7
7
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
8
- import { BADGE_METRICS_DEFAULTS } from "../constants";
8
+ import { BADGE_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
9
9
  import type { ResolvedGridStyleConfig } from "../core/resolveConfig";
10
10
  import type { YAxisEntry } from "../draw/grid";
11
11
  import {
@@ -21,7 +21,8 @@ import type { BadgeMetrics, LiveChartPalette } from "../types";
21
21
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
22
22
  import { AnimatedLabel } from "./AnimatedLabel";
23
23
 
24
- const MAX_Y_LABELS = 15;
24
+ /** Right margin (px) for floating labels — keeps them just off the canvas edge. */
25
+ const FLOAT_LABEL_RIGHT_MARGIN = 6;
25
26
 
26
27
  export function YAxisOverlay({
27
28
  entries,
@@ -34,6 +35,8 @@ export function YAxisOverlay({
34
35
  badgeMetrics = BADGE_METRICS_DEFAULTS,
35
36
  seriesLabelInset = 0,
36
37
  gridStyle,
38
+ variant = "all",
39
+ float = false,
37
40
  }: {
38
41
  entries: SharedValue<YAxisEntry[]>;
39
42
  engine: ChartEngineLayout;
@@ -50,6 +53,17 @@ export function YAxisOverlay({
50
53
  seriesLabelInset?: number;
51
54
  /** Grid-line styling overrides. Omit for the legacy solid 1px line. */
52
55
  gridStyle?: ResolvedGridStyleConfig;
56
+ /**
57
+ * Which parts to draw. `"all"` (default) renders grid lines + labels together.
58
+ * `"grid"` / `"labels"` split them so a chart can draw the grid behind the data
59
+ * and the labels (over a soft fade) on top — see {@link YAxisEdgeFade}.
60
+ */
61
+ variant?: "all" | "grid" | "labels";
62
+ /**
63
+ * Floating-axis mode: right-align labels at the canvas edge (over a full-width
64
+ * plot) instead of centering them in a reserved gutter. See {@link YAxisConfig.float}.
65
+ */
66
+ float?: boolean;
53
67
  }) {
54
68
  const gridColor = gridStyle?.color ?? palette.gridLine;
55
69
  const gridWidth = gridStyle?.strokeWidth ?? 1;
@@ -59,6 +73,7 @@ export function YAxisOverlay({
59
73
 
60
74
  const gridLinesPath = useDerivedValue(() => {
61
75
  const b = gridBuilder.value;
76
+ if (variant === "labels") return b.detach();
62
77
  const items = entries.get();
63
78
  const w = engine.canvasWidth.get();
64
79
  for (let i = 0; i < items.length; i++) {
@@ -72,6 +87,7 @@ export function YAxisOverlay({
72
87
  badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
73
88
 
74
89
  const labelEntries = useDerivedValue(() => {
90
+ if (variant === "grid") return [];
75
91
  const items = entries.get();
76
92
  const w = engine.canvasWidth.get();
77
93
  const fm = font.getMetrics();
@@ -80,11 +96,13 @@ export function YAxisOverlay({
80
96
  for (let i = 0; i < items.length; i++) {
81
97
  const e = items[i];
82
98
  const textW = measureFontTextWidth(font, e.label);
83
- const x = badge
84
- ? pillTextLeftX(w, padding.right, leftInset, textW, badgeMetrics)
85
- : seriesLabelInset > 0
86
- ? gutterRightAlignedTextLeftX(w, textW)
87
- : gutterCenteredTextLeftX(w, padding.right, textW);
99
+ const x = float
100
+ ? gutterRightAlignedTextLeftX(w, textW, FLOAT_LABEL_RIGHT_MARGIN)
101
+ : badge
102
+ ? pillTextLeftX(w, padding.right, leftInset, textW, badgeMetrics)
103
+ : seriesLabelInset > 0
104
+ ? gutterRightAlignedTextLeftX(w, textW)
105
+ : gutterCenteredTextLeftX(w, padding.right, textW);
88
106
  result.push({
89
107
  x,
90
108
  y: e.y - baselineOffset,
@@ -97,27 +115,30 @@ export function YAxisOverlay({
97
115
 
98
116
  return (
99
117
  <Group>
100
- <Group opacity={gridOpacity}>
101
- <Path
102
- path={gridLinesPath}
103
- style="stroke"
104
- strokeWidth={gridWidth}
105
- color={gridColor}
106
- >
107
- {gridIntervals.length > 0 && (
108
- <DashPathEffect intervals={gridIntervals} />
109
- )}
110
- </Path>
111
- </Group>
112
- {Array.from({ length: MAX_Y_LABELS }, (_, i) => (
113
- <AnimatedLabel
114
- key={i}
115
- entries={labelEntries}
116
- index={i}
117
- font={font}
118
- color={palette.gridLabel}
119
- />
120
- ))}
118
+ {variant !== "labels" && (
119
+ <Group opacity={gridOpacity}>
120
+ <Path
121
+ path={gridLinesPath}
122
+ style="stroke"
123
+ strokeWidth={gridWidth}
124
+ color={gridColor}
125
+ >
126
+ {gridIntervals.length > 0 && (
127
+ <DashPathEffect intervals={gridIntervals} />
128
+ )}
129
+ </Path>
130
+ </Group>
131
+ )}
132
+ {variant !== "grid" &&
133
+ Array.from({ length: MAX_Y_LABELS }, (_, i) => (
134
+ <AnimatedLabel
135
+ key={i}
136
+ entries={labelEntries}
137
+ index={i}
138
+ font={font}
139
+ color={palette.gridLabel}
140
+ />
141
+ ))}
121
142
  </Group>
122
143
  );
123
144
  }
package/src/constants.ts CHANGED
@@ -9,6 +9,21 @@ import type {
9
9
  /** Milliseconds per frame at 60 fps — baseline for frame-rate-independent lerp. */
10
10
  export const MS_PER_FRAME_60FPS = 16.67;
11
11
 
12
+ /**
13
+ * Size of the pre-allocated Y-axis label pool — the most price labels the axis
14
+ * ever renders at once. Caps both the dynamic (nice-interval) grid and the
15
+ * fixed-`count` mode (see {@link YAxisConfig.count}).
16
+ */
17
+ export const MAX_Y_LABELS = 15;
18
+
19
+ /**
20
+ * Default press-and-hold (ms) before scrub engages in the `holdToScrub`
21
+ * time-scroll mode, so a quick one-finger drag scrolls instead. Overridden by
22
+ * `timeScroll.scrubHoldMs`, then `scrub.panGestureDelay`. Shared by `LiveChart`
23
+ * and `LiveChartSeries`.
24
+ */
25
+ export const HOLD_TO_SCRUB_MS = 500;
26
+
12
27
  // ─── Metric default tokens (single source of truth for LiveChartMetrics) ─────
13
28
  // The resolved `metrics` config (see resolveMetrics) is assembled from these
14
29
  // objects. Draw/worklet helpers default their metric params to the matching
@@ -29,6 +44,8 @@ export const CANDLE_METRICS_DEFAULTS: CandleMetrics = {
29
44
  minBodyPx: 1,
30
45
  maxBodyPx: 40,
31
46
  bodyWidthRatio: 0.8,
47
+ bodyRadius: 0,
48
+ wickWidth: 1,
32
49
  };
33
50
 
34
51
  /** Default grid + axis-label fade speeds. */
@@ -9,6 +9,20 @@ export interface EngineTickMutable {
9
9
  displayMax: number;
10
10
  displayWindow: number;
11
11
  timestamp: number;
12
+ /**
13
+ * The right-edge time the engine would use if it were following live —
14
+ * `now (+ windowBuffer)`. Equals {@link timestamp} while following; when
15
+ * scrolled back in time (see {@link EngineTickInput.viewEnd}) it keeps
16
+ * advancing while `timestamp` stays frozen. Exposed so the pan-scroll gesture
17
+ * can clamp against the live edge and detect catch-up.
18
+ */
19
+ liveEdge: number;
20
+ /**
21
+ * Smoothed value at the visible window's right edge: the live value while
22
+ * following, or the price at `viewEnd` while scrolled back. Lets a badge track
23
+ * the last visible price as you pan (see `badge.followViewEdge`).
24
+ */
25
+ edgeValue: number;
12
26
  /**
13
27
  * Value + time of the lowest / highest data point in the visible window —
14
28
  * the actual extrema, NOT the smoothed display bounds (which carry margin and
@@ -48,6 +62,21 @@ export interface EngineTickInput {
48
62
  windowBuffer?: number;
49
63
  /** When true, freeze the viewport timestamp and skip displayWindow lerp */
50
64
  paused?: boolean;
65
+ /**
66
+ * Absolute right-edge time (unix seconds) to freeze the window at, or
67
+ * `null`/`undefined` to follow the live edge. Set by the pan-scroll gesture
68
+ * to scroll back in time; once it reaches (or passes) the live edge the engine
69
+ * resumes following. Takes precedence over {@link paused}.
70
+ */
71
+ viewEnd?: number | null;
72
+ /**
73
+ * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
74
+ * to follow the configured {@link timeWindow}. Set by the pinch-zoom gesture
75
+ * (see `usePinchZoom` / the `zoom` prop). The symmetric counterpart of
76
+ * {@link viewEnd}: `viewEnd` overrides the window's right edge, `viewWindow`
77
+ * overrides its width. `displayWindow` eases toward this when set.
78
+ */
79
+ viewWindow?: number | null;
51
80
  /** Chart mode — `"candle"` uses OHLC bars for Y range instead of line points. */
52
81
  mode?: "line" | "candle";
53
82
  /** Committed OHLC bars (sorted by time). Used when mode is `"candle"`. */
@@ -66,9 +95,18 @@ export function tickLiveChartEngineFrame(
66
95
  ): void {
67
96
  "worklet";
68
97
  const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000;
69
- if (!input.paused) {
70
- state.timestamp = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
98
+ const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
99
+ state.liveEdge = liveEdge;
100
+ const viewEnd = input.viewEnd;
101
+ if (viewEnd != null && viewEnd < liveEdge) {
102
+ // Scrolled back in time: freeze the right edge at an absolute timestamp so
103
+ // the window stops tracking "now" (see usePanScroll / the `timeScroll` prop).
104
+ state.timestamp = viewEnd;
105
+ } else if (!input.paused) {
106
+ // Following the live edge — viewEnd is null/undefined or has caught back up.
107
+ state.timestamp = liveEdge;
71
108
  }
109
+ // else: paused with no active pan → leave the frozen timestamp untouched.
72
110
 
73
111
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
74
112
 
@@ -93,12 +131,10 @@ export function tickLiveChartEngineFrame(
93
131
  input.dt,
94
132
  );
95
133
 
96
- state.displayWindow = lerp(
97
- state.displayWindow,
98
- input.timeWindow,
99
- speed,
100
- input.dt,
101
- );
134
+ // Pinch-zoom: ease toward the zoom override when set, else the configured
135
+ // window. Mirrors the viewEnd freeze above (width vs. right edge).
136
+ const targetWindow = input.viewWindow ?? input.timeWindow;
137
+ state.displayWindow = lerp(state.displayWindow, targetWindow, speed, input.dt);
102
138
 
103
139
  const winStart = state.timestamp - state.displayWindow;
104
140
 
@@ -232,4 +268,41 @@ export function tickLiveChartEngineFrame(
232
268
  state.displayMax = lerp(state.displayMax, tMax, speed, input.dt);
233
269
  }
234
270
  }
271
+
272
+ // Edge value: the price at the visible window's right edge. Following live →
273
+ // track the live value (badge unchanged). Scrolled back → track the last
274
+ // visible point/candle close, lerped so a `followViewEdge` badge glides to the
275
+ // last price as you pan.
276
+ const scrolledBack = viewEnd != null && viewEnd < liveEdge;
277
+ if (!scrolledBack) {
278
+ state.edgeValue = state.displayValue;
279
+ } else {
280
+ let edgeTarget = state.displayValue;
281
+ if (input.mode === "candle") {
282
+ const cs = input.candles;
283
+ if (cs && cs.length > 0) {
284
+ let elo = 0;
285
+ let ehi = cs.length;
286
+ while (elo < ehi) {
287
+ const m = (elo + ehi) >> 1;
288
+ if (cs[m].time <= state.timestamp) elo = m + 1;
289
+ else ehi = m;
290
+ }
291
+ if (elo > 0) edgeTarget = cs[elo - 1].close;
292
+ }
293
+ const lc = input.liveCandle;
294
+ if (lc && lc.time <= state.timestamp) edgeTarget = lc.close;
295
+ } else {
296
+ const pts = input.points;
297
+ let elo = 0;
298
+ let ehi = pts.length;
299
+ while (elo < ehi) {
300
+ const m = (elo + ehi) >> 1;
301
+ if (pts[m].time <= state.timestamp) elo = m + 1;
302
+ else ehi = m;
303
+ }
304
+ if (elo > 0) edgeTarget = pts[elo - 1].value;
305
+ }
306
+ state.edgeValue = lerp(state.edgeValue, edgeTarget, speed, input.dt);
307
+ }
235
308
  }
@@ -7,6 +7,12 @@ export interface MultiEngineTickMutable {
7
7
  displayMax: number;
8
8
  displayWindow: number;
9
9
  timestamp: number;
10
+ /**
11
+ * The right-edge time the engine would use if following live (`now (+ buffer)`).
12
+ * Equals {@link timestamp} while following; keeps advancing while `timestamp`
13
+ * stays frozen when scrolled back (see {@link MultiEngineTickInput.viewEnd}).
14
+ */
15
+ liveEdge: number;
10
16
  displayValues: number[];
11
17
  opacities: number[];
12
18
  /**
@@ -44,6 +50,18 @@ export interface MultiEngineTickInput {
44
50
  /** Right-edge buffer as a fraction of the time window. */
45
51
  windowBuffer?: number;
46
52
  paused?: boolean;
53
+ /**
54
+ * Absolute right-edge time (unix seconds) to freeze the window at, or
55
+ * `null`/`undefined` to follow the live edge. Drives pan-scroll; takes
56
+ * precedence over {@link paused}.
57
+ */
58
+ viewEnd?: number | null;
59
+ /**
60
+ * Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
61
+ * to follow {@link timeWindow}. Drives pinch-zoom (see `usePinchZoom`); the
62
+ * symmetric counterpart of {@link viewEnd}.
63
+ */
64
+ viewWindow?: number | null;
47
65
  }
48
66
 
49
67
  /**
@@ -57,9 +75,17 @@ export function tickLiveChartSeriesEngineFrame(
57
75
  ): void {
58
76
  "worklet";
59
77
  const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000;
60
- if (!input.paused) {
61
- state.timestamp = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
78
+ const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
79
+ state.liveEdge = liveEdge;
80
+ const viewEnd = input.viewEnd;
81
+ if (viewEnd != null && viewEnd < liveEdge) {
82
+ // Scrolled back in time: freeze the right edge (see usePanScroll).
83
+ state.timestamp = viewEnd;
84
+ } else if (!input.paused) {
85
+ // Following the live edge — viewEnd is null/undefined or has caught back up.
86
+ state.timestamp = liveEdge;
62
87
  }
88
+ // else: paused with no active pan → leave the frozen timestamp untouched.
63
89
 
64
90
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
65
91
 
@@ -77,18 +103,33 @@ export function tickLiveChartSeriesEngineFrame(
77
103
  state.opacities.length = n;
78
104
  }
79
105
 
80
- state.displayWindow = lerp(
81
- state.displayWindow,
82
- input.timeWindow,
83
- speed,
84
- input.dt,
85
- );
106
+ // Pinch-zoom: ease toward the zoom override when set, else the configured
107
+ // window (mirrors the single-series tick).
108
+ const targetWindow = input.viewWindow ?? input.timeWindow;
109
+ state.displayWindow = lerp(state.displayWindow, targetWindow, speed, input.dt);
86
110
 
87
111
  const winStart = state.timestamp - state.displayWindow;
88
112
  const range = state.displayMax - state.displayMin;
89
113
 
114
+ // Scrolled back in time (pan/zoom): the per-series tips/dots sit at the
115
+ // window's right edge, so they must track each series' value AT that edge
116
+ // (`timestamp`), not the live value — otherwise the dot floats at the current
117
+ // price while the line ends in the past. Mirrors single-series `edgeValue`.
118
+ const scrolledBack = viewEnd != null && viewEnd < liveEdge;
119
+
90
120
  for (let i = 0; i < n; i++) {
91
- const target = series[i].value;
121
+ let target = series[i].value;
122
+ if (scrolledBack) {
123
+ const pts = series[i].data;
124
+ let elo = 0;
125
+ let ehi = pts.length;
126
+ while (elo < ehi) {
127
+ const m = (elo + ehi) >> 1;
128
+ if (pts[m].time <= state.timestamp) elo = m + 1;
129
+ else ehi = m;
130
+ }
131
+ if (elo > 0) target = pts[elo - 1].value;
132
+ }
92
133
  const cur = state.displayValues[i];
93
134
  const gapRatio =
94
135
  range > 0 ? Math.min(Math.abs(target - cur) / range, 1) : 0;