@ledgerhq/lumen-ui-rnative-visualization 0.1.5 → 0.1.6
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/CartesianChart/CartesianChart.js +6 -53
- package/dist/module/lib/Components/CartesianChart/CartesianChart.js.map +1 -1
- package/dist/module/lib/Components/CartesianChart/utils.js +63 -0
- package/dist/module/lib/Components/CartesianChart/utils.js.map +1 -0
- package/dist/module/lib/Components/CartesianChart/utils.test.js +86 -0
- package/dist/module/lib/Components/CartesianChart/utils.test.js.map +1 -0
- package/dist/module/lib/Components/LineChart/LineChart.stories.js.map +1 -1
- package/dist/module/lib/Components/Point/Point.js +84 -0
- package/dist/module/lib/Components/Point/Point.js.map +1 -0
- package/dist/module/lib/Components/Point/Point.stories.js +178 -0
- package/dist/module/lib/Components/Point/Point.stories.js.map +1 -0
- package/dist/module/lib/Components/Point/Point.test.js +228 -0
- package/dist/module/lib/Components/Point/Point.test.js.map +1 -0
- package/dist/module/lib/Components/Point/index.js +4 -0
- package/dist/module/lib/Components/Point/index.js.map +1 -0
- package/dist/module/lib/Components/Point/types.js +4 -0
- package/dist/module/lib/Components/Point/types.js.map +1 -0
- package/dist/module/lib/Components/Point/utils.js +44 -0
- package/dist/module/lib/Components/Point/utils.js.map +1 -0
- package/dist/module/lib/Components/Point/utils.test.js +91 -0
- package/dist/module/lib/Components/Point/utils.test.js.map +1 -0
- package/dist/module/lib/Components/index.js +1 -0
- package/dist/module/lib/Components/index.js.map +1 -1
- package/dist/module/lib/utils/scales/scales.js +15 -0
- package/dist/module/lib/utils/scales/scales.js.map +1 -1
- package/dist/module/lib/utils/scales/scales.test.js +90 -1
- package/dist/module/lib/utils/scales/scales.test.js.map +1 -1
- package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts +1 -1
- package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts +4 -2
- package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts.map +1 -1
- package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts +19 -0
- package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/Point/Point.d.ts +4 -0
- package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/Point/index.d.ts +3 -0
- package/dist/typescript/src/lib/Components/Point/index.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/Point/types.d.ts +74 -0
- package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/Point/utils.d.ts +24 -0
- package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -0
- package/dist/typescript/src/lib/Components/index.d.ts +1 -0
- package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
- package/dist/typescript/src/lib/utils/scales/scales.d.ts +10 -0
- package/dist/typescript/src/lib/utils/scales/scales.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/lib/Components/CartesianChart/CartesianChart.tsx +12 -65
- package/src/lib/Components/CartesianChart/types.ts +4 -2
- package/src/lib/Components/CartesianChart/utils.test.ts +88 -0
- package/src/lib/Components/CartesianChart/utils.ts +65 -0
- package/src/lib/Components/LineChart/LineChart.stories.tsx +1 -1
- package/src/lib/Components/Point/Point.stories.tsx +152 -0
- package/src/lib/Components/Point/Point.test.tsx +197 -0
- package/src/lib/Components/Point/Point.tsx +100 -0
- package/src/lib/Components/Point/index.ts +2 -0
- package/src/lib/Components/Point/types.ts +76 -0
- package/src/lib/Components/Point/utils.test.ts +118 -0
- package/src/lib/Components/Point/utils.ts +70 -0
- package/src/lib/Components/index.ts +1 -0
- package/src/lib/utils/scales/scales.test.ts +54 -0
- package/src/lib/utils/scales/scales.ts +21 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
2
|
+
import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
|
|
3
|
+
import { ThemeProvider } from '@ledgerhq/lumen-ui-rnative';
|
|
4
|
+
import { fireEvent, render } from '@testing-library/react-native';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { Text as SvgText } from 'react-native-svg';
|
|
7
|
+
|
|
8
|
+
import { CartesianChart } from '../CartesianChart';
|
|
9
|
+
|
|
10
|
+
import { Point } from './Point';
|
|
11
|
+
|
|
12
|
+
const sampleSeries = [{ id: 's1', stroke: '#000', data: [10, 20, 30, 40, 50] }];
|
|
13
|
+
|
|
14
|
+
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
15
|
+
<ThemeProvider themes={ledgerLiveThemes} colorScheme='light'>
|
|
16
|
+
{children}
|
|
17
|
+
</ThemeProvider>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const renderInChart = (
|
|
21
|
+
pointElement: React.ReactNode,
|
|
22
|
+
{
|
|
23
|
+
width = 400,
|
|
24
|
+
height = 200,
|
|
25
|
+
xAxis,
|
|
26
|
+
yAxis,
|
|
27
|
+
}: {
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
xAxis?: Parameters<typeof CartesianChart>[0]['xAxis'];
|
|
31
|
+
yAxis?: Parameters<typeof CartesianChart>[0]['yAxis'];
|
|
32
|
+
} = {},
|
|
33
|
+
) =>
|
|
34
|
+
render(
|
|
35
|
+
<Wrapper>
|
|
36
|
+
<CartesianChart
|
|
37
|
+
series={sampleSeries}
|
|
38
|
+
width={width}
|
|
39
|
+
height={height}
|
|
40
|
+
xAxis={xAxis}
|
|
41
|
+
yAxis={yAxis}
|
|
42
|
+
>
|
|
43
|
+
{pointElement}
|
|
44
|
+
</CartesianChart>
|
|
45
|
+
</Wrapper>,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
describe('Point', () => {
|
|
49
|
+
it('renders a circle at the projected position', () => {
|
|
50
|
+
const { getByTestId } = renderInChart(<Point dataX={2} dataY={30} />);
|
|
51
|
+
const circle = getByTestId('point-circle');
|
|
52
|
+
expect(circle).toBeTruthy();
|
|
53
|
+
expect(circle.props.r).toBe(5);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders with custom color and size', () => {
|
|
57
|
+
const { getByTestId } = renderInChart(
|
|
58
|
+
<Point dataX={2} dataY={30} color='#FF0000' size={16} />,
|
|
59
|
+
);
|
|
60
|
+
const circle = getByTestId('point-circle');
|
|
61
|
+
expect(circle.props.fill).toBe('#FF0000');
|
|
62
|
+
expect(circle.props.r).toBe(8);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders a string label', () => {
|
|
66
|
+
const { getByText } = renderInChart(
|
|
67
|
+
<Point dataX={2} dataY={30} label='Peak' />,
|
|
68
|
+
);
|
|
69
|
+
expect(getByText('Peak')).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders a label via function', () => {
|
|
73
|
+
const { getByText } = renderInChart(
|
|
74
|
+
<Point dataX={2} dataY={30} label={(i) => `#${i}`} />,
|
|
75
|
+
);
|
|
76
|
+
expect(getByText('#2')).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('renders custom LabelComponent', () => {
|
|
80
|
+
const CustomLabel = ({
|
|
81
|
+
x,
|
|
82
|
+
y,
|
|
83
|
+
children,
|
|
84
|
+
}: {
|
|
85
|
+
x: number;
|
|
86
|
+
y: number;
|
|
87
|
+
children: string;
|
|
88
|
+
}) => (
|
|
89
|
+
<SvgText testID='custom-label' x={x} y={y}>
|
|
90
|
+
{children}
|
|
91
|
+
</SvgText>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const { getByTestId } = renderInChart(
|
|
95
|
+
<Point dataX={2} dataY={30} label='Peak' LabelComponent={CustomLabel} />,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(getByTestId('custom-label')).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not render LabelComponent when label is absent', () => {
|
|
102
|
+
const CustomLabel = ({
|
|
103
|
+
children,
|
|
104
|
+
}: {
|
|
105
|
+
x: number;
|
|
106
|
+
y: number;
|
|
107
|
+
children: string;
|
|
108
|
+
}) => <SvgText testID='custom-label'>{children}</SvgText>;
|
|
109
|
+
|
|
110
|
+
const { queryByTestId } = renderInChart(
|
|
111
|
+
<Point dataX={2} dataY={30} LabelComponent={CustomLabel} />,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(queryByTestId('custom-label')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('hides the circle when hidePoint is true but shows label', () => {
|
|
118
|
+
const { queryByTestId, getByText } = renderInChart(
|
|
119
|
+
<Point dataX={2} dataY={30} hidePoint label='Still here' />,
|
|
120
|
+
);
|
|
121
|
+
expect(queryByTestId('point-circle')).toBeNull();
|
|
122
|
+
expect(getByText('Still here')).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null when point is outside drawing area', () => {
|
|
126
|
+
const { queryByTestId } = renderInChart(
|
|
127
|
+
<Point dataX={-100} dataY={-100} />,
|
|
128
|
+
);
|
|
129
|
+
expect(queryByTestId('point-group')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('works with band (categorical) x-axis scale', () => {
|
|
133
|
+
const { getByTestId } = renderInChart(<Point dataX={1} dataY={30} />, {
|
|
134
|
+
xAxis: { scaleType: 'band' },
|
|
135
|
+
});
|
|
136
|
+
const circle = getByTestId('point-circle');
|
|
137
|
+
expect(circle).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('renders arrow when label is present and showArrow is true', () => {
|
|
141
|
+
const { getByTestId } = renderInChart(
|
|
142
|
+
<Point dataX={2} dataY={30} label='Peak' />,
|
|
143
|
+
);
|
|
144
|
+
expect(getByTestId('point-arrow')).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not render arrow when no label is present', () => {
|
|
148
|
+
const { queryByTestId } = renderInChart(<Point dataX={2} dataY={30} />);
|
|
149
|
+
expect(queryByTestId('point-arrow')).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not render arrow when showArrow is false', () => {
|
|
153
|
+
const { queryByTestId } = renderInChart(
|
|
154
|
+
<Point dataX={2} dataY={30} label='Peak' showLabelArrow={false} />,
|
|
155
|
+
);
|
|
156
|
+
expect(queryByTestId('point-arrow')).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('renders label below the point when labelPosition is bottom', () => {
|
|
160
|
+
const topSpy = jest.fn(
|
|
161
|
+
({ y }: { x: number; y: number; children: string }) => (
|
|
162
|
+
<SvgText>{y}</SvgText>
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
const bottomSpy = jest.fn(
|
|
166
|
+
({ y }: { x: number; y: number; children: string }) => (
|
|
167
|
+
<SvgText>{y}</SvgText>
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
renderInChart(
|
|
172
|
+
<Point dataX={2} dataY={30} label='T' LabelComponent={topSpy} />,
|
|
173
|
+
);
|
|
174
|
+
renderInChart(
|
|
175
|
+
<Point
|
|
176
|
+
dataX={2}
|
|
177
|
+
dataY={30}
|
|
178
|
+
label='B'
|
|
179
|
+
labelPosition='bottom'
|
|
180
|
+
LabelComponent={bottomSpy}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const topY = topSpy.mock.calls[0][0].y;
|
|
185
|
+
const bottomY = bottomSpy.mock.calls[0][0].y;
|
|
186
|
+
expect(bottomY).toBeGreaterThan(topY);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('calls onPress when the point is pressed', () => {
|
|
190
|
+
const onPress = jest.fn();
|
|
191
|
+
const { getByTestId } = renderInChart(
|
|
192
|
+
<Point dataX={2} dataY={30} onPress={onPress} />,
|
|
193
|
+
);
|
|
194
|
+
fireEvent.press(getByTestId('point-group'));
|
|
195
|
+
expect(onPress).toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useTheme } from '@ledgerhq/lumen-ui-rnative';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { Circle, G, Polygon, Text as SvgText } from 'react-native-svg';
|
|
4
|
+
|
|
5
|
+
import { projectPoint } from '../../utils/scales/scales';
|
|
6
|
+
import { useCartesianChartContext } from '../CartesianChart/context';
|
|
7
|
+
|
|
8
|
+
import type { PointLabelProps, PointProps } from './types';
|
|
9
|
+
import {
|
|
10
|
+
buildArrowPoints,
|
|
11
|
+
computeLabelY,
|
|
12
|
+
DEFAULT_SIZE,
|
|
13
|
+
isWithinBounds,
|
|
14
|
+
resolveLabel,
|
|
15
|
+
STROKE_WIDTH,
|
|
16
|
+
} from './utils';
|
|
17
|
+
|
|
18
|
+
export function PointLabel({
|
|
19
|
+
textAnchor = 'middle',
|
|
20
|
+
...props
|
|
21
|
+
}: Readonly<PointLabelProps>) {
|
|
22
|
+
const { theme } = useTheme();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<SvgText
|
|
26
|
+
textAnchor={textAnchor}
|
|
27
|
+
fill={theme.colors.text.base}
|
|
28
|
+
fontSize={theme.typographies.body4.fontSize}
|
|
29
|
+
fontWeight={theme.typographies.body4.fontWeight}
|
|
30
|
+
fontFamily={theme.fontFamilies.sans}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Point({
|
|
37
|
+
dataX,
|
|
38
|
+
dataY,
|
|
39
|
+
color,
|
|
40
|
+
label,
|
|
41
|
+
LabelComponent,
|
|
42
|
+
labelPosition = 'top',
|
|
43
|
+
hidePoint = false,
|
|
44
|
+
showLabelArrow = true,
|
|
45
|
+
size = DEFAULT_SIZE,
|
|
46
|
+
onPress,
|
|
47
|
+
}: Readonly<PointProps>) {
|
|
48
|
+
const { getXScale, getYScale, drawingArea } = useCartesianChartContext();
|
|
49
|
+
const { theme } = useTheme();
|
|
50
|
+
|
|
51
|
+
const xScale = getXScale();
|
|
52
|
+
const yScale = getYScale();
|
|
53
|
+
|
|
54
|
+
const radius = size / 2;
|
|
55
|
+
const fill = color ?? theme.colors.bg.mutedStrong;
|
|
56
|
+
|
|
57
|
+
const pixel = useMemo(() => {
|
|
58
|
+
if (!xScale || !yScale) return undefined;
|
|
59
|
+
return projectPoint(dataX, dataY, xScale, yScale);
|
|
60
|
+
}, [dataX, dataY, xScale, yScale]);
|
|
61
|
+
|
|
62
|
+
if (!pixel || !isWithinBounds(pixel.x, pixel.y, drawingArea)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resolvedLabel = resolveLabel(label, dataX);
|
|
67
|
+
const hasLabel = resolvedLabel != null;
|
|
68
|
+
const renderArrow = showLabelArrow && hasLabel;
|
|
69
|
+
const labelY = computeLabelY(pixel.y, radius, labelPosition, renderArrow);
|
|
70
|
+
|
|
71
|
+
const Label = LabelComponent ?? PointLabel;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<G testID='point-group' onPress={onPress}>
|
|
75
|
+
{!hidePoint && (
|
|
76
|
+
<Circle
|
|
77
|
+
testID='point-circle'
|
|
78
|
+
cx={pixel.x}
|
|
79
|
+
cy={pixel.y}
|
|
80
|
+
r={radius}
|
|
81
|
+
fill={fill}
|
|
82
|
+
stroke={theme.colors.bg.canvas}
|
|
83
|
+
strokeWidth={STROKE_WIDTH}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{renderArrow && (
|
|
87
|
+
<Polygon
|
|
88
|
+
testID='point-arrow'
|
|
89
|
+
points={buildArrowPoints(pixel.x, pixel.y, radius, labelPosition)}
|
|
90
|
+
fill={theme.colors.text.base}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
{resolvedLabel != null && (
|
|
94
|
+
<Label x={pixel.x} y={labelY}>
|
|
95
|
+
{resolvedLabel}
|
|
96
|
+
</Label>
|
|
97
|
+
)}
|
|
98
|
+
</G>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import type { GestureResponderEvent } from 'react-native';
|
|
3
|
+
import type { TextProps } from 'react-native-svg';
|
|
4
|
+
|
|
5
|
+
export type PointLabelProps = {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
children: string;
|
|
9
|
+
} & Omit<TextProps, 'x' | 'y' | 'children'>;
|
|
10
|
+
|
|
11
|
+
export type PointLabelComponent = ComponentType<PointLabelProps>;
|
|
12
|
+
|
|
13
|
+
export type PointProps = {
|
|
14
|
+
/**
|
|
15
|
+
* X coordinate in data space (index or explicit value).
|
|
16
|
+
*/
|
|
17
|
+
dataX: number;
|
|
18
|
+
/**
|
|
19
|
+
* Y coordinate in data space.
|
|
20
|
+
*/
|
|
21
|
+
dataY: number;
|
|
22
|
+
/**
|
|
23
|
+
* Fill color of the point circle.
|
|
24
|
+
* Defaults to `theme.colors.bg.mutedStrong`.
|
|
25
|
+
*
|
|
26
|
+
* Use the `useTheme` hook from `@ledgerhq/lumen-ui-rnative` to
|
|
27
|
+
* access design-token colors at runtime:
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* import { useTheme } from '@ledgerhq/lumen-ui-rnative';
|
|
32
|
+
*
|
|
33
|
+
* const { theme } = useTheme();
|
|
34
|
+
* <Point dataX={0} dataY={42} color={theme.colors.bg.errorStrong} />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
color?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Text label displayed near the point.
|
|
40
|
+
* A string is rendered directly; a function receives `dataX` and returns
|
|
41
|
+
* the label string, enabling index-based formatting.
|
|
42
|
+
*/
|
|
43
|
+
label?: string | ((dataIndex: number) => string);
|
|
44
|
+
/**
|
|
45
|
+
* Custom component rendered instead of the default `PointLabel`.
|
|
46
|
+
* Receives pixel coordinates (`x`, `y`) and the resolved label as
|
|
47
|
+
* `children`. Use `PointLabel` inside your component to retain
|
|
48
|
+
* theme styling while customising layout or appearance.
|
|
49
|
+
*/
|
|
50
|
+
LabelComponent?: PointLabelComponent;
|
|
51
|
+
/**
|
|
52
|
+
* Placement of the label relative to the point.
|
|
53
|
+
* @default 'top'
|
|
54
|
+
*/
|
|
55
|
+
labelPosition?: 'top' | 'bottom';
|
|
56
|
+
/**
|
|
57
|
+
* When `true`, the point circle is hidden but the label (if any) is still rendered.
|
|
58
|
+
* @default false
|
|
59
|
+
*/
|
|
60
|
+
hidePoint?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Diameter of the point circle in pixels.
|
|
63
|
+
* Internally converted to an SVG radius (`size / 2`).
|
|
64
|
+
* @default 10
|
|
65
|
+
*/
|
|
66
|
+
size?: number;
|
|
67
|
+
/**
|
|
68
|
+
* When `true`, an arrow is displayed pointing from the point to the label.
|
|
69
|
+
* @default true
|
|
70
|
+
*/
|
|
71
|
+
showLabelArrow?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Called when the point is pressed.
|
|
74
|
+
*/
|
|
75
|
+
onPress?: (event: GestureResponderEvent) => void;
|
|
76
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ARROW_HEIGHT,
|
|
5
|
+
ARROW_WIDTH,
|
|
6
|
+
buildArrowPoints,
|
|
7
|
+
computeLabelY,
|
|
8
|
+
GAP,
|
|
9
|
+
isWithinBounds,
|
|
10
|
+
LABEL_FONT_SIZE,
|
|
11
|
+
resolveLabel,
|
|
12
|
+
} from './utils';
|
|
13
|
+
|
|
14
|
+
describe('isWithinBounds', () => {
|
|
15
|
+
const area = { x: 10, y: 20, width: 100, height: 50 };
|
|
16
|
+
|
|
17
|
+
it('returns true for a point inside the area', () => {
|
|
18
|
+
expect(isWithinBounds(50, 40, area)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns true on the top-left edge', () => {
|
|
22
|
+
expect(isWithinBounds(10, 20, area)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns true on the bottom-right edge', () => {
|
|
26
|
+
expect(isWithinBounds(110, 70, area)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns false when x is left of the area', () => {
|
|
30
|
+
expect(isWithinBounds(9, 40, area)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns false when x is right of the area', () => {
|
|
34
|
+
expect(isWithinBounds(111, 40, area)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns false when y is above the area', () => {
|
|
38
|
+
expect(isWithinBounds(50, 19, area)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns false when y is below the area', () => {
|
|
42
|
+
expect(isWithinBounds(50, 71, area)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('buildArrowPoints', () => {
|
|
47
|
+
const cx = 100;
|
|
48
|
+
const cy = 200;
|
|
49
|
+
const radius = 5;
|
|
50
|
+
const halfW = ARROW_WIDTH / 2;
|
|
51
|
+
|
|
52
|
+
it('builds a downward-pointing arrow for top position', () => {
|
|
53
|
+
const result = buildArrowPoints(cx, cy, radius, 'top');
|
|
54
|
+
const tipY = cy - radius - GAP;
|
|
55
|
+
const baseY = tipY - ARROW_HEIGHT;
|
|
56
|
+
expect(result).toBe(
|
|
57
|
+
`${cx},${tipY} ${cx - halfW},${baseY} ${cx + halfW},${baseY}`,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('builds an upward-pointing arrow for bottom position', () => {
|
|
62
|
+
const result = buildArrowPoints(cx, cy, radius, 'bottom');
|
|
63
|
+
const tipY = cy + radius + GAP;
|
|
64
|
+
const baseY = tipY + ARROW_HEIGHT;
|
|
65
|
+
expect(result).toBe(
|
|
66
|
+
`${cx},${tipY} ${cx - halfW},${baseY} ${cx + halfW},${baseY}`,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('resolveLabel', () => {
|
|
72
|
+
it('returns a string label as-is', () => {
|
|
73
|
+
expect(resolveLabel('Peak', 0)).toBe('Peak');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('calls a function label with dataX', () => {
|
|
77
|
+
expect(resolveLabel((i) => `#${i}`, 3)).toBe('#3');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns undefined for an undefined label', () => {
|
|
81
|
+
expect(resolveLabel(undefined, 0)).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns undefined for an empty string', () => {
|
|
85
|
+
expect(resolveLabel('', 0)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns undefined when a function returns an empty string', () => {
|
|
89
|
+
expect(resolveLabel(() => '', 0)).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('computeLabelY', () => {
|
|
94
|
+
const pixelY = 100;
|
|
95
|
+
const radius = 5;
|
|
96
|
+
|
|
97
|
+
it('positions label above the point when labelPosition is top with arrow', () => {
|
|
98
|
+
const result = computeLabelY(pixelY, radius, 'top', true);
|
|
99
|
+
const arrowOffset = ARROW_HEIGHT + GAP;
|
|
100
|
+
expect(result).toBe(pixelY - radius - arrowOffset - GAP);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('positions label above the point when labelPosition is top without arrow', () => {
|
|
104
|
+
const result = computeLabelY(pixelY, radius, 'top', false);
|
|
105
|
+
expect(result).toBe(pixelY - radius - GAP - GAP);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('positions label below the point when labelPosition is bottom with arrow', () => {
|
|
109
|
+
const result = computeLabelY(pixelY, radius, 'bottom', true);
|
|
110
|
+
const arrowOffset = ARROW_HEIGHT + GAP;
|
|
111
|
+
expect(result).toBe(pixelY + radius + arrowOffset + GAP + LABEL_FONT_SIZE);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('positions label below the point when labelPosition is bottom without arrow', () => {
|
|
115
|
+
const result = computeLabelY(pixelY, radius, 'bottom', false);
|
|
116
|
+
expect(result).toBe(pixelY + radius + GAP + GAP + LABEL_FONT_SIZE);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DrawingArea } from '../../utils/types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SIZE = 10;
|
|
4
|
+
export const STROKE_WIDTH = 2;
|
|
5
|
+
export const LABEL_FONT_SIZE = 10;
|
|
6
|
+
export const ARROW_WIDTH = 6;
|
|
7
|
+
export const ARROW_HEIGHT = 4;
|
|
8
|
+
export const GAP = 4;
|
|
9
|
+
|
|
10
|
+
export const isWithinBounds = (
|
|
11
|
+
px: number,
|
|
12
|
+
py: number,
|
|
13
|
+
area: DrawingArea,
|
|
14
|
+
): boolean =>
|
|
15
|
+
px >= area.x &&
|
|
16
|
+
px <= area.x + area.width &&
|
|
17
|
+
py >= area.y &&
|
|
18
|
+
py <= area.y + area.height;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds the three SVG points of the arrow triangle.
|
|
22
|
+
*
|
|
23
|
+
* `'top'` → arrow sits below the label, tip pointing **down** toward the circle.
|
|
24
|
+
* `'bottom'` → arrow sits above the label, tip pointing **up** toward the circle.
|
|
25
|
+
*/
|
|
26
|
+
export const buildArrowPoints = (
|
|
27
|
+
cx: number,
|
|
28
|
+
cy: number,
|
|
29
|
+
radius: number,
|
|
30
|
+
position: 'top' | 'bottom',
|
|
31
|
+
): string => {
|
|
32
|
+
const halfW = ARROW_WIDTH / 2;
|
|
33
|
+
|
|
34
|
+
if (position === 'top') {
|
|
35
|
+
const tipY = cy - radius - GAP;
|
|
36
|
+
const baseY = tipY - ARROW_HEIGHT;
|
|
37
|
+
return `${cx},${tipY} ${cx - halfW},${baseY} ${cx + halfW},${baseY}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tipY = cy + radius + GAP;
|
|
41
|
+
const baseY = tipY + ARROW_HEIGHT;
|
|
42
|
+
return `${cx},${tipY} ${cx - halfW},${baseY} ${cx + halfW},${baseY}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the label prop to a string or undefined.
|
|
47
|
+
*/
|
|
48
|
+
export const resolveLabel = (
|
|
49
|
+
label: string | ((dataIndex: number) => string) | undefined,
|
|
50
|
+
dataX: number,
|
|
51
|
+
): string | undefined => {
|
|
52
|
+
const resolved = typeof label === 'function' ? label(dataX) : label;
|
|
53
|
+
return resolved === '' ? undefined : resolved;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Computes the vertical position of the label text baseline.
|
|
58
|
+
*/
|
|
59
|
+
export const computeLabelY = (
|
|
60
|
+
pixelY: number,
|
|
61
|
+
radius: number,
|
|
62
|
+
labelPosition: 'top' | 'bottom',
|
|
63
|
+
renderArrow: boolean,
|
|
64
|
+
): number => {
|
|
65
|
+
const arrowOffset = renderArrow ? ARROW_HEIGHT + GAP : GAP;
|
|
66
|
+
|
|
67
|
+
return labelPosition === 'top'
|
|
68
|
+
? pixelY - radius - arrowOffset - GAP
|
|
69
|
+
: pixelY + radius + arrowOffset + GAP + LABEL_FONT_SIZE;
|
|
70
|
+
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isBandScaleType,
|
|
7
7
|
isCategoricalScale,
|
|
8
8
|
isNumericScale,
|
|
9
|
+
projectPoint,
|
|
9
10
|
} from './scales';
|
|
10
11
|
|
|
11
12
|
describe('getNumericScale', () => {
|
|
@@ -131,3 +132,56 @@ describe('isCategoricalScale / isNumericScale', () => {
|
|
|
131
132
|
expect(isNumericScale(bandScale)).toBe(false);
|
|
132
133
|
});
|
|
133
134
|
});
|
|
135
|
+
|
|
136
|
+
describe('projectPoint', () => {
|
|
137
|
+
it('should project using two linear scales', () => {
|
|
138
|
+
const xScale = getNumericScale({
|
|
139
|
+
scaleType: 'linear',
|
|
140
|
+
domain: { min: 0, max: 10 },
|
|
141
|
+
range: { min: 0, max: 500 },
|
|
142
|
+
});
|
|
143
|
+
const yScale = getNumericScale({
|
|
144
|
+
scaleType: 'linear',
|
|
145
|
+
domain: { min: 0, max: 100 },
|
|
146
|
+
range: { min: 300, max: 0 },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const pt = projectPoint(5, 50, xScale, yScale);
|
|
150
|
+
expect(pt.x).toBe(250);
|
|
151
|
+
expect(pt.y).toBe(150);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should center on band for a categorical x scale', () => {
|
|
155
|
+
const xScale = getCategoricalScale({
|
|
156
|
+
domain: { min: 0, max: 3 },
|
|
157
|
+
range: { min: 0, max: 400 },
|
|
158
|
+
padding: 0,
|
|
159
|
+
});
|
|
160
|
+
const yScale = getNumericScale({
|
|
161
|
+
scaleType: 'linear',
|
|
162
|
+
domain: { min: 0, max: 100 },
|
|
163
|
+
range: { min: 200, max: 0 },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const pt = projectPoint(1, 50, xScale, yScale);
|
|
167
|
+
const expectedX = (xScale(1) ?? 0) + xScale.bandwidth() / 2;
|
|
168
|
+
expect(pt.x).toBe(expectedX);
|
|
169
|
+
expect(pt.y).toBe(100);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should handle edge values at domain boundaries', () => {
|
|
173
|
+
const xScale = getNumericScale({
|
|
174
|
+
scaleType: 'linear',
|
|
175
|
+
domain: { min: 0, max: 10 },
|
|
176
|
+
range: { min: 0, max: 500 },
|
|
177
|
+
});
|
|
178
|
+
const yScale = getNumericScale({
|
|
179
|
+
scaleType: 'linear',
|
|
180
|
+
domain: { min: 0, max: 100 },
|
|
181
|
+
range: { min: 300, max: 0 },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(projectPoint(0, 0, xScale, yScale)).toEqual({ x: 0, y: 300 });
|
|
185
|
+
expect(projectPoint(10, 100, xScale, yScale)).toEqual({ x: 500, y: 0 });
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -72,3 +72,24 @@ export const isNumericScale = (
|
|
|
72
72
|
): scale is NumericScale => {
|
|
73
73
|
return !isCategoricalScale(scale);
|
|
74
74
|
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Projects a single data-space coordinate pair to pixel-space
|
|
78
|
+
* using the provided x and y scale functions.
|
|
79
|
+
*
|
|
80
|
+
* For categorical (band) scales the point is anchored at the center of the band.
|
|
81
|
+
*/
|
|
82
|
+
export const projectPoint = (
|
|
83
|
+
dataX: number,
|
|
84
|
+
dataY: number,
|
|
85
|
+
xScale: ChartScaleFunction,
|
|
86
|
+
yScale: ChartScaleFunction,
|
|
87
|
+
): { x: number; y: number } => {
|
|
88
|
+
const x = isCategoricalScale(xScale)
|
|
89
|
+
? (xScale(dataX) ?? 0) + xScale.bandwidth() / 2
|
|
90
|
+
: xScale(dataX);
|
|
91
|
+
const y = isCategoricalScale(yScale)
|
|
92
|
+
? (yScale(dataY) ?? 0) + yScale.bandwidth() / 2
|
|
93
|
+
: yScale(dataY);
|
|
94
|
+
return { x: x, y };
|
|
95
|
+
};
|