@ledgerhq/lumen-ui-rnative-visualization 0.1.21 → 0.1.23

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 (36) hide show
  1. package/dist/module/lib/Components/Point/Point.js +78 -48
  2. package/dist/module/lib/Components/Point/Point.js.map +1 -1
  3. package/dist/module/lib/Components/Point/Point.test.js +41 -0
  4. package/dist/module/lib/Components/Point/Point.test.js.map +1 -1
  5. package/dist/module/lib/Components/Point/constants.js +2 -0
  6. package/dist/module/lib/Components/Point/constants.js.map +1 -1
  7. package/dist/module/lib/Components/Point/usePointGeometry.js +60 -0
  8. package/dist/module/lib/Components/Point/usePointGeometry.js.map +1 -0
  9. package/dist/module/lib/Components/Point/usePointGeometry.test.js +86 -0
  10. package/dist/module/lib/Components/Point/usePointGeometry.test.js.map +1 -0
  11. package/dist/module/lib/Components/Point/utils.js +58 -15
  12. package/dist/module/lib/Components/Point/utils.js.map +1 -1
  13. package/dist/module/lib/Components/Point/utils.test.js +37 -75
  14. package/dist/module/lib/Components/Point/utils.test.js.map +1 -1
  15. package/dist/module/lib/utils/domain/domain.js +4 -4
  16. package/dist/module/lib/utils/domain/domain.js.map +1 -1
  17. package/dist/typescript/src/lib/Components/Point/Point.d.ts +1 -1
  18. package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -1
  19. package/dist/typescript/src/lib/Components/Point/constants.d.ts +2 -0
  20. package/dist/typescript/src/lib/Components/Point/constants.d.ts.map +1 -1
  21. package/dist/typescript/src/lib/Components/Point/types.d.ts +32 -0
  22. package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -1
  23. package/dist/typescript/src/lib/Components/Point/usePointGeometry.d.ts +33 -0
  24. package/dist/typescript/src/lib/Components/Point/usePointGeometry.d.ts.map +1 -0
  25. package/dist/typescript/src/lib/Components/Point/utils.d.ts +30 -4
  26. package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -1
  27. package/package.json +5 -5
  28. package/src/lib/Components/Point/Point.test.tsx +46 -0
  29. package/src/lib/Components/Point/Point.tsx +75 -56
  30. package/src/lib/Components/Point/constants.ts +2 -0
  31. package/src/lib/Components/Point/types.ts +41 -0
  32. package/src/lib/Components/Point/usePointGeometry.test.ts +95 -0
  33. package/src/lib/Components/Point/usePointGeometry.ts +77 -0
  34. package/src/lib/Components/Point/utils.test.ts +34 -80
  35. package/src/lib/Components/Point/utils.ts +77 -19
  36. package/src/lib/utils/domain/domain.ts +4 -4
@@ -1,22 +1,16 @@
1
1
  import { useTheme } from '@ledgerhq/lumen-ui-rnative';
2
- import { useMemo } from 'react';
3
2
  import Animated from 'react-native-reanimated';
4
3
  import { Circle, G, Polygon, Text as SvgText } from 'react-native-svg';
5
4
 
6
- import { projectPoint } from '../../utils/scales/scales';
7
- import { useCartesianChartContext } from '../CartesianChart/context';
8
- import { useRevealFadeProps } from '../CartesianChart/RevealAnimation';
9
- import { DEFAULT_SIZE, STROKE_WIDTH } from './constants';
10
- import { useMagneticPointsContext } from './pointContext';
11
-
12
- import type { PointLabelProps, PointProps } from './types';
13
- import {
14
- buildArrowPoints,
15
- computeLabelY,
16
- isWithinBounds,
17
- resolveLabel,
18
- useMagneticRegistration,
19
- } from './utils';
5
+ import { DEFAULT_SIZE, LABEL_FONT_SIZE, STROKE_WIDTH } from './constants';
6
+ import type {
7
+ PointArrowProps,
8
+ PointLabelProps,
9
+ PointMarkerProps,
10
+ PointProps,
11
+ } from './types';
12
+ import { usePointGeometry } from './usePointGeometry';
13
+ import { buildArrowPoints, computeLabelGeometry, resolveLabel } from './utils';
20
14
 
21
15
  const AnimatedG = Animated.createAnimatedComponent(G);
22
16
 
