react-native-livechart 4.12.0 → 4.13.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 (47) hide show
  1. package/dist/components/CrosshairLine.d.ts +12 -4
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +12 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  6. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  7. package/dist/components/LiveChart.d.ts.map +1 -1
  8. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  9. package/dist/components/PerSeriesTooltipOverlay.d.ts +3 -1
  10. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/ReferenceLineOverlay.d.ts +8 -1
  12. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  13. package/dist/components/YAxisOverlay.d.ts +6 -2
  14. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  15. package/dist/core/resolveConfig.d.ts +16 -0
  16. package/dist/core/resolveConfig.d.ts.map +1 -1
  17. package/dist/draw/grid.d.ts +13 -0
  18. package/dist/draw/grid.d.ts.map +1 -1
  19. package/dist/hooks/crosshairShared.d.ts +22 -3
  20. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  21. package/dist/hooks/useCrosshair.d.ts +7 -1
  22. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  23. package/dist/hooks/useCrosshairSeries.d.ts +6 -1
  24. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  25. package/dist/hooks/useCrosshairVisibleOpacity.d.ts +8 -0
  26. package/dist/hooks/useCrosshairVisibleOpacity.d.ts.map +1 -0
  27. package/dist/hooks/useReferenceLine.d.ts +10 -1
  28. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  29. package/dist/types.d.ts +47 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/components/CrosshairLine.tsx +39 -11
  33. package/src/components/CrosshairOverlay.tsx +49 -15
  34. package/src/components/CustomTooltipOverlay.tsx +15 -3
  35. package/src/components/LiveChart.tsx +25 -2
  36. package/src/components/LiveChartSeries.tsx +83 -16
  37. package/src/components/PerSeriesTooltipOverlay.tsx +4 -1
  38. package/src/components/ReferenceLineOverlay.tsx +14 -0
  39. package/src/components/YAxisOverlay.tsx +34 -9
  40. package/src/core/resolveConfig.ts +29 -0
  41. package/src/draw/grid.ts +33 -0
  42. package/src/hooks/crosshairShared.ts +81 -4
  43. package/src/hooks/useCrosshair.ts +46 -28
  44. package/src/hooks/useCrosshairSeries.ts +31 -4
  45. package/src/hooks/useCrosshairVisibleOpacity.ts +57 -0
  46. package/src/hooks/useReferenceLine.ts +26 -1
  47. package/src/types.ts +47 -0
@@ -55,6 +55,7 @@ import { useChartReveal } from "../hooks/useChartReveal";
55
55
  import { useChartOverlayContext } from "../hooks/useChartOverlayContext";
56
56
  import { useChartSkiaFont } from "../hooks/useChartSkiaFont";
57
57
  import { useCrosshairSeries } from "../hooks/useCrosshairSeries";
58
+ import { useCrosshairVisibleOpacity } from "../hooks/useCrosshairVisibleOpacity";
58
59
  import { useMarkers } from "../hooks/useMarkers";
59
60
  import { useMultiSeriesDegen } from "../hooks/useMultiSeriesDegen";
60
61
  import { useMultiSeriesLinePaths } from "../hooks/useMultiSeriesLinePaths";
@@ -463,6 +464,7 @@ function useLiveChartSeriesController({
463
464
  maxTime: tooltipMaxTime,
464
465
  }
465
466
  : undefined,
467
+ scrubCfg?.clampToPlot ?? false,
466
468
  );
467
469
 
468
470
  // Capture only the shared value in the worklets below. Referencing
@@ -712,6 +714,8 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
712
714
  badge={false}
713
715
  seriesLabelInset={seriesLabelInset}
714
716
  gridStyle={gridStyleCfg}
717
+ labelRightMargin={yAxisCfg.labelRightMargin}
718
+ gridEndGap={yAxisCfg.gridEndGap}
715
719
  />
716
720
  </Group>
717
721
  )}
@@ -728,6 +732,9 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
728
732
  palette={palette}
729
733
  formatValue={formatValue}
730
734
  font={skiaFont}
