@ledgerhq/lumen-ui-rnative-visualization 0.1.4 → 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.
Files changed (61) hide show
  1. package/dist/module/lib/Components/CartesianChart/CartesianChart.js +6 -53
  2. package/dist/module/lib/Components/CartesianChart/CartesianChart.js.map +1 -1
  3. package/dist/module/lib/Components/CartesianChart/utils.js +63 -0
  4. package/dist/module/lib/Components/CartesianChart/utils.js.map +1 -0
  5. package/dist/module/lib/Components/CartesianChart/utils.test.js +86 -0
  6. package/dist/module/lib/Components/CartesianChart/utils.test.js.map +1 -0
  7. package/dist/module/lib/Components/LineChart/LineChart.stories.js.map +1 -1
  8. package/dist/module/lib/Components/Point/Point.js +84 -0
  9. package/dist/module/lib/Components/Point/Point.js.map +1 -0
  10. package/dist/module/lib/Components/Point/Point.stories.js +178 -0
  11. package/dist/module/lib/Components/Point/Point.stories.js.map +1 -0
  12. package/dist/module/lib/Components/Point/Point.test.js +228 -0
  13. package/dist/module/lib/Components/Point/Point.test.js.map +1 -0
  14. package/dist/module/lib/Components/Point/index.js +4 -0
  15. package/dist/module/lib/Components/Point/index.js.map +1 -0
  16. package/dist/module/lib/Components/Point/types.js +4 -0
  17. package/dist/module/lib/Components/Point/types.js.map +1 -0
  18. package/dist/module/lib/Components/Point/utils.js +44 -0
  19. package/dist/module/lib/Components/Point/utils.js.map +1 -0
  20. package/dist/module/lib/Components/Point/utils.test.js +91 -0
  21. package/dist/module/lib/Components/Point/utils.test.js.map +1 -0
  22. package/dist/module/lib/Components/index.js +1 -0
  23. package/dist/module/lib/Components/index.js.map +1 -1
  24. package/dist/module/lib/utils/scales/scales.js +15 -0
  25. package/dist/module/lib/utils/scales/scales.js.map +1 -1
  26. package/dist/module/lib/utils/scales/scales.test.js +90 -1
  27. package/dist/module/lib/utils/scales/scales.test.js.map +1 -1
  28. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts +1 -1
  29. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts.map +1 -1
  30. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts +4 -2
  31. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts.map +1 -1
  32. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts +19 -0
  33. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts.map +1 -0
  34. package/dist/typescript/src/lib/Components/Point/Point.d.ts +4 -0
  35. package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -0
  36. package/dist/typescript/src/lib/Components/Point/index.d.ts +3 -0
  37. package/dist/typescript/src/lib/Components/Point/index.d.ts.map +1 -0
  38. package/dist/typescript/src/lib/Components/Point/types.d.ts +74 -0
  39. package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -0
  40. package/dist/typescript/src/lib/Components/Point/utils.d.ts +24 -0
  41. package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -0
  42. package/dist/typescript/src/lib/Components/index.d.ts +1 -0
  43. package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
  44. package/dist/typescript/src/lib/utils/scales/scales.d.ts +10 -0
  45. package/dist/typescript/src/lib/utils/scales/scales.d.ts.map +1 -1
  46. package/package.json +3 -3
  47. package/src/lib/Components/CartesianChart/CartesianChart.tsx +12 -65
  48. package/src/lib/Components/CartesianChart/types.ts +4 -2
  49. package/src/lib/Components/CartesianChart/utils.test.ts +88 -0
  50. package/src/lib/Components/CartesianChart/utils.ts +65 -0
  51. package/src/lib/Components/LineChart/LineChart.stories.tsx +1 -1
  52. package/src/lib/Components/Point/Point.stories.tsx +152 -0
  53. package/src/lib/Components/Point/Point.test.tsx +197 -0
  54. package/src/lib/Components/Point/Point.tsx +100 -0
  55. package/src/lib/Components/Point/index.ts +2 -0
  56. package/src/lib/Components/Point/types.ts +76 -0
  57. package/src/lib/Components/Point/utils.test.ts +118 -0
  58. package/src/lib/Components/Point/utils.ts +70 -0
  59. package/src/lib/Components/index.ts +1 -0
  60. package/src/lib/utils/scales/scales.test.ts +54 -0
  61. 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,2 @@
1
+ export { Point, PointLabel } from './Point';
2
+ export type { PointLabelComponent, PointLabelProps, PointProps } from './types';
@@ -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
+ };
@@ -1 +1,2 @@
1
1
  export * from './LineChart';
2
+ export * from './Point';
@@ -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
+ };