@automattic/charts 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/dist/index.cjs +5013 -4565
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +108 -90
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +120 -64
- package/dist/index.d.ts +120 -64
- package/dist/index.js +5032 -4584
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/charts/area-chart/area-chart.module.scss +23 -0
- package/src/charts/area-chart/area-chart.tsx +444 -0
- package/src/charts/area-chart/index.ts +2 -0
- package/src/charts/area-chart/private/index.ts +2 -0
- package/src/charts/area-chart/private/overlays.tsx +123 -0
- package/src/charts/area-chart/private/validate-data.ts +31 -0
- package/src/charts/area-chart/test/area-chart.test.tsx +264 -0
- package/src/charts/area-chart/types.ts +51 -0
- package/src/charts/line-chart/index.ts +1 -1
- package/src/charts/line-chart/line-chart.tsx +8 -118
- package/src/charts/private/time-axis.ts +106 -0
- package/src/components/legend/legend.tsx +1 -0
- package/src/index.ts +2 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createRef } from 'react';
|
|
4
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
5
|
+
import AreaChart, { AreaChartUnresponsive } from '../area-chart';
|
|
6
|
+
import type { SingleChartRef } from '../../private/single-chart-context';
|
|
7
|
+
|
|
8
|
+
const mockRefCallback = jest.fn();
|
|
9
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
10
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
11
|
+
} ) );
|
|
12
|
+
|
|
13
|
+
describe( 'AreaChart', () => {
|
|
14
|
+
const defaultProps = {
|
|
15
|
+
width: 500,
|
|
16
|
+
height: 300,
|
|
17
|
+
data: [
|
|
18
|
+
{
|
|
19
|
+
label: 'Series A',
|
|
20
|
+
data: [
|
|
21
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
22
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
label: 'Series B',
|
|
27
|
+
data: [
|
|
28
|
+
{ date: new Date( '2024-01-01' ), value: 5, label: 'Jan 1' },
|
|
29
|
+
{ date: new Date( '2024-01-02' ), value: 15, label: 'Jan 2' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const renderWithProvider = ( props = {}, children: React.ReactNode = undefined ) => {
|
|
36
|
+
return render(
|
|
37
|
+
<GlobalChartsProvider>
|
|
38
|
+
<AreaChart { ...defaultProps } { ...props }>
|
|
39
|
+
{ children }
|
|
40
|
+
</AreaChart>
|
|
41
|
+
</GlobalChartsProvider>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const renderUnresponsive = ( props = {}, ref?: React.Ref< SingleChartRef > ) => {
|
|
46
|
+
return render(
|
|
47
|
+
<GlobalChartsProvider>
|
|
48
|
+
<AreaChartUnresponsive { ...defaultProps } { ...props } ref={ ref } />
|
|
49
|
+
</GlobalChartsProvider>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe( 'Data Validation', () => {
|
|
54
|
+
test( 'shows error when data is empty', () => {
|
|
55
|
+
renderWithProvider( { data: [] } );
|
|
56
|
+
expect( screen.getByText( /no data available/i ) ).toBeInTheDocument();
|
|
57
|
+
} );
|
|
58
|
+
|
|
59
|
+
test( 'shows error for null values', () => {
|
|
60
|
+
renderWithProvider( {
|
|
61
|
+
data: [
|
|
62
|
+
{
|
|
63
|
+
label: 'Series A',
|
|
64
|
+
data: [
|
|
65
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
66
|
+
{ date: new Date( '2024-01-02' ), value: 5 },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
} );
|
|
71
|
+
expect( screen.getByText( /invalid data/i ) ).toBeInTheDocument();
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
test( 'shows error for invalid dates', () => {
|
|
75
|
+
renderWithProvider( {
|
|
76
|
+
data: [
|
|
77
|
+
{
|
|
78
|
+
label: 'Series A',
|
|
79
|
+
data: [
|
|
80
|
+
{ date: new Date( 'invalid' ), value: 10 },
|
|
81
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
} );
|
|
86
|
+
expect( screen.getByText( /invalid data/i ) ).toBeInTheDocument();
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
test( 'shows error when a series has empty data', () => {
|
|
90
|
+
// A non-empty series guards against the "No data available" path
|
|
91
|
+
// for the top-level array; the empty-series check is a separate guard.
|
|
92
|
+
renderWithProvider( {
|
|
93
|
+
data: [
|
|
94
|
+
{
|
|
95
|
+
label: 'Series A',
|
|
96
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10 } ],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
label: 'Series B (empty)',
|
|
100
|
+
data: [],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
} );
|
|
104
|
+
expect( screen.getByText( /no data available/i ) ).toBeInTheDocument();
|
|
105
|
+
} );
|
|
106
|
+
|
|
107
|
+
test( 'renders with valid data', () => {
|
|
108
|
+
renderWithProvider();
|
|
109
|
+
expect( screen.getByRole( 'grid', { name: /area chart/i } ) ).toBeInTheDocument();
|
|
110
|
+
} );
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
describe( 'Stacking', () => {
|
|
114
|
+
test( 'is stacked by default', () => {
|
|
115
|
+
renderWithProvider();
|
|
116
|
+
// Both series should be rendered, regardless of mode.
|
|
117
|
+
expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
|
|
118
|
+
expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
|
|
119
|
+
} );
|
|
120
|
+
|
|
121
|
+
test( 'renders unstacked when stacked={ false }', () => {
|
|
122
|
+
renderWithProvider( { stacked: false } );
|
|
123
|
+
expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
|
|
124
|
+
expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
|
|
125
|
+
} );
|
|
126
|
+
|
|
127
|
+
test( 'accepts custom stackOffset', () => {
|
|
128
|
+
renderWithProvider( { stackOffset: 'expand' } );
|
|
129
|
+
expect( screen.getByRole( 'grid', { name: /area chart/i } ) ).toBeInTheDocument();
|
|
130
|
+
} );
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
describe( 'Legend', () => {
|
|
134
|
+
test( 'shows legend when showLegend is true', () => {
|
|
135
|
+
renderWithProvider( { showLegend: true } );
|
|
136
|
+
expect( screen.getByText( 'Series A' ) ).toBeInTheDocument();
|
|
137
|
+
expect( screen.getByText( 'Series B' ) ).toBeInTheDocument();
|
|
138
|
+
} );
|
|
139
|
+
|
|
140
|
+
test( 'hides legend by default', () => {
|
|
141
|
+
renderWithProvider();
|
|
142
|
+
expect( screen.queryByText( 'Series A' ) ).not.toBeInTheDocument();
|
|
143
|
+
} );
|
|
144
|
+
|
|
145
|
+
test( 'renders composition legend as child component', () => {
|
|
146
|
+
renderWithProvider( {}, <AreaChart.Legend /> );
|
|
147
|
+
expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
|
|
148
|
+
} );
|
|
149
|
+
} );
|
|
150
|
+
|
|
151
|
+
describe( 'Accessibility', () => {
|
|
152
|
+
test( 'chart container has expected ARIA attributes', () => {
|
|
153
|
+
renderWithProvider();
|
|
154
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
155
|
+
expect( chart ).toHaveAttribute( 'tabIndex', '0' );
|
|
156
|
+
expect( chart ).toHaveAttribute( 'aria-label', 'Area chart' );
|
|
157
|
+
} );
|
|
158
|
+
} );
|
|
159
|
+
|
|
160
|
+
describe( 'Chart Ref Interface', () => {
|
|
161
|
+
test( 'exposes getScales via ref', () => {
|
|
162
|
+
const ref = createRef< SingleChartRef >();
|
|
163
|
+
renderUnresponsive( {}, ref );
|
|
164
|
+
|
|
165
|
+
expect( ref.current?.getScales() ).toBeDefined();
|
|
166
|
+
expect( ref.current?.getScales()?.xScale ).toBeDefined();
|
|
167
|
+
expect( ref.current?.getScales()?.yScale ).toBeDefined();
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
test( 'exposes getChartDimensions via ref', () => {
|
|
171
|
+
const ref = createRef< SingleChartRef >();
|
|
172
|
+
renderUnresponsive( { width: 800, height: 400 }, ref );
|
|
173
|
+
|
|
174
|
+
const dimensions = ref.current?.getChartDimensions();
|
|
175
|
+
expect( dimensions?.width ).toBe( 800 );
|
|
176
|
+
expect( dimensions?.height ).toBe( 400 );
|
|
177
|
+
} );
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
describe( 'Tooltips', () => {
|
|
181
|
+
test( 'tooltips can be disabled', () => {
|
|
182
|
+
renderWithProvider( { withTooltips: false } );
|
|
183
|
+
// Tooltip portal element should not be present.
|
|
184
|
+
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
185
|
+
} );
|
|
186
|
+
} );
|
|
187
|
+
|
|
188
|
+
describe( 'Hover glyphs', () => {
|
|
189
|
+
// Trigger the AccessibleTooltip's keyboard nav so a tooltip is opened
|
|
190
|
+
// against a known datum index, which is the only reliable way to
|
|
191
|
+
// surface the visx TooltipContext state in a jsdom environment.
|
|
192
|
+
const focusFirstDatum = async () => {
|
|
193
|
+
const user = userEvent.setup();
|
|
194
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
195
|
+
chart.focus();
|
|
196
|
+
await user.keyboard( '{ArrowRight}' );
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
test( 'renders one glyph per visible series in stacked + offset="none"', async () => {
|
|
200
|
+
renderWithProvider();
|
|
201
|
+
await focusFirstDatum();
|
|
202
|
+
|
|
203
|
+
const glyphs = screen.getAllByTestId( /^area-chart-hover-glyph-/ );
|
|
204
|
+
expect( glyphs ).toHaveLength( 2 );
|
|
205
|
+
} );
|
|
206
|
+
|
|
207
|
+
test( 'renders no glyphs when stackOffset is "expand"', async () => {
|
|
208
|
+
renderWithProvider( { stackOffset: 'expand' } );
|
|
209
|
+
await focusFirstDatum();
|
|
210
|
+
|
|
211
|
+
expect( screen.queryAllByTestId( /^area-chart-hover-glyph-/ ) ).toHaveLength( 0 );
|
|
212
|
+
} );
|
|
213
|
+
|
|
214
|
+
test( 'renders no glyphs when stackOffset is "wiggle"', async () => {
|
|
215
|
+
renderWithProvider( { stackOffset: 'wiggle' } );
|
|
216
|
+
await focusFirstDatum();
|
|
217
|
+
|
|
218
|
+
expect( screen.queryAllByTestId( /^area-chart-hover-glyph-/ ) ).toHaveLength( 0 );
|
|
219
|
+
} );
|
|
220
|
+
|
|
221
|
+
test( 'renders no glyphs when stackOffset is "silhouette"', async () => {
|
|
222
|
+
renderWithProvider( { stackOffset: 'silhouette' } );
|
|
223
|
+
await focusFirstDatum();
|
|
224
|
+
|
|
225
|
+
expect( screen.queryAllByTestId( /^area-chart-hover-glyph-/ ) ).toHaveLength( 0 );
|
|
226
|
+
} );
|
|
227
|
+
|
|
228
|
+
test( 'renders glyphs in unstacked mode', async () => {
|
|
229
|
+
renderWithProvider( { stacked: false } );
|
|
230
|
+
await focusFirstDatum();
|
|
231
|
+
|
|
232
|
+
const glyphs = screen.getAllByTestId( /^area-chart-hover-glyph-/ );
|
|
233
|
+
expect( glyphs ).toHaveLength( 2 );
|
|
234
|
+
} );
|
|
235
|
+
|
|
236
|
+
test( 'renders glyphs only for series with a datum at the hovered x (mismatched x-domains)', async () => {
|
|
237
|
+
// Each series' data[0] is a different date. Keyboard nav fires
|
|
238
|
+
// showTooltip for every series at its own data[selectedIndex];
|
|
239
|
+
// the LAST one wins as the nearestDatum, so the resolved hover
|
|
240
|
+
// date is Series B's. HoverGlyphs should:
|
|
241
|
+
// - Skip Series A (no datum at Series B's date) — cumulative += 0
|
|
242
|
+
// - Render a glyph for Series B
|
|
243
|
+
renderWithProvider( {
|
|
244
|
+
data: [
|
|
245
|
+
{
|
|
246
|
+
label: 'Series A',
|
|
247
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10 } ],
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
label: 'Series B',
|
|
251
|
+
data: [ { date: new Date( '2024-01-15' ), value: 5 } ],
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
} );
|
|
255
|
+
await focusFirstDatum();
|
|
256
|
+
|
|
257
|
+
const glyphs = screen.getAllByTestId( /^area-chart-hover-glyph-/ );
|
|
258
|
+
expect( glyphs ).toHaveLength( 1 );
|
|
259
|
+
// Only Series B (index 1) has a datum at the hovered date.
|
|
260
|
+
expect( screen.getByTestId( 'area-chart-hover-glyph-1' ) ).toBeInTheDocument();
|
|
261
|
+
expect( screen.queryByTestId( 'area-chart-hover-glyph-0' ) ).not.toBeInTheDocument();
|
|
262
|
+
} );
|
|
263
|
+
} );
|
|
264
|
+
} );
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BaseChartProps, DataPointDate, SeriesData } from '../../types';
|
|
2
|
+
import type { CurveType } from '../line-chart/types';
|
|
3
|
+
import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
|
|
7
|
+
/**
|
|
8
|
+
* Whether series should be stacked on top of each other.
|
|
9
|
+
* When false, series are rendered as overlapping filled areas.
|
|
10
|
+
* @default true
|
|
11
|
+
*/
|
|
12
|
+
stacked?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Stack offset strategy when stacked is true. Mirrors d3-shape stack offsets.
|
|
15
|
+
* - 'none' (default): values stack at their natural magnitude
|
|
16
|
+
* - 'expand': values are normalized to the [0,1] range (percentage stacks)
|
|
17
|
+
* - 'wiggle': used for streamgraphs
|
|
18
|
+
* - 'silhouette': stack centered around zero
|
|
19
|
+
*/
|
|
20
|
+
stackOffset?: 'none' | 'expand' | 'wiggle' | 'silhouette';
|
|
21
|
+
/**
|
|
22
|
+
* Smoothing using a Catmull-Rom curve. Ignored if `curveType` is set.
|
|
23
|
+
*/
|
|
24
|
+
smoothing?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Curve interpolation type. Takes precedence over `smoothing`.
|
|
27
|
+
*/
|
|
28
|
+
curveType?: CurveType;
|
|
29
|
+
/**
|
|
30
|
+
* Custom tooltip renderer.
|
|
31
|
+
*/
|
|
32
|
+
renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Whether to show crosshair lines in the tooltip.
|
|
35
|
+
*/
|
|
36
|
+
withTooltipCrosshairs?: {
|
|
37
|
+
showVertical?: boolean;
|
|
38
|
+
showHorizontal?: boolean;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Fill opacity for the stacked areas. 0–1.
|
|
42
|
+
* @default 0.85 when stacked, 0.4 when overlapping
|
|
43
|
+
*/
|
|
44
|
+
fillOpacity?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Whether to render a stroke (line) on top of each area.
|
|
47
|
+
* @default false when stacked, true when overlapping
|
|
48
|
+
*/
|
|
49
|
+
withStroke?: boolean;
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { default as LineChart, LineChartUnresponsive } from './line-chart';
|
|
1
|
+
export { default as LineChart, LineChartUnresponsive, renderDefaultTooltip } from './line-chart';
|
|
2
2
|
export type { AnnotationStyles } from '../../types';
|
|
3
3
|
export type {
|
|
4
4
|
LineChartAnnotationProps,
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { formatNumberCompact, formatNumber } from '@automattic/number-formatters';
|
|
2
|
-
import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
|
|
3
2
|
import { LinearGradient } from '@visx/gradient';
|
|
4
|
-
import { scaleTime } from '@visx/scale';
|
|
5
3
|
import { XYChart, AreaSeries, Grid, Axis, DataContext } from '@visx/xychart';
|
|
6
4
|
import { __ } from '@wordpress/i18n';
|
|
7
5
|
import { Stack } from '@wordpress/ui';
|
|
8
6
|
import clsx from 'clsx';
|
|
9
|
-
import { differenceInHours, differenceInYears } from 'date-fns';
|
|
10
7
|
import {
|
|
11
8
|
useMemo,
|
|
12
9
|
useContext,
|
|
@@ -38,10 +35,11 @@ import { ChartLayout } from '../private/chart-layout';
|
|
|
38
35
|
import { DefaultGlyph } from '../private/default-glyph';
|
|
39
36
|
import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context';
|
|
40
37
|
import { SvgEmptyState } from '../private/svg-empty-state';
|
|
38
|
+
import { getCurveType, getFormatter, guessOptimalNumTicks } from '../private/time-axis';
|
|
41
39
|
import { withResponsive } from '../private/with-responsive';
|
|
42
40
|
import styles from './line-chart.module.scss';
|
|
43
41
|
import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
|
|
44
|
-
import type {
|
|
42
|
+
import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
|
|
45
43
|
import type { DataPoint, DataPointDate, SeriesData, Optional } from '../../types';
|
|
46
44
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
47
45
|
import type { TickFormatter } from '@visx/axis';
|
|
@@ -49,8 +47,6 @@ import type { GlyphProps } from '@visx/xychart';
|
|
|
49
47
|
import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip';
|
|
50
48
|
import type { FC, Ref } from 'react';
|
|
51
49
|
|
|
52
|
-
const X_TICK_WIDTH = 60;
|
|
53
|
-
|
|
54
50
|
const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
|
|
55
51
|
return <DefaultGlyph { ...props } key={ props.key } />;
|
|
56
52
|
};
|
|
@@ -61,32 +57,14 @@ const toNumber = ( val?: number | string | null ): number | undefined => {
|
|
|
61
57
|
};
|
|
62
58
|
|
|
63
59
|
/**
|
|
64
|
-
*
|
|
60
|
+
* Default visx-tooltip render that prints the hovered date as a heading and
|
|
61
|
+
* one row per visible series (label + formatted value), sorted descending by
|
|
62
|
+
* value. Reused by AreaChart, which has the same multi-series shape.
|
|
65
63
|
*
|
|
66
|
-
* @param
|
|
67
|
-
* @
|
|
68
|
-
* @return The curve function to use for the line
|
|
64
|
+
* @param params - visx `RenderTooltipParams< DataPointDate >`.
|
|
65
|
+
* @return Tooltip JSX, or `null` when no datum is hovered.
|
|
69
66
|
*/
|
|
70
|
-
const
|
|
71
|
-
// If no type specified, use legacy smoothing behavior
|
|
72
|
-
if ( ! type ) {
|
|
73
|
-
return smoothing ? curveCatmullRom : curveLinear;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Handle explicit curve types
|
|
77
|
-
switch ( type ) {
|
|
78
|
-
case 'smooth':
|
|
79
|
-
return curveCatmullRom;
|
|
80
|
-
case 'monotone':
|
|
81
|
-
return curveMonotoneX;
|
|
82
|
-
case 'linear':
|
|
83
|
-
return curveLinear;
|
|
84
|
-
default:
|
|
85
|
-
return curveLinear;
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate > ) => {
|
|
67
|
+
export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate > ) => {
|
|
90
68
|
const { tooltipData } = params;
|
|
91
69
|
const nearestDatum = tooltipData?.nearestDatum?.datum;
|
|
92
70
|
if ( ! nearestDatum ) return null;
|
|
@@ -121,94 +99,6 @@ const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate > ) =>
|
|
|
121
99
|
);
|
|
122
100
|
};
|
|
123
101
|
|
|
124
|
-
const formatYearTick = ( timestamp: number ) => {
|
|
125
|
-
const date = new Date( timestamp );
|
|
126
|
-
return date.toLocaleDateString( undefined, {
|
|
127
|
-
year: 'numeric',
|
|
128
|
-
} );
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const formatDateTick = ( timestamp: number ) => {
|
|
132
|
-
const date = new Date( timestamp );
|
|
133
|
-
return date.toLocaleDateString( undefined, {
|
|
134
|
-
month: 'short',
|
|
135
|
-
day: 'numeric',
|
|
136
|
-
} );
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const formatHourTick = ( timestamp: number ) => {
|
|
140
|
-
const date = new Date( timestamp );
|
|
141
|
-
return date.toLocaleTimeString( undefined, {
|
|
142
|
-
hour: 'numeric',
|
|
143
|
-
hour12: true,
|
|
144
|
-
} );
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const getFormatter = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
|
|
148
|
-
const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
|
|
149
|
-
const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
|
|
150
|
-
|
|
151
|
-
const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
|
|
152
|
-
if ( diffInHours <= 24 ) {
|
|
153
|
-
return formatHourTick;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
|
|
157
|
-
if ( diffInYears <= 1 ) {
|
|
158
|
-
return formatDateTick;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return formatYearTick;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const guessOptimalNumTicks = (
|
|
165
|
-
data: ReturnType< typeof useChartDataTransform >,
|
|
166
|
-
chartWidth: number,
|
|
167
|
-
tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
|
|
168
|
-
) => {
|
|
169
|
-
const minX = Math.min( ...data.map( datom => datom.data.at( 0 )?.date ) );
|
|
170
|
-
const maxX = Math.max( ...data.map( datom => datom.data.at( -1 )?.date ) );
|
|
171
|
-
const xScale = scaleTime( { domain: [ minX, maxX ] } );
|
|
172
|
-
|
|
173
|
-
// Calculate upper bound of tick numbers based on data points and chart width
|
|
174
|
-
const upperBound = Math.min(
|
|
175
|
-
data[ 0 ]?.data.length || 3, // A sane fallback to avoid NaN when no data is present
|
|
176
|
-
Math.ceil( chartWidth / X_TICK_WIDTH )
|
|
177
|
-
);
|
|
178
|
-
let secondBestGuess = 1; // a tick number that's no greater than upperBound
|
|
179
|
-
|
|
180
|
-
for ( let numTicks = upperBound; numTicks > 1; --numTicks ) {
|
|
181
|
-
const ticks = xScale.ticks( numTicks ).map( d => tickFormatter( d.getTime() ) );
|
|
182
|
-
|
|
183
|
-
// The .ticks() function doesn't properly respect the requested number of ticks, so we need to check the length
|
|
184
|
-
if ( ticks.length > upperBound ) {
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
secondBestGuess = Math.max( secondBestGuess, ticks.length );
|
|
189
|
-
|
|
190
|
-
const uniqueTicks = Array.from( new Set( ticks ) );
|
|
191
|
-
if ( uniqueTicks.length === 1 ) {
|
|
192
|
-
// All ticks are the same, so skip further processing
|
|
193
|
-
return 1;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Example: OCT 1 JAN 1 APR 1 JUL 1 OCT 1
|
|
197
|
-
// Here, the two OCTs are not duplicates as they represent October of two different years.
|
|
198
|
-
const hasConsecutiveDuplicate = ticks.some(
|
|
199
|
-
( tick, idx ) => idx > 0 && tick === ticks[ idx - 1 ]
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
if ( hasConsecutiveDuplicate ) {
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return ticks.length;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return secondBestGuess;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
102
|
const validateData = ( data: SeriesData[] ) => {
|
|
213
103
|
if ( ! data?.length ) return 'No data available';
|
|
214
104
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
|
|
2
|
+
import { scaleTime } from '@visx/scale';
|
|
3
|
+
import { differenceInHours, differenceInYears } from 'date-fns';
|
|
4
|
+
import type { useChartDataTransform } from '../../hooks';
|
|
5
|
+
import type { CurveType } from '../line-chart/types';
|
|
6
|
+
|
|
7
|
+
// Approximate min pixel width for an x-axis tick label.
|
|
8
|
+
const X_TICK_WIDTH = 60;
|
|
9
|
+
|
|
10
|
+
// Resolve the visx curve generator for a given `curveType` / `smoothing`
|
|
11
|
+
// combination. Shared by LineChart and AreaChart so the two render
|
|
12
|
+
// identically when given the same props.
|
|
13
|
+
//
|
|
14
|
+
// Explicit return type avoids a TS2742 portable-name error in the .d.ts
|
|
15
|
+
// build: the inferred type traces back to `@types/d3-shape` (a transitive
|
|
16
|
+
// dep), but `typeof curveLinear` resolves through `@visx/curve` which we
|
|
17
|
+
// own directly.
|
|
18
|
+
export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof curveLinear => {
|
|
19
|
+
if ( ! type ) {
|
|
20
|
+
return smoothing ? curveCatmullRom : curveLinear;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
switch ( type ) {
|
|
24
|
+
case 'smooth':
|
|
25
|
+
return curveCatmullRom;
|
|
26
|
+
case 'monotone':
|
|
27
|
+
return curveMonotoneX;
|
|
28
|
+
case 'linear':
|
|
29
|
+
return curveLinear;
|
|
30
|
+
default:
|
|
31
|
+
return curveLinear;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const formatYearTick = ( timestamp: number ) => {
|
|
36
|
+
const date = new Date( timestamp );
|
|
37
|
+
return date.toLocaleDateString( undefined, { year: 'numeric' } );
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const formatDateTick = ( timestamp: number ) => {
|
|
41
|
+
const date = new Date( timestamp );
|
|
42
|
+
return date.toLocaleDateString( undefined, { month: 'short', day: 'numeric' } );
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const formatHourTick = ( timestamp: number ) => {
|
|
46
|
+
const date = new Date( timestamp );
|
|
47
|
+
return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Pick the most informative tick formatter for the data's time span: hours
|
|
51
|
+
// within a day, calendar dates within a year, otherwise just years.
|
|
52
|
+
export const getFormatter = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
|
|
53
|
+
const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
|
|
54
|
+
const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
|
|
55
|
+
|
|
56
|
+
const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
|
|
57
|
+
if ( diffInHours <= 24 ) {
|
|
58
|
+
return formatHourTick;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
|
|
62
|
+
if ( diffInYears <= 1 ) {
|
|
63
|
+
return formatDateTick;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return formatYearTick;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Estimate the largest number of x-axis ticks that fit without producing
|
|
70
|
+
// consecutive duplicate labels under the given formatter. Used so the axis
|
|
71
|
+
// adapts to the data's resolution rather than picking a fixed count.
|
|
72
|
+
export const guessOptimalNumTicks = (
|
|
73
|
+
data: ReturnType< typeof useChartDataTransform >,
|
|
74
|
+
chartWidth: number,
|
|
75
|
+
tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
|
|
76
|
+
) => {
|
|
77
|
+
const minX = Math.min( ...data.map( datom => datom.data.at( 0 )?.date ) );
|
|
78
|
+
const maxX = Math.max( ...data.map( datom => datom.data.at( -1 )?.date ) );
|
|
79
|
+
const xScale = scaleTime( { domain: [ minX, maxX ] } );
|
|
80
|
+
|
|
81
|
+
const upperBound = Math.min(
|
|
82
|
+
data[ 0 ]?.data.length || 3,
|
|
83
|
+
Math.ceil( chartWidth / X_TICK_WIDTH )
|
|
84
|
+
);
|
|
85
|
+
let secondBestGuess = 1;
|
|
86
|
+
|
|
87
|
+
for ( let numTicks = upperBound; numTicks > 1; --numTicks ) {
|
|
88
|
+
const ticks = xScale.ticks( numTicks ).map( d => tickFormatter( d.getTime() ) );
|
|
89
|
+
|
|
90
|
+
if ( ticks.length > upperBound ) continue;
|
|
91
|
+
|
|
92
|
+
secondBestGuess = Math.max( secondBestGuess, ticks.length );
|
|
93
|
+
|
|
94
|
+
const uniqueTicks = Array.from( new Set( ticks ) );
|
|
95
|
+
if ( uniqueTicks.length === 1 ) return 1;
|
|
96
|
+
|
|
97
|
+
const hasConsecutiveDuplicate = ticks.some(
|
|
98
|
+
( tick, idx ) => idx > 0 && tick === ticks[ idx - 1 ]
|
|
99
|
+
);
|
|
100
|
+
if ( hasConsecutiveDuplicate ) continue;
|
|
101
|
+
|
|
102
|
+
return ticks.length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return secondBestGuess;
|
|
106
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Charts
|
|
2
|
+
export { AreaChart, AreaChartUnresponsive } from './charts/area-chart';
|
|
2
3
|
export { BarChart, BarChartUnresponsive } from './charts/bar-chart';
|
|
3
4
|
export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart';
|
|
4
5
|
export { ConversionFunnelChart } from './charts/conversion-funnel-chart';
|
|
@@ -79,6 +80,7 @@ export type { BaseTooltipProps, TooltipData, TooltipProps } from './components/t
|
|
|
79
80
|
export type { LegendProps, BaseLegendProps, ChartLegendOptions } from './components/legend';
|
|
80
81
|
|
|
81
82
|
// Previously available via '@automattic/charts/bar-chart', '@automattic/charts/line-chart', etc.
|
|
83
|
+
export type { AreaChartProps } from './charts/area-chart';
|
|
82
84
|
export type { BarChartProps } from './charts/bar-chart';
|
|
83
85
|
export type {
|
|
84
86
|
BarListChartProps,
|