@automattic/charts 4.1.1 → 4.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 +39 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1335 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +129 -8
- package/dist/index.d.ts +129 -8
- package/dist/index.js +1336 -351
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +137 -70
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +3 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
- package/src/charts/bar-chart/types.ts +39 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +6 -0
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +59 -3
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +149 -36
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +4 -3
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +37 -22
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +128 -41
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +14 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@automattic/number-formatters": "^1.2.9",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
|
-
"@date-fns/tz": "1.
|
|
68
|
+
"@date-fns/tz": "1.5.0",
|
|
69
69
|
"@react-spring/web": "10.1.1",
|
|
70
70
|
"@visx/annotation": "^4.0.0",
|
|
71
71
|
"@visx/axis": "^4.0.0",
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
"@visx/vendor": "^4.0.0",
|
|
85
85
|
"@visx/xychart": "^4.0.0",
|
|
86
86
|
"@wordpress/i18n": "^6.0.0",
|
|
87
|
-
"@wordpress/icons": "^
|
|
88
|
-
"@wordpress/theme": "2.1.1-next.v.
|
|
89
|
-
"@wordpress/ui": "0.
|
|
87
|
+
"@wordpress/icons": "^16.0.0",
|
|
88
|
+
"@wordpress/theme": "2.1.1-next.v.202609031004.0",
|
|
89
|
+
"@wordpress/ui": "0.22.0",
|
|
90
90
|
"clsx": "2.1.1",
|
|
91
91
|
"date-fns": "^4.1.0",
|
|
92
92
|
"deepmerge": "4.3.1",
|
|
@@ -111,9 +111,9 @@
|
|
|
111
111
|
"@types/react-dom": "18.3.7",
|
|
112
112
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
113
113
|
"@visx/glyph": "4.0.0",
|
|
114
|
-
"@wordpress/components": "40.
|
|
115
|
-
"@wordpress/element": "8.
|
|
116
|
-
"@wordpress/private-apis": "1.
|
|
114
|
+
"@wordpress/components": "40.1.0",
|
|
115
|
+
"@wordpress/element": "8.7.0",
|
|
116
|
+
"@wordpress/private-apis": "1.55.0",
|
|
117
117
|
"babel-jest": "30.5.1",
|
|
118
118
|
"identity-obj-proxy": "^3.0.0",
|
|
119
119
|
"jest": "30.5.1",
|
|
@@ -37,6 +37,7 @@ import { ChartLayout } from '../private/chart-layout';
|
|
|
37
37
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
38
38
|
import { getCurveType } from '../private/time-axis';
|
|
39
39
|
import { buildTimeAxisOptions } from '../private/time-axis-options';
|
|
40
|
+
import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
|
|
40
41
|
import { withResponsive } from '../private/with-responsive';
|
|
41
42
|
import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
|
|
42
43
|
import plotStyles from '../private/xy-plot/xy-plot.module.scss';
|
|
@@ -142,6 +143,15 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
|
|
|
142
143
|
[ seriesWithVisibility ]
|
|
143
144
|
);
|
|
144
145
|
|
|
146
|
+
// A normalized stack (expand/wiggle/silhouette) turns whole-number data into
|
|
147
|
+
// fractions, so the filter is skipped for every offset but 'none'.
|
|
148
|
+
const hasWholeNumberValues = useMemo(
|
|
149
|
+
() =>
|
|
150
|
+
( ! stacked || stackOffset === 'none' ) &&
|
|
151
|
+
hasOnlyWholeNumbers( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
|
|
152
|
+
[ dataSorted, isSeriesVisible, stacked, stackOffset ]
|
|
153
|
+
);
|
|
154
|
+
|
|
145
155
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
146
156
|
selectedIndex,
|
|
147
157
|
setSelectedIndex,
|
|
@@ -250,8 +260,9 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
|
|
|
250
260
|
withGlyph: false,
|
|
251
261
|
glyphSize: 0,
|
|
252
262
|
collapseGroups: legend.collapseGroups ?? false,
|
|
263
|
+
comparisonItem: legend.comparisonItem ?? false,
|
|
253
264
|
} ),
|
|
254
|
-
[ legend.collapseGroups ]
|
|
265
|
+
[ legend.collapseGroups, legend.comparisonItem ]
|
|
255
266
|
);
|
|
256
267
|
const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
|
|
257
268
|
|
|
@@ -438,15 +449,36 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
|
|
|
438
449
|
{ /* With every series hidden the value scale collapses, so the grid and axes
|
|
439
450
|
are dropped while the empty state stands in — otherwise they render
|
|
440
451
|
squished at the top. */ }
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
452
|
+
<WholeNumberTicks
|
|
453
|
+
axis="y"
|
|
454
|
+
numTicks={ chartOptions.axis.y.numTicks }
|
|
455
|
+
enabled={
|
|
456
|
+
hasWholeNumberValues &&
|
|
457
|
+
! chartOptions.axis.y.tickValues &&
|
|
458
|
+
! options?.yScale?.domain
|
|
459
|
+
}
|
|
460
|
+
>
|
|
461
|
+
{ tickValues => (
|
|
462
|
+
<>
|
|
463
|
+
{ ! allSeriesHidden && gridVisibility !== 'none' && (
|
|
464
|
+
<Grid
|
|
465
|
+
columns={ false }
|
|
466
|
+
numTicks={ chartOptions.axis.y.numTicks }
|
|
467
|
+
{ ...{ tickValues: tickValues ?? chartOptions.axis.y.tickValues } }
|
|
468
|
+
/>
|
|
469
|
+
) }
|
|
470
|
+
{ ! allSeriesHidden && chartOptions.axis.x.display && (
|
|
471
|
+
<Axis { ...chartOptions.axis.x } />
|
|
472
|
+
) }
|
|
473
|
+
{ ! allSeriesHidden && chartOptions.axis.y.display && (
|
|
474
|
+
<Axis
|
|
475
|
+
{ ...chartOptions.axis.y }
|
|
476
|
+
{ ...( tickValues ? { tickValues } : {} ) }
|
|
477
|
+
/>
|
|
478
|
+
) }
|
|
479
|
+
</>
|
|
480
|
+
) }
|
|
481
|
+
</WholeNumberTicks>
|
|
450
482
|
|
|
451
483
|
{ allSeriesHidden ? (
|
|
452
484
|
<SvgEmptyState
|
|
@@ -57,8 +57,7 @@ export const HoverGlyphs: FC< {
|
|
|
57
57
|
const yScale = dataContext?.yScale as ScaleFn | undefined;
|
|
58
58
|
const tooltipOpen = tooltipContext?.tooltipOpen;
|
|
59
59
|
const nearestDatum = tooltipContext?.tooltipData?.nearestDatum?.datum as
|
|
60
|
-
|
|
|
61
|
-
| undefined;
|
|
60
|
+
DataPointDate | undefined;
|
|
62
61
|
|
|
63
62
|
if (
|
|
64
63
|
! tooltipOpen ||
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { __ } from '@wordpress/i18n';
|
|
2
|
+
import { isInvalidReading } from '../../private/readings';
|
|
2
3
|
import type { DataPoint, DataPointDate, SeriesData } from '../../../types';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,9 +20,7 @@ export const validateData = ( data: SeriesData[] ) => {
|
|
|
19
20
|
const hasInvalidData = data.some( series =>
|
|
20
21
|
series.data.some(
|
|
21
22
|
( point: DataPointDate | DataPoint ) =>
|
|
22
|
-
|
|
23
|
-
point.value === null ||
|
|
24
|
-
point.value === undefined ||
|
|
23
|
+
isInvalidReading( point.value, { allowMissing: false } ) ||
|
|
25
24
|
( 'date' in point && point.date && isNaN( point.date.getTime() ) )
|
|
26
25
|
)
|
|
27
26
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, act } from '@testing-library/react';
|
|
1
|
+
import { render, screen, within, act } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { createRef } from 'react';
|
|
4
4
|
import { GlobalChartsProvider } from '../../../providers';
|
|
@@ -188,6 +188,77 @@ describe( 'AreaChart', () => {
|
|
|
188
188
|
} );
|
|
189
189
|
} );
|
|
190
190
|
|
|
191
|
+
describe( 'Y-Axis Ticks', () => {
|
|
192
|
+
test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
|
|
193
|
+
renderWithProvider( {
|
|
194
|
+
stacked: false,
|
|
195
|
+
data: [
|
|
196
|
+
{
|
|
197
|
+
label: 'Series A',
|
|
198
|
+
data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
|
|
199
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
200
|
+
value,
|
|
201
|
+
} ) ),
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
} );
|
|
205
|
+
|
|
206
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
207
|
+
const ticks = within( chart )
|
|
208
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
209
|
+
.map( el => el.textContent );
|
|
210
|
+
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
211
|
+
} );
|
|
212
|
+
|
|
213
|
+
test( 'keeps every tick on a y domain the caller pinned', () => {
|
|
214
|
+
renderWithProvider( {
|
|
215
|
+
stacked: false,
|
|
216
|
+
data: [
|
|
217
|
+
{
|
|
218
|
+
label: 'Series A',
|
|
219
|
+
data: [ 0, 0, 0 ].map( ( value, i ) => ( {
|
|
220
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
221
|
+
value,
|
|
222
|
+
} ) ),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
options: {
|
|
226
|
+
yScale: { domain: [ 0, 1 ] },
|
|
227
|
+
axis: { y: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` } },
|
|
228
|
+
},
|
|
229
|
+
} );
|
|
230
|
+
|
|
231
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
232
|
+
expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
|
|
233
|
+
} );
|
|
234
|
+
|
|
235
|
+
test( 'keeps fractional ticks for a normalized stack even when the data is whole numbers', () => {
|
|
236
|
+
renderWithProvider( {
|
|
237
|
+
stacked: true,
|
|
238
|
+
stackOffset: 'expand',
|
|
239
|
+
data: [
|
|
240
|
+
{
|
|
241
|
+
label: 'Series A',
|
|
242
|
+
data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
|
|
243
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
244
|
+
value,
|
|
245
|
+
} ) ),
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
label: 'Series B',
|
|
249
|
+
data: [ 1, 0, 0, 1 ].map( ( value, i ) => ( {
|
|
250
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
251
|
+
value,
|
|
252
|
+
} ) ),
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
} );
|
|
256
|
+
|
|
257
|
+
const chart = screen.getByRole( 'grid', { name: /area chart/i } );
|
|
258
|
+
expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ).length ).toBeGreaterThan( 2 );
|
|
259
|
+
} );
|
|
260
|
+
} );
|
|
261
|
+
|
|
191
262
|
describe( 'Stacking', () => {
|
|
192
263
|
test( 'is stacked by default', () => {
|
|
193
264
|
renderWithProvider();
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { formatNumber } from '@automattic/number-formatters';
|
|
2
1
|
import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
|
|
3
2
|
import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
|
|
4
3
|
import { __, sprintf } from '@wordpress/i18n';
|
|
@@ -22,46 +21,37 @@ import {
|
|
|
22
21
|
} from '../../providers';
|
|
23
22
|
import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
|
|
24
23
|
import { attachSubComponents } from '../../utils';
|
|
24
|
+
import { warnOnce } from '../../utils/warn-once';
|
|
25
25
|
import { useChartChildren } from '../private/chart-composition';
|
|
26
26
|
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
27
27
|
import { ChartLayout } from '../private/chart-layout';
|
|
28
|
+
import { formatReading, isInvalidReading } from '../private/readings';
|
|
28
29
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
30
|
+
import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
|
|
29
31
|
import { withResponsive } from '../private/with-responsive';
|
|
30
32
|
import plotStyles from '../private/xy-plot/xy-plot.module.scss';
|
|
31
33
|
import styles from './bar-chart.module.scss';
|
|
32
34
|
import {
|
|
33
35
|
useBarChartOptions,
|
|
36
|
+
BandHighlight,
|
|
37
|
+
BandTooltip,
|
|
34
38
|
ComparisonBars,
|
|
35
39
|
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
36
40
|
COMPARISON_INNER_GAP,
|
|
37
41
|
MAX_GROUP_PADDING,
|
|
38
42
|
COMPARISON_TICK_GAP_FACTOR,
|
|
39
43
|
BASE_BAND_PADDING_INNER,
|
|
44
|
+
countRenderedBars,
|
|
45
|
+
isBarRendered,
|
|
40
46
|
} from './private';
|
|
41
47
|
import type { ComparisonSeriesEntry } from './private';
|
|
42
|
-
import type {
|
|
43
|
-
|
|
44
|
-
DataPointDate,
|
|
45
|
-
SeriesData,
|
|
46
|
-
SeriesChartLegendConfig,
|
|
47
|
-
SeriesVisibilityProps,
|
|
48
|
-
Optional,
|
|
49
|
-
} from '../../types';
|
|
48
|
+
import type { BarChartProps } from './types';
|
|
49
|
+
import type { DataPointDate, SeriesData, Optional } from '../../types';
|
|
50
50
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
51
51
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
52
|
-
import type { FC,
|
|
53
|
-
|
|
54
|
-
export
|
|
55
|
-
/**
|
|
56
|
-
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
57
|
-
*/
|
|
58
|
-
legend?: SeriesChartLegendConfig;
|
|
59
|
-
renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
|
|
60
|
-
orientation?: 'horizontal' | 'vertical';
|
|
61
|
-
withPatterns?: boolean;
|
|
62
|
-
showZeroValues?: boolean;
|
|
63
|
-
children?: ReactNode;
|
|
64
|
-
}
|
|
52
|
+
import type { FC, ComponentType } from 'react';
|
|
53
|
+
|
|
54
|
+
export type { BarChartProps, BandHighlightSelection } from './types';
|
|
65
55
|
|
|
66
56
|
// Base props type with optional responsive properties
|
|
67
57
|
type BarChartBaseProps = Optional< BarChartProps, 'width' | 'height' | 'size' >;
|
|
@@ -77,20 +67,18 @@ type BarChartResponsiveComponent = FC< BarChartBaseProps & ResponsiveConfig > &
|
|
|
77
67
|
|
|
78
68
|
// Validation function similar to LineChart
|
|
79
69
|
const validateData = ( data: SeriesData[] ) => {
|
|
80
|
-
if ( ! data?.length ) return 'No data available';
|
|
70
|
+
if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' );
|
|
81
71
|
|
|
82
72
|
const hasInvalidData = data.some( series =>
|
|
83
73
|
series.data.some(
|
|
84
74
|
point =>
|
|
85
|
-
|
|
86
|
-
point.value === null ||
|
|
87
|
-
point.value === undefined ||
|
|
75
|
+
isInvalidReading( point.value, { allowMissing: true } ) ||
|
|
88
76
|
( ! point.label &&
|
|
89
77
|
( ! ( 'date' in point && point.date ) || isNaN( point.date.getTime() ) ) )
|
|
90
78
|
)
|
|
91
79
|
);
|
|
92
80
|
|
|
93
|
-
if ( hasInvalidData ) return 'Invalid data';
|
|
81
|
+
if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' );
|
|
94
82
|
return null;
|
|
95
83
|
};
|
|
96
84
|
|
|
@@ -121,10 +109,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
121
109
|
legend = {},
|
|
122
110
|
gridVisibility: gridVisibilityProp,
|
|
123
111
|
renderTooltip,
|
|
112
|
+
tooltipPlacement,
|
|
113
|
+
tooltipAnchorTop,
|
|
124
114
|
options = {},
|
|
125
115
|
orientation = 'vertical',
|
|
126
116
|
withPatterns = false,
|
|
127
117
|
showZeroValues = false,
|
|
118
|
+
withBandHighlight = false,
|
|
119
|
+
onBandHighlightChange,
|
|
128
120
|
defaultHiddenSeries,
|
|
129
121
|
animation,
|
|
130
122
|
children,
|
|
@@ -133,8 +125,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
133
125
|
onPointerUp,
|
|
134
126
|
onDatumActivate,
|
|
135
127
|
} ) => {
|
|
128
|
+
if ( ! withTooltips && ( withBandHighlight || onBandHighlightChange ) ) {
|
|
129
|
+
warnOnce(
|
|
130
|
+
'bar-chart-band-highlight-without-tooltips',
|
|
131
|
+
'BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
136
135
|
const legendInteractive = legend.interactive ?? false;
|
|
137
136
|
const legendCollapseGroups = legend.collapseGroups ?? false;
|
|
137
|
+
const legendComparisonItem = legend.comparisonItem ?? false;
|
|
138
138
|
const horizontal = orientation === 'horizontal';
|
|
139
139
|
const chartId = useChartId( providedChartId );
|
|
140
140
|
const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
|
|
@@ -155,8 +155,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
155
155
|
|
|
156
156
|
// Create legend items using the reusable hook
|
|
157
157
|
const legendOptions = useMemo(
|
|
158
|
-
() => ( { collapseGroups: legendCollapseGroups } ),
|
|
159
|
-
[ legendCollapseGroups ]
|
|
158
|
+
() => ( { collapseGroups: legendCollapseGroups, comparisonItem: legendComparisonItem } ),
|
|
159
|
+
[ legendCollapseGroups, legendComparisonItem ]
|
|
160
160
|
);
|
|
161
161
|
const legendItems = useChartLegendItems( dataSorted, legendOptions );
|
|
162
162
|
|
|
@@ -170,12 +170,24 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
170
170
|
[ isSeriesVisible ]
|
|
171
171
|
);
|
|
172
172
|
|
|
173
|
+
const hasWholeNumberValues = useMemo(
|
|
174
|
+
() => hasOnlyWholeNumbers( dataSorted.filter( isSeriesRendered ) ),
|
|
175
|
+
[ dataSorted, isSeriesRendered ]
|
|
176
|
+
);
|
|
177
|
+
|
|
173
178
|
const chartOptions = useBarChartOptions(
|
|
174
179
|
dataWithVisibleZeros,
|
|
175
180
|
horizontal,
|
|
176
181
|
options,
|
|
177
182
|
isSeriesRendered
|
|
178
183
|
);
|
|
184
|
+
const valueAxis = horizontal ? chartOptions.axis.x : chartOptions.axis.y;
|
|
185
|
+
const callerValueDomain = horizontal ? options.xScale?.domain : options.yScale?.domain;
|
|
186
|
+
const wholeNumberTicksProps = {
|
|
187
|
+
axis: horizontal ? ( 'x' as const ) : ( 'y' as const ),
|
|
188
|
+
numTicks: valueAxis.numTicks,
|
|
189
|
+
enabled: hasWholeNumberValues && ! valueAxis.tickValues && ! callerValueDomain,
|
|
190
|
+
};
|
|
179
191
|
const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
|
|
180
192
|
const chartRef = useRef< HTMLDivElement >( null );
|
|
181
193
|
|
|
@@ -193,13 +205,6 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
193
205
|
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
|
|
194
206
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
195
207
|
|
|
196
|
-
// Comparison series have no .visx-bar elements; count only primary series so
|
|
197
|
-
// keyboard navigation doesn't cycle phantom indices into comparison-only slots.
|
|
198
|
-
const primarySeriesForNav = dataWithVisibleZeros.filter( s => s.options?.type !== 'comparison' );
|
|
199
|
-
const totalPoints =
|
|
200
|
-
Math.max( 0, ...primarySeriesForNav.map( s => s.data?.length || 0 ) ) *
|
|
201
|
-
primarySeriesForNav.length;
|
|
202
|
-
|
|
203
208
|
// Add visibility information from the shared legend state.
|
|
204
209
|
const seriesWithVisibility = useMemo(
|
|
205
210
|
() =>
|
|
@@ -225,6 +230,9 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
225
230
|
[ seriesWithVisibility ]
|
|
226
231
|
);
|
|
227
232
|
|
|
233
|
+
const totalPoints =
|
|
234
|
+
Math.max( 0, ...primaryEntries.map( e => e.series.data.length ) ) * primaryEntries.length;
|
|
235
|
+
|
|
228
236
|
const primaryKeys = useMemo(
|
|
229
237
|
() => primaryEntries.map( ( { series } ) => series.label ),
|
|
230
238
|
[ primaryEntries ]
|
|
@@ -259,6 +267,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
259
267
|
[ primaryEntries, onDatumActivate ]
|
|
260
268
|
);
|
|
261
269
|
|
|
270
|
+
const visibleSeriesKey = useMemo( () => JSON.stringify( primaryKeys ), [ primaryKeys ] );
|
|
271
|
+
|
|
262
272
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
263
273
|
selectedIndex,
|
|
264
274
|
setSelectedIndex,
|
|
@@ -267,6 +277,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
267
277
|
chartRef,
|
|
268
278
|
totalPoints,
|
|
269
279
|
onActivate: activateSelectedBar,
|
|
280
|
+
visibleSeriesKey,
|
|
270
281
|
} );
|
|
271
282
|
|
|
272
283
|
const comparisonEntries = useMemo( () => {
|
|
@@ -342,10 +353,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
342
353
|
}, [ comparisonEntries.length, chartOptions.xScale, chartOptions.yScale, horizontal ] );
|
|
343
354
|
|
|
344
355
|
const getBarBackground = useCallback(
|
|
345
|
-
( index: number ) => () =>
|
|
356
|
+
( index: number ) => ( datum: DataPointDate ) =>
|
|
346
357
|
withPatterns
|
|
347
358
|
? `url(#${ getPatternId( chartId, index ) })`
|
|
348
|
-
: getElementStyles( { data: dataSorted[ index ], index } ).color,
|
|
359
|
+
: ( datum.color ?? getElementStyles( { data: dataSorted[ index ], index } ).color ),
|
|
349
360
|
[ withPatterns, getElementStyles, dataSorted, chartId ]
|
|
350
361
|
);
|
|
351
362
|
|
|
@@ -383,14 +394,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
383
394
|
|
|
384
395
|
// With a paired comparison value, show the category as the header and one row
|
|
385
396
|
// per period (primary + comparison).
|
|
386
|
-
if ( comparisonEntry && comparisonDatum
|
|
397
|
+
if ( comparisonEntry && comparisonDatum ) {
|
|
387
398
|
return (
|
|
388
399
|
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
389
400
|
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
|
|
390
|
-
{ renderTooltipRow( primaryKey,
|
|
401
|
+
{ renderTooltipRow( primaryKey, formatReading( nearestDatum.value ) ) }
|
|
391
402
|
{ renderTooltipRow(
|
|
392
403
|
comparisonEntry.series.label,
|
|
393
|
-
|
|
404
|
+
formatReading( comparisonDatum.value )
|
|
394
405
|
) }
|
|
395
406
|
</div>
|
|
396
407
|
);
|
|
@@ -399,7 +410,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
399
410
|
return (
|
|
400
411
|
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
401
412
|
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
|
|
402
|
-
{ renderTooltipRow( categoryLabel,
|
|
413
|
+
{ renderTooltipRow( categoryLabel, formatReading( nearestDatum.value ) ) }
|
|
403
414
|
</div>
|
|
404
415
|
);
|
|
405
416
|
},
|
|
@@ -461,34 +472,33 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
461
472
|
const createKeyboardHighlightStyle = useCallback( () => {
|
|
462
473
|
if ( selectedIndex === undefined ) return '';
|
|
463
474
|
|
|
464
|
-
// Use only primary entries
|
|
475
|
+
// Use only primary entries: comparison series have no .visx-bar elements, so
|
|
465
476
|
// their indices must not appear in the nth-child selector.
|
|
466
477
|
// Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
|
|
467
478
|
const primaryCount = primaryEntries.length;
|
|
468
|
-
|
|
469
|
-
const dataPointIndex = Math.floor( selectedIndex / primaryCount );
|
|
470
|
-
const seriesIndex = selectedIndex % primaryCount;
|
|
471
|
-
|
|
472
|
-
// Only highlight if we're within valid bounds
|
|
473
|
-
if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
|
|
479
|
+
if ( ! primaryCount ) {
|
|
474
480
|
return '';
|
|
475
481
|
}
|
|
476
|
-
|
|
482
|
+
const dataPointIndex = Math.floor( selectedIndex / primaryCount );
|
|
483
|
+
const seriesIndex = selectedIndex % primaryCount;
|
|
477
484
|
const seriesData = primaryEntries[ seriesIndex ]?.series;
|
|
478
|
-
|
|
485
|
+
const datum = seriesData?.data[ dataPointIndex ];
|
|
486
|
+
|
|
487
|
+
if ( ! seriesData || ! datum || ! isBarRendered( datum ) ) {
|
|
479
488
|
return '';
|
|
480
489
|
}
|
|
481
490
|
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
491
|
+
// The single .visx-bar-group holds series after series, and only the points visx drew a bar for.
|
|
492
|
+
const domBarIndex =
|
|
493
|
+
primaryEntries
|
|
494
|
+
.slice( 0, seriesIndex )
|
|
495
|
+
.reduce( ( count, { series } ) => count + countRenderedBars( series.data ), 0 ) +
|
|
496
|
+
countRenderedBars( seriesData.data.slice( 0, dataPointIndex ) );
|
|
487
497
|
|
|
488
498
|
// Use a CSS class selector instead of ID since useId() generates invalid CSS ID characters
|
|
489
499
|
const generatedStyles = `
|
|
490
500
|
.bar-chart[data-chart-id="bar-chart-${ chartId }"] .visx-bar-group .visx-bar:nth-child(${
|
|
491
|
-
|
|
501
|
+
domBarIndex + 1
|
|
492
502
|
}) {
|
|
493
503
|
stroke: #005fcc;
|
|
494
504
|
stroke-width: 2px;
|
|
@@ -581,6 +591,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
581
591
|
return (
|
|
582
592
|
<div
|
|
583
593
|
role="grid"
|
|
594
|
+
ref={ chartRef }
|
|
584
595
|
aria-label={ __( 'Bar chart', 'jetpack-charts' ) }
|
|
585
596
|
tabIndex={ 0 }
|
|
586
597
|
onKeyDown={ onChartKeyDown }
|
|
@@ -588,7 +599,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
588
599
|
onBlur={ onChartBlur }
|
|
589
600
|
>
|
|
590
601
|
{ chartHeight > 0 && (
|
|
591
|
-
<div
|
|
602
|
+
<div className={ plotStyles[ 'xy-plot' ] }>
|
|
592
603
|
<XYChart
|
|
593
604
|
theme={ theme }
|
|
594
605
|
width={ width }
|
|
@@ -600,18 +611,46 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
600
611
|
xScale={ xScale }
|
|
601
612
|
yScale={ yScale }
|
|
602
613
|
horizontal={ horizontal }
|
|
603
|
-
onPointerDown={ onPointerDown }
|
|
604
|
-
onPointerUp={ onPointerUp }
|
|
605
614
|
pointerEventsDataKey="nearest"
|
|
606
615
|
>
|
|
607
|
-
{
|
|
608
|
-
<
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
616
|
+
{ withTooltips && ( withBandHighlight || onBandHighlightChange ) && (
|
|
617
|
+
<BandHighlight
|
|
618
|
+
visible={ withBandHighlight }
|
|
619
|
+
horizontal={ horizontal }
|
|
620
|
+
onChange={ onBandHighlightChange }
|
|
612
621
|
/>
|
|
613
622
|
) }
|
|
614
623
|
|
|
624
|
+
{ ! allSeriesHidden && (
|
|
625
|
+
<WholeNumberTicks { ...wholeNumberTicksProps }>
|
|
626
|
+
{ valueTicks => (
|
|
627
|
+
<>
|
|
628
|
+
{ /* Visx forwards tickValues to its grid primitives but omits it from GridProps. */ }
|
|
629
|
+
<Grid
|
|
630
|
+
columns={ gridVisibility.includes( 'y' ) }
|
|
631
|
+
rows={ false }
|
|
632
|
+
numTicks={ chartOptions.axis.x.numTicks }
|
|
633
|
+
{ ...{
|
|
634
|
+
tickValues:
|
|
635
|
+
( horizontal ? valueTicks : undefined ) ??
|
|
636
|
+
chartOptions.axis.x.tickValues,
|
|
637
|
+
} }
|
|
638
|
+
/>
|
|
639
|
+
<Grid
|
|
640
|
+
columns={ false }
|
|
641
|
+
rows={ gridVisibility.includes( 'x' ) }
|
|
642
|
+
numTicks={ chartOptions.axis.y.numTicks }
|
|
643
|
+
{ ...{
|
|
644
|
+
tickValues:
|
|
645
|
+
( horizontal ? undefined : valueTicks ) ??
|
|
646
|
+
chartOptions.axis.y.tickValues,
|
|
647
|
+
} }
|
|
648
|
+
/>
|
|
649
|
+
</>
|
|
650
|
+
) }
|
|
651
|
+
</WholeNumberTicks>
|
|
652
|
+
) }
|
|
653
|
+
|
|
615
654
|
{ withPatterns && (
|
|
616
655
|
<>
|
|
617
656
|
<defs data-testid="bar-chart-patterns">
|
|
@@ -633,7 +672,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
633
672
|
</>
|
|
634
673
|
) }
|
|
635
674
|
|
|
636
|
-
{ highlightedBarStyle &&
|
|
675
|
+
{ highlightedBarStyle && (
|
|
676
|
+
<style data-testid="bar-chart-keyboard-highlight">
|
|
677
|
+
{ highlightedBarStyle }
|
|
678
|
+
</style>
|
|
679
|
+
) }
|
|
637
680
|
|
|
638
681
|
{ allSeriesHidden ? (
|
|
639
682
|
<SvgEmptyState
|
|
@@ -673,19 +716,43 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
673
716
|
/>
|
|
674
717
|
) ) }
|
|
675
718
|
</BarGroup>
|
|
719
|
+
{ /* Do not reorder: for one key the last showTooltip wins, so this must run after BarGroup. */ }
|
|
720
|
+
{ ( withTooltips || onPointerDown || onPointerUp ) && (
|
|
721
|
+
<BandTooltip
|
|
722
|
+
keys={ primaryKeys }
|
|
723
|
+
groupPadding={ groupPadding }
|
|
724
|
+
withTooltips={ withTooltips }
|
|
725
|
+
onPointerDown={ onPointerDown }
|
|
726
|
+
onPointerUp={ onPointerUp }
|
|
727
|
+
/>
|
|
728
|
+
) }
|
|
676
729
|
|
|
677
730
|
{ /* With every series hidden there is no data to build the value scale from, so
|
|
678
731
|
visx collapses the domain and the axes render squished at the top. Drop them
|
|
679
732
|
while the empty state stands in. */ }
|
|
680
733
|
{ ! allSeriesHidden && (
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
734
|
+
<WholeNumberTicks { ...wholeNumberTicksProps }>
|
|
735
|
+
{ valueTicks => (
|
|
736
|
+
<>
|
|
737
|
+
<Axis
|
|
738
|
+
{ ...chartOptions.axis.x }
|
|
739
|
+
{ ...( horizontal && valueTicks ? { tickValues: valueTicks } : {} ) }
|
|
740
|
+
/>
|
|
741
|
+
<Axis
|
|
742
|
+
{ ...chartOptions.axis.y }
|
|
743
|
+
{ ...( ! horizontal && valueTicks
|
|
744
|
+
? { tickValues: valueTicks }
|
|
745
|
+
: {} ) }
|
|
746
|
+
/>
|
|
747
|
+
</>
|
|
748
|
+
) }
|
|
749
|
+
</WholeNumberTicks>
|
|
685
750
|
) }
|
|
686
751
|
|
|
687
752
|
{ withTooltips && (
|
|
688
753
|
<AccessibleTooltip
|
|
754
|
+
tooltipPlacement={ tooltipPlacement }
|
|
755
|
+
tooltipAnchorTop={ tooltipAnchorTop }
|
|
689
756
|
detectBounds
|
|
690
757
|
snapTooltipToDatumX
|
|
691
758
|
snapTooltipToDatumY
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as BarChart, BarChartUnresponsive } from './bar-chart';
|
|
2
|
-
export type { BarChartProps } from './
|
|
2
|
+
export type { BarChartProps, BandHighlightSelection } from './types';
|