@@ -30,7 +24,7 @@ export function PointLabel({
30
24
  <SvgText
31
25
  textAnchor={textAnchor}
32
26
  fill={theme.colors.text.base}
33
- fontSize={theme.typographies.body4.fontSize}
27
+ fontSize={LABEL_FONT_SIZE}
34
28
  fontWeight={theme.typographies.body4.fontWeight}
35
29
  fontFamily={theme.fontFamilies.sans}
36
30
  {...props}
@@ -38,6 +32,36 @@ export function PointLabel({
38
32
  );
39
33
  }
40
34
 
35
+ function PointMarker({ x, y, size, color }: Readonly<PointMarkerProps>) {
36
+ const { theme } = useTheme();
37
+ const radius = size / 2;
38
+ const fill = color ?? theme.colors.bg.mutedStrong;
39
+
40
+ return (
41
+ <Circle
42
+ testID='point-circle'
43
+ cx={x}
44
+ cy={y}
45
+ r={radius}
46
+ fill={fill}
47
+ stroke={theme.colors.bg.canvas}
48
+ strokeWidth={STROKE_WIDTH}
49
+ />
50
+ );
51
+ }
52
+
53
+ function PointArrow({ x, y, size, position }: Readonly<PointArrowProps>) {
54
+ const { theme } = useTheme();
55
+
56
+ return (
57
+ <Polygon
58
+ testID='point-arrow'
59
+ points={buildArrowPoints(x, y, size / 2, position)}
60
+ fill={theme.colors.text.base}
61
+ />
62
+ );
63
+ }
64
+
41
65
  export function Point({
42
66
  dataX,
43
67
  dataY,
@@ -50,60 +74,55 @@ export function Point({
50
74
  size = DEFAULT_SIZE,
51
75
  onPress,
52
76
  magnetic = false,
77
+ labelAlignment = 'auto',
53
78
  }: Readonly<PointProps>) {
54
- const { getXScale, getYScale, getXAxisConfig, drawingArea } =
55
- useCartesianChartContext();
56
- const fadeProps = useRevealFadeProps();
57
- const magneticContext = useMagneticPointsContext();
58
-
59
- useMagneticRegistration(magnetic, dataX, getXAxisConfig, magneticContext);
60
- const { theme } = useTheme();
79
+ const { pixel, drawingArea, fadeProps, isVisible } = usePointGeometry({
80
+ dataX,
81
+ dataY,
82
+ magnetic,
83
+ });
61
84
 
62
- const xScale = getXScale();
63
- const yScale = getYScale();
64
-
65
- const radius = size / 2;
66
- const fill = color ?? theme.colors.bg.mutedStrong;
67
-
68
- const pixel = useMemo(() => {
69
- if (!xScale || !yScale) return undefined;
70
- return projectPoint(dataX, dataY, xScale, yScale);
71
- }, [dataX, dataY, xScale, yScale]);
72
-
73
- if (!pixel || !isWithinBounds(pixel.x, pixel.y, drawingArea)) {
85
+ if (!isVisible || !pixel) {
74
86
  return null;
75
87
  }
76
88
 
77
- const resolvedLabel = resolveLabel(label, dataX);
78
- const hasLabel = resolvedLabel != null;
79
- const renderArrow = showLabelArrow && hasLabel;
80
- const labelY = computeLabelY(pixel.y, radius, labelPosition, renderArrow);
89
+ const labelText = resolveLabel(label, dataX);
90
+ const isLabelVisible = labelText !== undefined;
91
+ const labelGeometry = isLabelVisible
92
+ ? computeLabelGeometry({
93
+ text: labelText,
94
+ pixelX: pixel.x,
95
+ pixelY: pixel.y,
96
+ size,
97
+ labelPosition,
98
+ showLabelArrow,
99
+ area: drawingArea,
100
+ alignment: labelAlignment,
101
+ })
102
+ : null;
81
103
 
82
104
  const Label = LabelComponent ?? PointLabel;
83
105
 
84
106
  return (
85
107
  <AnimatedG testID='point-group' onPress={onPress} animatedProps={fadeProps}>
86
108
  {!hidePoint && (
87
- <Circle
88
- testID='point-circle'
89
- cx={pixel.x}
90
- cy={pixel.y}
91
- r={radius}
92
- fill={fill}
93
- stroke={theme.colors.bg.canvas}
94
- strokeWidth={STROKE_WIDTH}
95
- />
109
+ <PointMarker x={pixel.x} y={pixel.y} size={size} color={color} />
96
110
  )}
97
- {renderArrow && (
98
- <Polygon
99
- testID='point-arrow'
100
- points={buildArrowPoints(pixel.x, pixel.y, radius, labelPosition)}
101
- fill={theme.colors.text.base}
111
+ {isLabelVisible && showLabelArrow && (
112
+ <PointArrow
113
+ x={pixel.x}
114
+ y={pixel.y}
115
+ size={size}
116
+ position={labelPosition}
102
117
  />
103
118
  )}
104
- {resolvedLabel != null && (
105
- <Label x={pixel.x} y={labelY}>
106
- {resolvedLabel}
119
+ {labelText != null && labelGeometry && (
120
+ <Label
121
+ x={labelGeometry.x}
122
+ y={labelGeometry.y}
123
+ textAnchor={labelGeometry.textAnchor}
124
+ >
125
+ {labelText}
107
126
  </Label>
108
127
  )}
109
128
  </AnimatedG>
@@ -4,3 +4,5 @@ export const LABEL_FONT_SIZE = 10;
4
4
  export const ARROW_WIDTH = 6;
5
5
  export const ARROW_HEIGHT = 4;
6
6
  export const GAP = 4;
7
+ /** Approximate width of a character in the label font, as a ratio of the font size. Used to compute the label width. */
8
+ export const LABEL_CHAR_WIDTH_RATIO = 0.6;
@@ -10,6 +10,38 @@ export type PointLabelProps = {
10
10
 
11
11
  export type PointLabelComponent = ComponentType<PointLabelProps>;
12
12
 
13
+ /**
14
+ * Horizontal alignment strategy for a point's label.
15
+ *
16
+ * - `'auto'`: keep the label inside the drawing area, anchoring it to the
17
+ * nearest edge when it would overflow.
18
+ * - `'center'`: always centre the label on the point.
19
+ */
20
+ export type LabelAlignment = 'center' | 'auto';
21
+
22
+ /**
23
+ * Pixel position and styling inputs shared by the point's rendered glyphs. Each
24
+ * glyph picks the subset it needs and derives its own pixel geometry (e.g.
25
+ * radius from `size`).
26
+ */
27
+ type PointGlyphProps = {
28
+ x: number;
29
+ y: number;
30
+ size: number;
31
+ color?: string;
32
+ position: 'top' | 'bottom';
33
+ };
34
+
35
+ export type PointMarkerProps = Pick<
36
+ PointGlyphProps,
37
+ 'x' | 'y' | 'size' | 'color'
38
+ >;
39
+
40
+ export type PointArrowProps = Pick<
41
+ PointGlyphProps,
42
+ 'x' | 'y' | 'size' | 'position'
43
+ >;
44
+
13
45
  export type PointProps = {
14
46
  /**
15
47
  * X coordinate in data space (index or explicit value).
@@ -80,4 +112,13 @@ export type PointProps = {
80
112
  * @default false
81
113
  */
82
114
  magnetic?: boolean;
115
+ /**
116
+ * Horizontal alignment of the label relative to the chart's drawing area.
117
+ * With `'auto'`, a label that would overflow the left/right edge is anchored
118
+ * to that edge and grows inward instead of being clipped, while the arrow
119
+ * keeps pointing at the exact data point. Use `'center'` to always centre the
120
+ * label on the point.
121
+ * @default 'auto'
122
+ */
123
+ labelAlignment?: LabelAlignment;
83
124
  };
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { renderHook } from '@testing-library/react-native';
3
+
4
+ import type { BaseAxisProps } from '../Axis';
5
+ import { useMagneticRegistration } from './usePointGeometry';
6
+
7
+ const makeContext = () => ({
8
+ register: jest.fn(),
9
+ unregister: jest.fn(),
10
+ version: 0,
11
+ getMagneticPoints: () => new Set<number>(),
12
+ });
13
+
14
+ const noAxisConfig = (): BaseAxisProps | undefined => undefined;
15
+
16
+ describe('useMagneticRegistration', () => {
17
+ it('registers the data index when magnetic is true', () => {
18
+ const ctx = makeContext();
19
+ renderHook(() => useMagneticRegistration(true, 3, noAxisConfig, ctx));
20
+
21
+ expect(ctx.register).toHaveBeenCalledWith(3);
22
+ });
23
+
24
+ it('does not register when magnetic is false', () => {
25
+ const ctx = makeContext();
26
+ renderHook(() => useMagneticRegistration(false, 3, noAxisConfig, ctx));
27
+
28
+ expect(ctx.register).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it('unregisters on unmount', () => {
32
+ const ctx = makeContext();
33
+ const { unmount } = renderHook(() =>
34
+ useMagneticRegistration(true, 3, noAxisConfig, ctx),
35
+ );
36
+
37
+ unmount();
38
+ expect(ctx.unregister).toHaveBeenCalledWith(3);
39
+ });
40
+
41
+ it('resolves dataX through axis config data when provided', () => {
42
+ const ctx = makeContext();
43
+ const getXAxisConfig = (): BaseAxisProps => ({
44
+ scaleType: 'band',
45
+ data: [100, 200, 300],
46
+ });
47
+
48
+ renderHook(() => useMagneticRegistration(true, 200, getXAxisConfig, ctx));
49
+
50
+ expect(ctx.register).toHaveBeenCalledWith(1);
51
+ });
52
+
53
+ it('does not register when axis data exists but does not contain the value', () => {
54
+ const ctx = makeContext();
55
+ const getXAxisConfig = (): BaseAxisProps => ({
56
+ scaleType: 'band',
57
+ data: [100, 200, 300],
58
+ });
59
+
60
+ renderHook(() => useMagneticRegistration(true, 999, getXAxisConfig, ctx));
61
+
62
+ expect(ctx.register).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('re-registers when dataX changes', () => {
66
+ const ctx = makeContext();
67
+ const { rerender } = renderHook(
68
+ ({ dataX }: { dataX: number }) =>
69
+ useMagneticRegistration(true, dataX, noAxisConfig, ctx),
70
+ { initialProps: { dataX: 2 } },
71
+ );
72
+
73
+ expect(ctx.register).toHaveBeenCalledWith(2);
74
+
75
+ rerender({ dataX: 4 });
76
+
77
+ expect(ctx.unregister).toHaveBeenCalledWith(2);
78
+ expect(ctx.register).toHaveBeenCalledWith(4);
79
+ });
80
+
81
+ it('unregisters and stops when magnetic switches to false', () => {
82
+ const ctx = makeContext();
83
+ const { rerender } = renderHook(
84
+ ({ magnetic }: { magnetic: boolean }) =>
85
+ useMagneticRegistration(magnetic, 3, noAxisConfig, ctx),
86
+ { initialProps: { magnetic: true } },
87
+ );
88
+
89
+ expect(ctx.register).toHaveBeenCalledWith(3);
90
+
91
+ rerender({ magnetic: false });
92
+
93
+ expect(ctx.unregister).toHaveBeenCalledWith(3);
94
+ });
95
+ });
@@ -0,0 +1,77 @@
1
+ import { useEffect, useMemo } from 'react';
2
+
3
+ import { projectPoint } from '../../utils/scales/scales';
4
+ import type { DrawingArea } from '../../utils/types';
5
+ import type { BaseAxisProps } from '../Axis';
6
+ import { useCartesianChartContext } from '../CartesianChart/context';
7
+ import { useRevealFadeProps } from '../CartesianChart/RevealAnimation';
8
+ import { useMagneticPointsContext } from './pointContext';
9
+ import type { MagneticPointsContextValue } from './pointContext/magneticPointsContext';
10
+ import { isWithinBounds, resolveDataXToIndex } from './utils';
11
+
12
+ type Pixel = { x: number; y: number };
13
+
14
+ type UsePointGeometryParams = {
15
+ dataX: number;
16
+ dataY: number;
17
+ magnetic: boolean;
18
+ };
19
+
20
+ type PointGeometry = {
21
+ pixel: Pixel | undefined;
22
+ drawingArea: DrawingArea;
23
+ fadeProps: ReturnType<typeof useRevealFadeProps>;
24
+ isVisible: boolean;
25
+ };
26
+
27
+ /**
28
+ * Resolves a point's chart geometry and behaviour: projects its data
29
+ * coordinates to pixels, registers it as a magnetic snap target when enabled,
30
+ * exposes the reveal-animation props, and reports whether it falls inside the
31
+ * drawing area.
32
+ */
33
+ export const usePointGeometry = ({
34
+ dataX,
35
+ dataY,
36
+ magnetic,
37
+ }: UsePointGeometryParams): PointGeometry => {
38
+ const { getXScale, getYScale, getXAxisConfig, drawingArea } =
39
+ useCartesianChartContext();
40
+ const magneticContext = useMagneticPointsContext();
41
+
42
+ useMagneticRegistration(magnetic, dataX, getXAxisConfig, magneticContext);
43
+
44
+ const xScale = getXScale();
45
+ const yScale = getYScale();
46
+
47
+ const pixel = useMemo(() => {
48
+ if (!xScale || !yScale) return undefined;
49
+ return projectPoint(dataX, dataY, xScale, yScale);
50
+ }, [dataX, dataY, xScale, yScale]);
51
+
52
+ const fadeProps = useRevealFadeProps();
53
+
54
+ const isVisible =
55
+ pixel !== undefined && isWithinBounds(pixel.x, pixel.y, drawingArea);
56
+
57
+ return { pixel, drawingArea, fadeProps, isVisible };
58
+ };
59
+
60
+ /**
61
+ * Registers/unregisters a data index as a magnetic snap target
62
+ * when the Point has `magnetic` enabled.
63
+ */
64
+ export const useMagneticRegistration = (
65
+ magnetic: boolean,
66
+ dataX: number,
67
+ getXAxisConfig: () => BaseAxisProps | undefined,
68
+ { register, unregister }: MagneticPointsContextValue,
69
+ ): void => {
70
+ useEffect(() => {
71
+ if (!magnetic) return;
72
+ const index = resolveDataXToIndex(dataX, getXAxisConfig());
73
+ if (index === undefined) return;
74
+ register(index);
75
+ return () => unregister(index);
76
+ }, [magnetic, dataX, getXAxisConfig, register, unregister]);
77
+ };
@@ -1,14 +1,12 @@
1
- import { describe, expect, it, jest } from '@jest/globals';
2
- import { renderHook } from '@testing-library/react-native';
1
+ import { describe, expect, it } from '@jest/globals';
3
2
 
4
- import type { BaseAxisProps } from '../Axis';
5
3
  import { ARROW_HEIGHT, ARROW_WIDTH, GAP, LABEL_FONT_SIZE } from './constants';
6
4
  import {
7
5
  buildArrowPoints,
6
+ computeLabelX,
8
7
  computeLabelY,
9
8
  isWithinBounds,
10
9
  resolveLabel,
11
- useMagneticRegistration,
12
10
  } from './utils';
13
11
 
14
12
  describe('isWithinBounds', () => {
@@ -117,92 +115,48 @@ describe('computeLabelY', () => {
117
115
  });
118
116
  });
119
117
 
120
- const makeContext = () => ({
121
- register: jest.fn(),
122
- unregister: jest.fn(),
123
- version: 0,
124
- getMagneticPoints: () => new Set<number>(),
125
- });
126
-
127
- const noAxisConfig = (): BaseAxisProps | undefined => undefined;
118
+ describe('computeLabelX', () => {
119
+ const area = { x: 100, y: 0, width: 200, height: 100 };
120
+ const halfArrow = ARROW_WIDTH / 2;
128
121
 
129
- describe('useMagneticRegistration', () => {
130
- it('registers the data index when magnetic is true', () => {
131
- const ctx = makeContext();
132
- renderHook(() => useMagneticRegistration(true, 3, noAxisConfig, ctx));
133
-
134
- expect(ctx.register).toHaveBeenCalledWith(3);
135
- });
136
-
137
- it('does not register when magnetic is false', () => {
138
- const ctx = makeContext();
139
- renderHook(() => useMagneticRegistration(false, 3, noAxisConfig, ctx));
140
-
141
- expect(ctx.register).not.toHaveBeenCalled();
142
- });
143
-
144
- it('unregisters on unmount', () => {
145
- const ctx = makeContext();
146
- const { unmount } = renderHook(() =>
147
- useMagneticRegistration(true, 3, noAxisConfig, ctx),
148
- );
149
-
150
- unmount();
151
- expect(ctx.unregister).toHaveBeenCalledWith(3);
122
+ it('centres the label on the point when clamping is disabled', () => {
123
+ expect(
124
+ computeLabelX(105, 'A very long label', area, 'center', true),
125
+ ).toEqual({
126
+ x: 105,
127
+ textAnchor: 'middle',
128
+ });
152
129
  });
153
130
 
154
- it('resolves dataX through axis config data when provided', () => {
155
- const ctx = makeContext();
156
- const getXAxisConfig = (): BaseAxisProps => ({
157
- scaleType: 'band',
158
- data: [100, 200, 300],
131
+ it('centres the label on the point when it fits inside the bounds', () => {
132
+ expect(computeLabelX(200, 'Peak', area, 'auto', true)).toEqual({
133
+ x: 200,
134
+ textAnchor: 'middle',
159
135
  });
160
-
161
- renderHook(() => useMagneticRegistration(true, 200, getXAxisConfig, ctx));
162
-
163
- expect(ctx.register).toHaveBeenCalledWith(1);
164
136
  });
165
137
 
166
- it('does not register when axis data exists but does not contain the value', () => {
167
- const ctx = makeContext();
168
- const getXAxisConfig = (): BaseAxisProps => ({
169
- scaleType: 'band',
170
- data: [100, 200, 300],
138
+ it('anchors near the left edge, offset by half the arrow width from the point', () => {
139
+ expect(computeLabelX(105, 'Long label', area, 'auto', true)).toEqual({
140
+ x: 105 - halfArrow,
141
+ textAnchor: 'start',
171
142
  });
172
-
173
- renderHook(() => useMagneticRegistration(true, 999, getXAxisConfig, ctx));
174
-
175
- expect(ctx.register).not.toHaveBeenCalled();
176
143
  });
177
144
 
178
- it('re-registers when dataX changes', () => {
179
- const ctx = makeContext();
180
- const { rerender } = renderHook(
181
- ({ dataX }: { dataX: number }) =>
182
- useMagneticRegistration(true, dataX, noAxisConfig, ctx),
183
- { initialProps: { dataX: 2 } },
184
- );
185
-
186
- expect(ctx.register).toHaveBeenCalledWith(2);
187
-
188
- rerender({ dataX: 4 });
189
-
190
- expect(ctx.unregister).toHaveBeenCalledWith(2);
191
- expect(ctx.register).toHaveBeenCalledWith(4);
145
+ it('anchors near the right edge, offset by half the arrow width from the point', () => {
146
+ expect(computeLabelX(295, 'Long label', area, 'auto', true)).toEqual({
147
+ x: 295 + halfArrow,
148
+ textAnchor: 'end',
149
+ });
192
150
  });
193
151
 
194
- it('unregisters and stops when magnetic switches to false', () => {
195
- const ctx = makeContext();
196
- const { rerender } = renderHook(
197
- ({ magnetic }: { magnetic: boolean }) =>
198
- useMagneticRegistration(magnetic, 3, noAxisConfig, ctx),
199
- { initialProps: { magnetic: true } },
200
- );
201
-
202
- expect(ctx.register).toHaveBeenCalledWith(3);
203
-
204
- rerender({ magnetic: false });
205
-
206
- expect(ctx.unregister).toHaveBeenCalledWith(3);
152
+ it('omits the arrow offset when no arrow is rendered', () => {
153
+ expect(computeLabelX(105, 'Long label', area, 'auto', false)).toEqual({
154
+ x: 105,
155
+ textAnchor: 'start',
156
+ });
157
+ expect(computeLabelX(295, 'Long label', area, 'auto', false)).toEqual({
158
+ x: 295,
159
+ textAnchor: 'end',
160
+ });
207
161
  });
208
162
  });
@@ -1,9 +1,15 @@
1
- import { useEffect } from 'react';
2
-
3
1
  import type { DrawingArea } from '../../utils/types';
4
2
  import type { BaseAxisProps } from '../Axis';
5
- import { LABEL_FONT_SIZE, ARROW_WIDTH, ARROW_HEIGHT, GAP } from './constants';
6
- import type { MagneticPointsContextValue } from './pointContext/magneticPointsContext';
3
+ import {
4
+ ARROW_HEIGHT,
5
+ ARROW_WIDTH,
6
+ GAP,
7
+ LABEL_CHAR_WIDTH_RATIO,
8
+ LABEL_FONT_SIZE,
9
+ } from './constants';
10
+ import type { LabelAlignment } from './types';
11
+
12
+ export type LabelTextAnchor = 'start' | 'middle' | 'end';
7
13
 
8
14
  export const isWithinBounds = (
9
15
  px: number,
@@ -63,22 +69,33 @@ export const resolveDataXToIndex = (
63
69
  };
64
70
 
65
71
  /**
66
- * Registers/unregisters a data index as a magnetic snap target
67
- * when the Point has `magnetic` enabled.
72
+ * Computes the label's horizontal placement, keeping it inside the drawing area
73
+ * near the left/right edges. Centred on the point when it fits; otherwise
74
+ * anchored to the edge and aligned with the arrow's outer vertex (offset by half
75
+ * the arrow width from the point, when an arrow is rendered).
68
76
  */
69
- export const useMagneticRegistration = (
70
- magnetic: boolean,
71
- dataX: number,
72
- getXAxisConfig: () => BaseAxisProps | undefined,
73
- { register, unregister }: MagneticPointsContextValue,
74
- ): void => {
75
- useEffect(() => {
76
- if (!magnetic) return;
77
- const index = resolveDataXToIndex(dataX, getXAxisConfig());
78
- if (index === undefined) return;
79
- register(index);
80
- return () => unregister(index);
81
- }, [magnetic, dataX, getXAxisConfig, register, unregister]);
77
+ export const computeLabelX = (
78
+ pixelX: number,
79
+ label: string,
80
+ area: DrawingArea,
81
+ alignment: LabelAlignment,
82
+ hasArrow: boolean,
83
+ ): { x: number; textAnchor: LabelTextAnchor } => {
84
+ if (alignment === 'center') {
85
+ return { x: pixelX, textAnchor: 'middle' };
86
+ }
87
+
88
+ const halfWidth =
89
+ (label.length * LABEL_FONT_SIZE * LABEL_CHAR_WIDTH_RATIO) / 2;
90
+ const arrowOffset = hasArrow ? ARROW_WIDTH / 2 : 0;
91
+
92
+ if (pixelX - halfWidth < area.x) {
93
+ return { x: pixelX - arrowOffset, textAnchor: 'start' };
94
+ }
95
+ if (pixelX + halfWidth > area.x + area.width) {
96
+ return { x: pixelX + arrowOffset, textAnchor: 'end' };
97
+ }
98
+ return { x: pixelX, textAnchor: 'middle' };
82
99
  };
83
100
 
84
101
  /**
@@ -96,3 +113,44 @@ export const computeLabelY = (
96
113
  ? pixelY - radius - arrowOffset - GAP
97
114
  : pixelY + radius + arrowOffset + GAP + LABEL_FONT_SIZE;
98
115
  };
116
+
117
+ /**
118
+ * Computes where a point's label and its connector arrow are drawn: the
119
+ * (optionally clamped) horizontal placement, the vertical baseline, and whether
120
+ * the arrow is rendered. Label content is resolved separately via
121
+ * {@link resolveLabel}.
122
+ */
123
+ export const computeLabelGeometry = ({
124
+ text,
125
+ pixelX,
126
+ pixelY,
127
+ size,
128
+ labelPosition,
129
+ showLabelArrow,
130
+ area,
131
+ alignment,
132
+ }: {
133
+ text: string;
134
+ pixelX: number;
135
+ pixelY: number;
136
+ size: number;
137
+ labelPosition: 'top' | 'bottom';
138
+ showLabelArrow: boolean;
139
+ area: DrawingArea;
140
+ alignment: LabelAlignment;
141
+ }): {
142
+ x: number;
143
+ y: number;
144
+ textAnchor: LabelTextAnchor;
145
+ } => {
146
+ const { x, textAnchor } = computeLabelX(
147
+ pixelX,
148
+ text,
149
+ area,
150
+ alignment,
151
+ showLabelArrow,
152
+ );
153
+ const y = computeLabelY(pixelY, size / 2, labelPosition, showLabelArrow);
154
+
155
+ return { x, y, textAnchor };
156
+ };
@@ -55,13 +55,13 @@ export const computeYDomain = (
55
55
  if (!s.data) continue;
56
56
  for (const v of s.data) {
57
57
  if (v !== null && v !== undefined) {
58
- if (!hasValue) {
58
+ if (hasValue) {
59
+ if (v < min) min = v;
60
+ if (v > max) max = v;
61
+ } else {
59
62
  min = v;
60
63
  max = v;
61
64
  hasValue = true;
62
- } else {
63
- if (v < min) min = v;
64
- if (v > max) max = v;
65
65
  }
66
66
  }
67
67
  }