@ledgerhq/lumen-ui-rnative-visualization 0.1.22 → 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.
- package/dist/module/lib/Components/Point/Point.js +78 -48
- package/dist/module/lib/Components/Point/Point.js.map +1 -1
- package/dist/module/lib/Components/Point/Point.test.js +41 -0
- package/dist/module/lib/Components/Point/Point.test.js.map +1 -1
- package/dist/module/lib/Components/Point/constants.js +2 -0
- package/dist/module/lib/Components/Point/constants.js.map +1 -1
- package/dist/module/lib/Components/Point/usePointGeometry.js +60 -0
- package/dist/module/lib/Components/Point/usePointGeometry.js.map +1 -0
- package/dist/module/lib/Components/Point/usePointGeometry.test.js +86 -0
- package/dist/module/lib/Components/Point/usePointGeometry.test.js.map +1 -0
- package/dist/module/lib/Components/Point/utils.js +58 -15
- package/dist/module/lib/Components/Point/utils.js.map +1 -1
- package/dist/module/lib/Components/Point/utils.test.js +37 -75
- package/dist/module/lib/Components/Point/utils.test.js.map +1 -1
- package/dist/module/lib/utils/domain/domain.js +4 -4
- package/dist/module/lib/utils/domain/domain.js.map +1 -1
- package/dist/typescript/src/lib/Components/Point/Point.d.ts +1 -1
- package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/Point/constants.d.ts +2 -0
- package/dist/typescript/src/lib/Components/Point/constants.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/Point/types.d.ts +32 -0
- package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/Point/usePointGeometry.d.ts +33 -0
- package/dist/typescript/src/lib/Components/Point/usePointGeometry.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/Point/utils.d.ts +30 -4
- package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/lib/Components/Point/Point.test.tsx +46 -0
- package/src/lib/Components/Point/Point.tsx +75 -56
- package/src/lib/Components/Point/constants.ts +2 -0
- package/src/lib/Components/Point/types.ts +41 -0
- package/src/lib/Components/Point/usePointGeometry.test.ts +95 -0
- package/src/lib/Components/Point/usePointGeometry.ts +77 -0
- package/src/lib/Components/Point/utils.test.ts +34 -80
- package/src/lib/Components/Point/utils.ts +77 -19
- 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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
|
|
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={
|
|
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 {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
{isLabelVisible && showLabelArrow && (
|
|
112
|
+
<PointArrow
|
|
113
|
+
x={pixel.x}
|
|
114
|
+
y={pixel.y}
|
|
115
|
+
size={size}
|
|
116
|
+
position={labelPosition}
|
|
102
117
|
/>
|
|
103
118
|
)}
|
|
104
|
-
{
|
|
105
|
-
<Label
|
|
106
|
-
{
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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('
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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('
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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('
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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('
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 {
|
|
6
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 (
|
|
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
|
}
|