@automattic/charts 4.2.0 → 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 +20 -0
- package/dist/index.cjs +470 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -2
- package/dist/index.d.ts +23 -2
- package/dist/index.js +471 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +42 -10
- 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 +100 -83
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
- package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
- package/src/charts/bar-chart/private/index.ts +1 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +484 -9
- package/src/charts/bar-chart/types.ts +39 -0
- package/src/charts/line-chart/line-chart.tsx +148 -35
- package/src/charts/line-chart/private/index.ts +1 -0
- 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 +2 -1
- 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/whole-number-ticks.tsx +79 -0
- 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 +36 -21
- 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 +1 -1
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/use-chart-margin.tsx +7 -2
- 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/types.ts +8 -0
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
package/package.json
CHANGED
|
@@ -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
|
|
@@ -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,10 +21,13 @@ 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';
|
|
@@ -39,47 +41,17 @@ import {
|
|
|
39
41
|
MAX_GROUP_PADDING,
|
|
40
42
|
COMPARISON_TICK_GAP_FACTOR,
|
|
41
43
|
BASE_BAND_PADDING_INNER,
|
|
44
|
+
countRenderedBars,
|
|
45
|
+
isBarRendered,
|
|
42
46
|
} from './private';
|
|
43
47
|
import type { ComparisonSeriesEntry } from './private';
|
|
44
|
-
import type {
|
|
45
|
-
|
|
46
|
-
DataPointDate,
|
|
47
|
-
SeriesData,
|
|
48
|
-
SeriesChartLegendConfig,
|
|
49
|
-
SeriesVisibilityProps,
|
|
50
|
-
Optional,
|
|
51
|
-
} from '../../types';
|
|
48
|
+
import type { BarChartProps } from './types';
|
|
49
|
+
import type { DataPointDate, SeriesData, Optional } from '../../types';
|
|
52
50
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
53
51
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
54
|
-
import type { FC,
|
|
55
|
-
|
|
56
|
-
export type BandHighlightSelection = {
|
|
57
|
-
datum: DataPointDate;
|
|
58
|
-
x: number;
|
|
59
|
-
y: number;
|
|
60
|
-
width: number;
|
|
61
|
-
height: number;
|
|
62
|
-
};
|
|
52
|
+
import type { FC, ComponentType } from 'react';
|
|
63
53
|
|
|
64
|
-
export
|
|
65
|
-
/**
|
|
66
|
-
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
67
|
-
*/
|
|
68
|
-
legend?: SeriesChartLegendConfig;
|
|
69
|
-
renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
|
|
70
|
-
/** Place the tooltip beside its category without vertical flipping. */
|
|
71
|
-
tooltipPlacement?: 'auto' | 'beside';
|
|
72
|
-
/** Tooltip top anchor in SVG coordinates; negative offsets are supported. */
|
|
73
|
-
tooltipAnchorTop?: number;
|
|
74
|
-
orientation?: 'horizontal' | 'vertical';
|
|
75
|
-
withPatterns?: boolean;
|
|
76
|
-
showZeroValues?: boolean;
|
|
77
|
-
/** Highlight the active band across the plot when tooltips are enabled. */
|
|
78
|
-
withBandHighlight?: boolean;
|
|
79
|
-
/** Receive active band bounds in SVG coordinates, or null when dismissed. */
|
|
80
|
-
onBandHighlightChange?: ( selection: BandHighlightSelection | null ) => void;
|
|
81
|
-
children?: ReactNode;
|
|
82
|
-
}
|
|
54
|
+
export type { BarChartProps, BandHighlightSelection } from './types';
|
|
83
55
|
|
|
84
56
|
// Base props type with optional responsive properties
|
|
85
57
|
type BarChartBaseProps = Optional< BarChartProps, 'width' | 'height' | 'size' >;
|
|
@@ -95,20 +67,18 @@ type BarChartResponsiveComponent = FC< BarChartBaseProps & ResponsiveConfig > &
|
|
|
95
67
|
|
|
96
68
|
// Validation function similar to LineChart
|
|
97
69
|
const validateData = ( data: SeriesData[] ) => {
|
|
98
|
-
if ( ! data?.length ) return 'No data available';
|
|
70
|
+
if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' );
|
|
99
71
|
|
|
100
72
|
const hasInvalidData = data.some( series =>
|
|
101
73
|
series.data.some(
|
|
102
74
|
point =>
|
|
103
|
-
|
|
104
|
-
point.value === null ||
|
|
105
|
-
point.value === undefined ||
|
|
75
|
+
isInvalidReading( point.value, { allowMissing: true } ) ||
|
|
106
76
|
( ! point.label &&
|
|
107
77
|
( ! ( 'date' in point && point.date ) || isNaN( point.date.getTime() ) ) )
|
|
108
78
|
)
|
|
109
79
|
);
|
|
110
80
|
|
|
111
|
-
if ( hasInvalidData ) return 'Invalid data';
|
|
81
|
+
if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' );
|
|
112
82
|
return null;
|
|
113
83
|
};
|
|
114
84
|
|
|
@@ -155,8 +125,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
155
125
|
onPointerUp,
|
|
156
126
|
onDatumActivate,
|
|
157
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
|
+
|
|
158
135
|
const legendInteractive = legend.interactive ?? false;
|
|
159
136
|
const legendCollapseGroups = legend.collapseGroups ?? false;
|
|
137
|
+
const legendComparisonItem = legend.comparisonItem ?? false;
|
|
160
138
|
const horizontal = orientation === 'horizontal';
|
|
161
139
|
const chartId = useChartId( providedChartId );
|
|
162
140
|
const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
|
|
@@ -177,8 +155,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
177
155
|
|
|
178
156
|
// Create legend items using the reusable hook
|
|
179
157
|
const legendOptions = useMemo(
|
|
180
|
-
() => ( { collapseGroups: legendCollapseGroups } ),
|
|
181
|
-
[ legendCollapseGroups ]
|
|
158
|
+
() => ( { collapseGroups: legendCollapseGroups, comparisonItem: legendComparisonItem } ),
|
|
159
|
+
[ legendCollapseGroups, legendComparisonItem ]
|
|
182
160
|
);
|
|
183
161
|
const legendItems = useChartLegendItems( dataSorted, legendOptions );
|
|
184
162
|
|
|
@@ -192,12 +170,24 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
192
170
|
[ isSeriesVisible ]
|
|
193
171
|
);
|
|
194
172
|
|
|
173
|
+
const hasWholeNumberValues = useMemo(
|
|
174
|
+
() => hasOnlyWholeNumbers( dataSorted.filter( isSeriesRendered ) ),
|
|
175
|
+
[ dataSorted, isSeriesRendered ]
|
|
176
|
+
);
|
|
177
|
+
|
|
195
178
|
const chartOptions = useBarChartOptions(
|
|
196
179
|
dataWithVisibleZeros,
|
|
197
180
|
horizontal,
|
|
198
181
|
options,
|
|
199
182
|
isSeriesRendered
|
|
200
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
|
+
};
|
|
201
191
|
const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
|
|
202
192
|
const chartRef = useRef< HTMLDivElement >( null );
|
|
203
193
|
|
|
@@ -404,14 +394,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
404
394
|
|
|
405
395
|
// With a paired comparison value, show the category as the header and one row
|
|
406
396
|
// per period (primary + comparison).
|
|
407
|
-
if ( comparisonEntry && comparisonDatum
|
|
397
|
+
if ( comparisonEntry && comparisonDatum ) {
|
|
408
398
|
return (
|
|
409
399
|
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
410
400
|
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
|
|
411
|
-
{ renderTooltipRow( primaryKey,
|
|
401
|
+
{ renderTooltipRow( primaryKey, formatReading( nearestDatum.value ) ) }
|
|
412
402
|
{ renderTooltipRow(
|
|
413
403
|
comparisonEntry.series.label,
|
|
414
|
-
|
|
404
|
+
formatReading( comparisonDatum.value )
|
|
415
405
|
) }
|
|
416
406
|
</div>
|
|
417
407
|
);
|
|
@@ -420,7 +410,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
420
410
|
return (
|
|
421
411
|
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
422
412
|
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
|
|
423
|
-
{ renderTooltipRow( categoryLabel,
|
|
413
|
+
{ renderTooltipRow( categoryLabel, formatReading( nearestDatum.value ) ) }
|
|
424
414
|
</div>
|
|
425
415
|
);
|
|
426
416
|
},
|
|
@@ -482,34 +472,33 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
482
472
|
const createKeyboardHighlightStyle = useCallback( () => {
|
|
483
473
|
if ( selectedIndex === undefined ) return '';
|
|
484
474
|
|
|
485
|
-
// Use only primary entries
|
|
475
|
+
// Use only primary entries: comparison series have no .visx-bar elements, so
|
|
486
476
|
// their indices must not appear in the nth-child selector.
|
|
487
477
|
// Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
|
|
488
478
|
const primaryCount = primaryEntries.length;
|
|
489
|
-
|
|
490
|
-
const dataPointIndex = Math.floor( selectedIndex / primaryCount );
|
|
491
|
-
const seriesIndex = selectedIndex % primaryCount;
|
|
492
|
-
|
|
493
|
-
// Only highlight if we're within valid bounds
|
|
494
|
-
if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
|
|
479
|
+
if ( ! primaryCount ) {
|
|
495
480
|
return '';
|
|
496
481
|
}
|
|
497
|
-
|
|
482
|
+
const dataPointIndex = Math.floor( selectedIndex / primaryCount );
|
|
483
|
+
const seriesIndex = selectedIndex % primaryCount;
|
|
498
484
|
const seriesData = primaryEntries[ seriesIndex ]?.series;
|
|
499
|
-
|
|
485
|
+
const datum = seriesData?.data[ dataPointIndex ];
|
|
486
|
+
|
|
487
|
+
if ( ! seriesData || ! datum || ! isBarRendered( datum ) ) {
|
|
500
488
|
return '';
|
|
501
489
|
}
|
|
502
490
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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 ) );
|
|
508
497
|
|
|
509
498
|
// Use a CSS class selector instead of ID since useId() generates invalid CSS ID characters
|
|
510
499
|
const generatedStyles = `
|
|
511
500
|
.bar-chart[data-chart-id="bar-chart-${ chartId }"] .visx-bar-group .visx-bar:nth-child(${
|
|
512
|
-
|
|
501
|
+
domBarIndex + 1
|
|
513
502
|
}) {
|
|
514
503
|
stroke: #005fcc;
|
|
515
504
|
stroke-width: 2px;
|
|
@@ -633,21 +622,33 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
633
622
|
) }
|
|
634
623
|
|
|
635
624
|
{ ! allSeriesHidden && (
|
|
636
|
-
|
|
637
|
-
{
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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>
|
|
651
652
|
) }
|
|
652
653
|
|
|
653
654
|
{ withPatterns && (
|
|
@@ -671,7 +672,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
671
672
|
</>
|
|
672
673
|
) }
|
|
673
674
|
|
|
674
|
-
{ highlightedBarStyle &&
|
|
675
|
+
{ highlightedBarStyle && (
|
|
676
|
+
<style data-testid="bar-chart-keyboard-highlight">
|
|
677
|
+
{ highlightedBarStyle }
|
|
678
|
+
</style>
|
|
679
|
+
) }
|
|
675
680
|
|
|
676
681
|
{ allSeriesHidden ? (
|
|
677
682
|
<SvgEmptyState
|
|
@@ -726,10 +731,22 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
726
731
|
visx collapses the domain and the axes render squished at the top. Drop them
|
|
727
732
|
while the empty state stands in. */ }
|
|
728
733
|
{ ! allSeriesHidden && (
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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>
|
|
733
750
|
) }
|
|
734
751
|
|
|
735
752
|
{ withTooltips && (
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as BarChart, BarChartUnresponsive } from './bar-chart';
|
|
2
|
-
export type { BarChartProps, BandHighlightSelection } from './
|
|
2
|
+
export type { BarChartProps, BandHighlightSelection } from './types';
|
|
@@ -4,7 +4,7 @@ import { useDeepMemo } from '../../../hooks';
|
|
|
4
4
|
import { CATALOG_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
|
|
5
5
|
import type { BandScale } from './band-scale';
|
|
6
6
|
import type { DataPointDate } from '../../../types';
|
|
7
|
-
import type { BandHighlightSelection } from '../
|
|
7
|
+
import type { BandHighlightSelection } from '../types';
|
|
8
8
|
|
|
9
9
|
type Props = {
|
|
10
10
|
visible: boolean;
|
|
@@ -41,6 +41,8 @@ export function BandHighlight( { visible, horizontal, onChange }: Props ) {
|
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
datum: nearest.datum as DataPointDate,
|
|
44
|
+
key: nearest.key,
|
|
45
|
+
index: nearest.index,
|
|
44
46
|
x: horizontal ? ( margin?.left ?? 0 ) : position,
|
|
45
47
|
y: horizontal ? position : ( margin?.top ?? 0 ),
|
|
46
48
|
width: horizontal ? innerWidth : scale.bandwidth(),
|
|
@@ -2,7 +2,7 @@ import { DataContext, TooltipContext, useEventEmitter } from '@visx/xychart';
|
|
|
2
2
|
import { useCallback, useContext, useMemo } from 'react';
|
|
3
3
|
import { createGroupScale } from './band-scale';
|
|
4
4
|
import type { BandScale } from './band-scale';
|
|
5
|
-
import type { BarChartProps } from '../
|
|
5
|
+
import type { BarChartProps } from '../types';
|
|
6
6
|
|
|
7
7
|
type PointerHandler = NonNullable< Parameters< typeof useEventEmitter >[ 1 ] >;
|
|
8
8
|
type PointerSelection = Parameters< NonNullable< BarChartProps[ 'onPointerUp' ] > >[ 0 ];
|
|
@@ -9,5 +9,6 @@ export {
|
|
|
9
9
|
COMPARISON_TICK_GAP_FACTOR,
|
|
10
10
|
} from './comparison-constants';
|
|
11
11
|
export { BASE_BAND_PADDING_INNER } from './use-bar-chart-options';
|
|
12
|
+
export { countRenderedBars, isBarRendered } from './value-domain';
|
|
12
13
|
export { BandHighlight } from './band-highlight';
|
|
13
14
|
export { BandTooltip } from './band-tooltip';
|
|
@@ -7,7 +7,7 @@ import { GlobalChartsProvider } from '../../../../providers';
|
|
|
7
7
|
import { CATALOG_POINTERS } from '../../../../providers/chart-context/private/catalog-pointers';
|
|
8
8
|
import { BarChartUnresponsive } from '../../bar-chart';
|
|
9
9
|
import { BandHighlight } from '../band-highlight';
|
|
10
|
-
import type { BandHighlightSelection } from '../../
|
|
10
|
+
import type { BandHighlightSelection } from '../../types';
|
|
11
11
|
|
|
12
12
|
const xScale = scaleBand( { domain: [ 'Jan', 'Feb' ], range: [ 10, 210 ] } );
|
|
13
13
|
const dataContext = {
|
|
@@ -25,7 +25,7 @@ const Fixture = ( { label = 'Jan', onChange } ) => {
|
|
|
25
25
|
value={
|
|
26
26
|
{
|
|
27
27
|
tooltipOpen: true,
|
|
28
|
-
tooltipData: { nearestDatum: { key: 'scores', datum: { label, value: 50 } } },
|
|
28
|
+
tooltipData: { nearestDatum: { key: 'scores', index: 0, datum: { label, value: 50 } } },
|
|
29
29
|
} as never
|
|
30
30
|
}
|
|
31
31
|
>
|
|
@@ -38,6 +38,20 @@ const Fixture = ( { label = 'Jan', onChange } ) => {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
describe( 'BandHighlight', () => {
|
|
41
|
+
test( 'reports the initial selection and clears it on unmount', () => {
|
|
42
|
+
const onChange = jest.fn();
|
|
43
|
+
const { unmount } = render( <Fixture onChange={ onChange } /> );
|
|
44
|
+
expect( onChange ).toHaveBeenLastCalledWith(
|
|
45
|
+
expect.objectContaining( {
|
|
46
|
+
key: 'scores',
|
|
47
|
+
index: 0,
|
|
48
|
+
datum: { label: 'Jan', value: 50 },
|
|
49
|
+
} )
|
|
50
|
+
);
|
|
51
|
+
unmount();
|
|
52
|
+
expect( onChange ).toHaveBeenLastCalledWith( null );
|
|
53
|
+
} );
|
|
54
|
+
|
|
41
55
|
test( 'uses the catalog surface fill', () => {
|
|
42
56
|
render( <Fixture onChange={ undefined } /> );
|
|
43
57
|
expect( screen.getByTestId( 'bar-chart-band-highlight' ) ).toHaveAttribute(
|
|
@@ -79,6 +93,7 @@ describe( 'BandHighlight', () => {
|
|
|
79
93
|
test.each( [ { withTooltips: false }, { data: [] } ] )(
|
|
80
94
|
'clears the selected band when the highlight unmounts with %j',
|
|
81
95
|
async props => {
|
|
96
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
82
97
|
const user = userEvent.setup();
|
|
83
98
|
const onChange = jest.fn();
|
|
84
99
|
const chartProps = {
|
|
@@ -103,6 +118,17 @@ describe( 'BandHighlight', () => {
|
|
|
103
118
|
<BarChartUnresponsive { ...chartProps } { ...props } />
|
|
104
119
|
</GlobalChartsProvider>
|
|
105
120
|
);
|
|
121
|
+
rerender(
|
|
122
|
+
<GlobalChartsProvider>
|
|
123
|
+
<BarChartUnresponsive
|
|
124
|
+
{ ...chartProps }
|
|
125
|
+
{ ...props }
|
|
126
|
+
onBandHighlightChange={ jest.fn() }
|
|
127
|
+
/>
|
|
128
|
+
</GlobalChartsProvider>
|
|
129
|
+
);
|
|
130
|
+
expect( warn ).toHaveBeenCalledTimes( 'withTooltips' in props ? 1 : 0 );
|
|
131
|
+
warn.mockRestore();
|
|
106
132
|
expect( onChange ).toHaveBeenLastCalledWith( null );
|
|
107
133
|
}
|
|
108
134
|
);
|