735
+ yAxisEntries={yAxisEntries}
736
+ labelRightMargin={yAxisCfg?.labelRightMargin}
737
+ gridEndGap={yAxisCfg?.gridEndGap}
731
738
  />
732
739
  ))}
733
740
  </Group>
@@ -888,6 +895,8 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
888
895
  palette,
889
896
  formatValue,
890
897
  skiaFont,
898
+ yAxisCfg,
899
+ yAxisEntries,
891
900
  degenShakeTransform,
892
901
  overlayScrubFade,
893
902
  } = model;
@@ -907,6 +916,9 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
907
916
  suppressTag={refLineCustom[i]}
908
917
  suppressTagWhenOffAxis={refLineOffAxisCustom[i]}
909
918
  customTagWidths={refLineCustomTagWidths}
919
+ yAxisEntries={yAxisEntries}
920
+ labelRightMargin={yAxisCfg?.labelRightMargin}
921
+ gridEndGap={yAxisCfg?.gridEndGap}
910
922
  />
911
923
  ))}
912
924
  </Group>
@@ -920,6 +932,53 @@ function SeriesCustomConsumerOverlay({ model }: { model: LiveChartSeriesModel })
920
932
  return <ChartOverlayLayer render={renderOverlay!} context={overlayContext} />;
921
933
  }
922
934
 
935
+ /** Keeps the extra fade mapper opt-in with the per-series tooltip itself. */
936
+ function SeriesTooltipLayer({
937
+ model,
938
+ config,
939
+ }: {
940
+ model: LiveChartSeriesModel;
941
+ config: NonNullable<LiveChartSeriesModel["seriesTooltipCfg"]>;
942
+ }) {
943
+ const {
944
+ crosshair,
945
+ engine,
946
+ effectivePadding,
947
+ scrubCfg,
948
+ skiaFont,
949
+ palette,
950
+ activeSeriesCount,
951
+ } = model;
952
+ const activeOpacity = useCrosshairVisibleOpacity(
953
+ crosshair.scrubX,
954
+ engine.canvasWidth,
955
+ effectivePadding.right,
956
+ crosshair.scrubActive,
957
+ scrubCfg?.crosshairFade ?? true,
958
+ scrubCfg?.crosshairFadeDistance ?? 4,
959
+ );
960
+ const scrubActive = crosshair.scrubActive;
961
+ const alwaysShow = config.alwaysShow;
962
+ const opacity = useDerivedValue(
963
+ () => (scrubActive.get() ? activeOpacity.get() : alwaysShow ? 1 : 0),
964
+ [scrubActive, activeOpacity, alwaysShow],
965
+ );
966
+
967
+ return (
968
+ <PerSeriesTooltipOverlay
969
+ layout={crosshair.tooltipLayout}
970
+ font={skiaFont}
971
+ palette={palette}
972
+ config={config}
973
+ seriesCount={activeSeriesCount}
974
+ opacity={opacity}
975
+ tooltipBackground={scrubCfg?.tooltipBackground}
976
+ tooltipColor={scrubCfg?.tooltipColor}
977
+ tooltipBorderColor={scrubCfg?.tooltipBorderColor}
978
+ />
979
+ );
980
+ }
981
+
923
982
  export function LiveChartSeries(props: LiveChartSeriesProps) {
924
983
  const model = useLiveChartSeriesController(props);
925
984
  const {
@@ -962,8 +1021,6 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
962
1021
  refLineCustomTagWidths,
963
1022
  overlayScrubFade,
964
1023
  canvasMode,
965
- activeSeriesCount,
966
- skiaFont,
967
1024
  } = model;
968
1025
 
969
1026
  // Mirror the Skia overlay fade onto the RN custom-marker sibling so
@@ -1065,7 +1122,14 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
1065
1122
  seriesTooltipCfg?.guideColor ??
1066
1123
  scrubCfg.crosshairLineColor
1067
1124
  }
1068
- crosshairLineWidth={seriesTooltipCfg?.guideWidth}
1125
+ crosshairStrokeWidth={
1126
+ seriesTooltipCfg?.guideWidth ??
1127
+ scrubCfg.crosshairStrokeWidth
1128
+ }
1129
+ crosshairOvershoot={scrubCfg.crosshairOvershoot}
1130
+ crosshairFade={scrubCfg.crosshairFade}
1131
+ crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
1132
+ crosshairLineCap={scrubCfg.crosshairLineCap}
1069
1133
  crosshairDash={
1070
1134
  seriesTooltipCfg
1071
1135
  ? seriesTooltipCfg.guideDashPattern
@@ -1079,19 +1143,6 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
1079
1143
  {/* Per-series value labels on top of the scrub dim so the dim never
1080
1144
  clips them (they track each series' live value, not the scrub). */}
1081
1145
  <SeriesValueLabelLayer model={model} />
1082
-
1083
- {seriesTooltipCfg && (
1084
- <PerSeriesTooltipOverlay
1085
- layout={crosshair.tooltipLayout}
1086
- font={skiaFont}
1087
- palette={palette}
1088
- config={seriesTooltipCfg}
1089
- seriesCount={activeSeriesCount}
1090
- tooltipBackground={scrubCfg?.tooltipBackground}
1091
- tooltipColor={scrubCfg?.tooltipColor}
1092
- tooltipBorderColor={scrubCfg?.tooltipBorderColor}
1093
- />
1094
- )}
1095
1146
  </Canvas>
1096
1147
 
1097
1148
  {/* RN labels floated over the canvas (sibling of <Canvas>, an RN
@@ -1153,6 +1204,22 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
1153
1204
  </Animated.View>
1154
1205
  )}
1155
1206
 
1207
+ {/* A dedicated transparent Canvas keeps the UI-thread tooltip above
1208
+ custom RN annotations. Rendering it in the primary Canvas would
1209
+ put every post-Canvas `renderReferenceLine` sibling above it and
1210
+ produce mixed stacking through translucent custom tags. */}
1211
+ {seriesTooltipCfg && (
1212
+ <View
1213
+ testID="live-chart-series-tooltip-overlay"
1214
+ pointerEvents="none"
1215
+ style={StyleSheet.absoluteFill}
1216
+ >
1217
+ <Canvas style={StyleSheet.absoluteFill}>
1218
+ <SeriesTooltipLayer model={model} config={seriesTooltipCfg} />
1219
+ </Canvas>
1220
+ </View>
1221
+ )}
1222
+
1156
1223
  {/* Custom consumer overlay — topmost RN sibling with the live plot
1157
1224
  rect, including this multi-series chart's resolved axis inset. */}
1158
1225
  {renderOverlay && <SeriesCustomConsumerOverlay model={model} />}
@@ -232,6 +232,7 @@ export function PerSeriesTooltipOverlay({
232
232
  palette,
233
233
  config,
234
234
  seriesCount,
235
+ opacity,
235
236
  tooltipBackground,
236
237
  tooltipColor,
237
238
  tooltipBorderColor,
@@ -241,6 +242,8 @@ export function PerSeriesTooltipOverlay({
241
242
  palette: LiveChartPalette;
242
243
  config: ResolvedPerSeriesTooltipConfig;
243
244
  seriesCount: number;
245
+ /** Active crosshair opacity; idle `alwaysShow` pills stay fully visible. */
246
+ opacity?: SharedValue<number> | DerivedValue<number>;
244
247
  tooltipBackground?: string;
245
248
  tooltipColor?: string;
246
249
  tooltipBorderColor?: string;
@@ -258,7 +261,7 @@ export function PerSeriesTooltipOverlay({
258
261
  config.seriesPillBorderColor ?? tooltipBorderColor;
259
262
 
260
263
  return (
261
- <Group>
264
+ <Group opacity={opacity}>
262
265
  <TimePill
263
266
  layout={layout}
264
267
  font={font}
@@ -9,6 +9,7 @@ import {
9
9
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
10
10
 
11
11
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
12
+ import type { YAxisEntry } from "../draw/grid";
12
13
  import type { ChartPadding } from "../draw/line";
13
14
  import { useChartSkiaFont } from "../hooks/useChartSkiaFont";
14
15
  import { usePathBuilder } from "../hooks/usePathBuilder";
@@ -85,6 +86,12 @@ type ReferenceLineOverlayProps = {
85
86
  dragValues?: SharedValue<number[]>;
86
87
  /** This line's index into {@link dragValues}. */
87
88
  index?: number;
89
+ /** Y-axis entries used to match a right-anchored grid endpoint. */
90
+ yAxisEntries?: SharedValue<YAxisEntry[]> | null;
91
+ /** Enables clipping to the right-anchored Y-axis label column. */
92
+ labelRightMargin?: number;
93
+ /** Gap between the drawn line endpoint and the label column. */
94
+ gridEndGap?: number;
88
95
  };
89
96
 
90
97
  export function ReferenceLineOverlay({
@@ -102,6 +109,9 @@ export function ReferenceLineOverlay({
102
109
  groupHidden,
103
110
  dragValues,
104
111
  index = 0,
112
+ yAxisEntries,
113
+ labelRightMargin,
114
+ gridEndGap,
105
115
  }: ReferenceLineOverlayProps) {
106
116
  const form = referenceLineForm(line);
107
117
  const isBand = form === "value-band" || form === "time-band";
@@ -141,6 +151,10 @@ export function ReferenceLineOverlay({
141
151
  badgeFont,
142
152
  dragValues,
143
153
  index,
154
+ yAxisEntries ?? undefined,
155
+ labelRightMargin,
156
+ gridEndGap,
157
+ font,
144
158
  );
145
159
 
146
160
  const labelColor =
@@ -7,7 +7,10 @@ import {
7
7
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
8
8
  import { BADGE_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
9
9
  import type { ResolvedGridStyleConfig } from "../core/resolveConfig";
10
- import type { YAxisEntry } from "../draw/grid";
10
+ import {
11
+ rightAnchoredYAxisColumnLayout,
12
+ type YAxisEntry,
13
+ } from "../draw/grid";
11
14
  import {
12
15
  badgeTailAndCap,
13
16
  gutterCenteredTextLeftX,
@@ -57,6 +60,8 @@ export function YAxisOverlay({
57
60
  gridStyle,
58
61
  variant = "all",
59
62
  float = false,
63
+ labelRightMargin,
64
+ gridEndGap = 0,
60
65
  }: {
61
66
  entries: SharedValue<YAxisEntry[]>;
62
67
  engine: ChartEngineLayout;
@@ -92,6 +97,10 @@ export function YAxisOverlay({
92
97
  * plot) instead of centering them in a reserved gutter. See {@link YAxisConfig.float}.
93
98
  */
94
99
  float?: boolean;
100
+ /** Fixed canvas-edge margin for a shared left-aligned label column. */
101
+ labelRightMargin?: number;
102
+ /** Gap between the grid-line end and the shared label column. */
103
+ gridEndGap?: number;
95
104
  }) {
96
105
  const gridColor = gridStyle?.color ?? palette.gridLine;
97
106
  const gridWidth = gridStyle?.strokeWidth ?? 1;
@@ -99,14 +108,26 @@ export function YAxisOverlay({
99
108
  const gridOpacity = gridStyle?.opacity ?? 1;
100
109
  const gridBuilder = usePathBuilder();
101
110
 
111
+ const rightAnchoredColumn = useDerivedValue(() => {
112
+ if (labelRightMargin === undefined) return null;
113
+ return rightAnchoredYAxisColumnLayout(
114
+ engine.canvasWidth.get(),
115
+ entries.get(),
116
+ font,
117
+ labelRightMargin,
118
+ gridEndGap,
119
+ );
120
+ });
121
+
102
122
  const gridLinesPath = useDerivedValue(() => {
103
123
  const b = gridBuilder.value;
104
124
  if (variant === "labels") return b.detach();
105
125
  const items = entries.get();
106
126
  const w = engine.canvasWidth.get();
127
+ const lineX2 = rightAnchoredColumn.get()?.gridEndX ?? w - padding.right;
107
128
  for (let i = 0; i < items.length; i++) {
108
129
  b.moveTo(padding.left, items[i].y);
109
- b.lineTo(w - padding.right, items[i].y);
130
+ b.lineTo(lineX2, items[i].y);
110
131
  }
111
132
  return b.detach();
112
133
  });
@@ -120,6 +141,7 @@ export function YAxisOverlay({
120
141
  const w = engine.canvasWidth.get();
121
142
  const fm = font.getMetrics();
122
143
  const baselineOffset = (fm.ascent + fm.descent) / 2;
144
+ const columnX = rightAnchoredColumn.get()?.labelX;
123
145
  const labelHeight = fm.descent - fm.ascent;
124
146
  const badgeIsVisible = badgeOpacity === undefined || badgeOpacity.get() > 0;
125
147
  const resolvedBadgeCenterY = badgeCenterY && badgeIsVisible
@@ -133,13 +155,16 @@ export function YAxisOverlay({
133
155
  for (let i = 0; i < items.length; i++) {
134
156
  const e = items[i];
135
157
  const textW = measureFontTextWidth(font, e.label);
136
- const x = float
137
- ? gutterRightAlignedTextLeftX(w, textW, FLOAT_LABEL_RIGHT_MARGIN)
138
- : badge
139
- ? pillTextLeftX(w, padding.right, leftInset, textW, badgeMetrics)
140
- : seriesLabelInset > 0
141
- ? gutterRightAlignedTextLeftX(w, textW)
142
- : gutterCenteredTextLeftX(w, padding.right, textW);
158
+ const x =
159
+ columnX !== undefined
160
+ ? columnX
161
+ : float
162
+ ? gutterRightAlignedTextLeftX(w, textW, FLOAT_LABEL_RIGHT_MARGIN)
163
+ : badge
164
+ ? pillTextLeftX(w, padding.right, leftInset, textW, badgeMetrics)
165
+ : seriesLabelInset > 0
166
+ ? gutterRightAlignedTextLeftX(w, textW)
167
+ : gutterCenteredTextLeftX(w, padding.right, textW);
143
168
  result.push({
144
169
  x,
145
170
  y: e.y - baselineOffset,
@@ -89,6 +89,10 @@ export interface ResolvedYAxisConfig {
89
89
  minGap: number;
90
90
  /** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
91
91
  count: number;
92
+ /** undefined → keep the default centered-gutter label placement. */
93
+ labelRightMargin: number | undefined;
94
+ /** undefined → no gap; inert unless labelRightMargin is set. */
95
+ gridEndGap: number | undefined;
92
96
  /** Float the axis over a full-width plot (no reserved right gutter). */
93
97
  float: boolean;
94
98
  }
@@ -151,6 +155,16 @@ export interface ResolvedScrubConfig {
151
155
  dimOpacity: number;
152
156
  /** undefined → palette.crosshairLine */
153
157
  crosshairLineColor: string | undefined;
158
+ /** Vertical crosshair line width in px. */
159
+ crosshairStrokeWidth: number;
160
+ /** Extension past the plot's top and bottom edges in px. */
161
+ crosshairOvershoot: number;
162
+ /** Fade the crosshair near the live edge. */
163
+ crosshairFade: boolean;
164
+ /** Visible-crosshair fade distance near the live edge in px. */
165
+ crosshairFadeDistance: number;
166
+ /** undefined preserves the existing Skia line-cap default. */
167
+ crosshairLineCap: "butt" | "round" | "square" | undefined;
154
168
  /** Dash intervals `[on, off, …]` for the crosshair line; undefined → solid. */
155
169
  crosshairDash: number[] | undefined;
156
170
  /** undefined → palette.crosshairDim */
@@ -175,6 +189,8 @@ export interface ResolvedScrubConfig {
175
189
  panGestureDelay: number;
176
190
  /** Fade markers + reference lines out while scrubbing. */
177
191
  hideOverlaysOnScrub: boolean;
192
+ /** Reject outside plain-scrub starts and clamp active scrub X to the plot. */
193
+ clampToPlot: boolean;
178
194
  }
179
195
 
180
196
  export interface ResolvedPerSeriesTooltipConfig {
@@ -465,6 +481,8 @@ export function resolveBadge(
465
481
  const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
466
482
  minGap: 36,
467
483
  count: 0,
484
+ labelRightMargin: undefined,
485
+ gridEndGap: undefined,
468
486
  float: false,
469
487
  };
470
488
 
@@ -664,6 +682,11 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
664
682
  seriesTooltip: null,
665
683
  dimOpacity: 0.3,
666
684
  crosshairLineColor: undefined,
685
+ crosshairStrokeWidth: 1,
686
+ crosshairOvershoot: 0,
687
+ crosshairFade: true,
688
+ crosshairFadeDistance: 4,
689
+ crosshairLineCap: undefined,
667
690
  crosshairDash: undefined,
668
691
  crosshairDimColor: undefined,
669
692
  tooltipBackground: undefined,
@@ -676,6 +699,7 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
676
699
  tooltipShowTime: true,
677
700
  panGestureDelay: 0,
678
701
  hideOverlaysOnScrub: false,
702
+ clampToPlot: false,
679
703
  };
680
704
 
681
705
  const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
@@ -743,6 +767,11 @@ export function resolveScrub(
743
767
  const seriesTooltip =
744
768
  typeof prop === "object" ? prop.seriesTooltip : undefined;
745
769
  resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
770
+ resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
771
+ resolved.crosshairFadeDistance = Math.max(
772
+ 0,
773
+ resolved.crosshairFadeDistance,
774
+ );
746
775
  }
747
776
  return resolved;
748
777
  }
package/src/draw/grid.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { GRID_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
2
+ import type { SkFont } from "@shopify/react-native-skia";
3
+ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
2
4
  import { lerp } from "../math/lerp";
3
5
  import type { GridMetrics } from "../types";
4
6
 
@@ -8,6 +10,37 @@ export interface YAxisEntry {
8
10
  alpha: number;
9
11
  }
10
12
 
13
+ export interface RightAnchoredYAxisColumnLayout {
14
+ /** Shared left X for every label in the column. */
15
+ labelX: number;
16
+ /** Grid/reference-line end X immediately before the column gap. */
17
+ gridEndX: number;
18
+ }
19
+
20
+ /**
21
+ * Measure the widest Y-axis label and anchor the shared left-aligned column to
22
+ * a fixed canvas-edge margin. Shared by the axis and reference-line worklets so
23
+ * their right edges cannot drift apart.
24
+ */
25
+ export function rightAnchoredYAxisColumnLayout(
26
+ canvasWidth: number,
27
+ entries: readonly YAxisEntry[],
28
+ font: SkFont,
29
+ labelRightMargin: number,
30
+ gridEndGap = 0,
31
+ ): RightAnchoredYAxisColumnLayout {
32
+ "worklet";
33
+ let maxTextW = 0;
34
+ for (let i = 0; i < entries.length; i++) {
35
+ maxTextW = Math.max(
36
+ maxTextW,
37
+ measureFontTextWidth(font, entries[i].label),
38
+ );
39
+ }
40
+ const labelX = canvasWidth - labelRightMargin - maxTextW;
41
+ return { labelX, gridEndX: labelX - gridEndGap };
42
+ }
43
+
11
44
  /**
12
45
  * Fixed-count Y-axis: place exactly `count` price labels evenly **in pixels**
13
46
  * across the plot band — top = `displayMax`, bottom = `displayMin`. Values track
@@ -12,13 +12,87 @@ const TOOLTIP_LINE_GAP = 4;
12
12
  const TOOLTIP_OFFSET_X = 12;
13
13
  const TOOLTIP_EDGE_GAP = 4;
14
14
  const TOOLTIP_TOP_MARGIN = 8;
15
- const FADE_ZONE = 4;
16
15
 
17
16
  /** Horizontal travel required before a plain scrub claims the touch. */
18
17
  export const SCRUB_ACTIVATE_X_PX = 20;
19
18
  /** Vertical travel that fails a plain scrub so a parent scroll gesture can win. */
20
19
  export const SCRUB_FAIL_Y_PX = 10;
21
20
 
21
+ /** Clamp an X pixel to the plot's horizontal bounds. */
22
+ export function clampPlotX(
23
+ x: number,
24
+ padLeft: number,
25
+ canvasWidth: number,
26
+ padRight: number,
27
+ ): number {
28
+ "worklet";
29
+ return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
30
+ }
31
+
32
+ export interface ScrubHitSlop {
33
+ left?: number;
34
+ right?: number;
35
+ bottom?: number;
36
+ }
37
+
38
+ /** Build touch-down bounds for a scrub recognizer. */
39
+ export function resolveScrubHitSlop(
40
+ padding: ChartPadding,
41
+ clampToPlot: boolean,
42
+ bottomExclude = 0,
43
+ ): ScrubHitSlop | undefined {
44
+ if (!clampToPlot && bottomExclude <= 0) return undefined;
45
+ return {
46
+ ...(clampToPlot
47
+ ? { left: -padding.left, right: -padding.right }
48
+ : {}),
49
+ ...(bottomExclude > 0 ? { bottom: -bottomExclude } : {}),
50
+ };
51
+ }
52
+
53
+ interface ScrubGestureValue<T> {
54
+ get(): T;
55
+ set(value: T): void;
56
+ }
57
+
58
+ /** Start plain scrubbing; the recognizer gates out-of-plot touch origins. */
59
+ export function startPlainScrub(
60
+ x: number,
61
+ padding: ChartPadding,
62
+ canvasWidth: number,
63
+ clampToPlot: boolean,
64
+ scrubX: ScrubGestureValue<number>,
65
+ scrubActive: ScrubGestureValue<boolean>,
66
+ gestureStarted: ScrubGestureValue<boolean>,
67
+ ): void {
68
+ "worklet";
69
+ if (clampToPlot) {
70
+ scrubX.set(clampPlotX(x, padding.left, canvasWidth, padding.right));
71
+ } else {
72
+ scrubX.set(x);
73
+ }
74
+ scrubActive.set(true);
75
+ gestureStarted.set(true);
76
+ }
77
+
78
+ /** Track plain scrubbing, clamping gestures accepted by the recognizer. */
79
+ export function updatePlainScrub(
80
+ x: number,
81
+ padding: ChartPadding,
82
+ canvasWidth: number,
83
+ clampToPlot: boolean,
84
+ scrubX: ScrubGestureValue<number>,
85
+ scrubActive: ScrubGestureValue<boolean>,
86
+ ): void {
87
+ "worklet";
88
+ if (clampToPlot) {
89
+ if (!scrubActive.get()) return;
90
+ scrubX.set(clampPlotX(x, padding.left, canvasWidth, padding.right));
91
+ } else {
92
+ scrubX.set(x);
93
+ }
94
+ }
95
+
22
96
  /** Measure rendered text so the pill and its content share the same centre. */
23
97
  function measureTooltipTextWidth(
24
98
  font: SkFont,
@@ -217,20 +291,23 @@ export function computeScrubTime(
217
291
  }
218
292
 
219
293
  /**
220
- * Crosshair opacity: fades 1→0 over FADE_ZONE px as the crosshair
221
- * approaches the live dot at the right chart edge.
294
+ * Crosshair opacity: fades 1→0 over `fadeDistance` px as the crosshair
295
+ * approaches the live dot at the right chart edge. A zero distance removes
296
+ * the ramp while keeping the crosshair hidden at and beyond the live edge.
222
297
  */
223
298
  export function computeCrosshairOpacity(
224
299
  scrubActive: boolean,
225
300
  scrubX: number,
226
301
  canvasWidth: number,
227
302
  paddingRight: number,
303
+ fadeDistance = 4,
228
304
  ): number {
229
305
  "worklet";
230
306
  if (!scrubActive) return 0;
231
307
  const dotX = canvasWidth - paddingRight;
232
308
  const dist = dotX - scrubX;
233
- return Math.min(1, Math.max(0, dist / FADE_ZONE));
309
+ if (fadeDistance <= 0) return dist > 0 ? 1 : 0;
310
+ return Math.min(1, Math.max(0, dist / fadeDistance));
234
311
  }
235
312
 
236
313
  /**